Spaces:
Running
Running
| import Database from 'better-sqlite3'; | |
| import path from 'path'; | |
| import fs from 'fs'; | |
| import { config } from '../config'; | |
| let db: Database.Database; | |
| function isValidSqliteFile(filePath: string): boolean { | |
| if (!fs.existsSync(filePath)) return false; | |
| try { | |
| const fd = fs.openSync(filePath, 'r'); | |
| const buf = Buffer.alloc(16); | |
| fs.readSync(fd, buf, 0, 16, 0); | |
| fs.closeSync(fd); | |
| return buf.toString('utf8', 0, 15) === 'SQLite format 3'; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function nukeDatabaseFiles(): void { | |
| for (const suffix of ['', '-wal', '-shm', '-journal']) { | |
| try { | |
| fs.unlinkSync(config.databasePath + suffix); | |
| console.log(`[db] Removed ${config.databasePath + suffix}`); | |
| } catch {} | |
| } | |
| } | |
| function openDatabase(): Database.Database { | |
| const dbDir = path.dirname(config.databasePath); | |
| if (!fs.existsSync(dbDir)) { | |
| fs.mkdirSync(dbDir, { recursive: true }); | |
| } | |
| // Pre-flight check: if the existing file isn't a valid SQLite file, nuke it. | |
| if (fs.existsSync(config.databasePath) && !isValidSqliteFile(config.databasePath)) { | |
| console.error('[db] Existing file is not a valid SQLite database. Removing it.'); | |
| nukeDatabaseFiles(); | |
| } | |
| let database: Database.Database; | |
| try { | |
| database = new Database(config.databasePath); | |
| } catch (err) { | |
| console.error('[db] Failed to open database, recreating:', err); | |
| nukeDatabaseFiles(); | |
| database = new Database(config.databasePath); | |
| } | |
| // Try pragmas. If they fail (corruption), nuke and retry. | |
| try { | |
| database.pragma('journal_mode = DELETE'); | |
| database.pragma('foreign_keys = ON'); | |
| } catch (err: any) { | |
| console.error('[db] Pragma failed, recreating database:', err); | |
| try { database.close(); } catch {} | |
| nukeDatabaseFiles(); | |
| database = new Database(config.databasePath); | |
| database.pragma('journal_mode = DELETE'); | |
| database.pragma('foreign_keys = ON'); | |
| } | |
| // Run integrity check. If fails, nuke and recreate. | |
| try { | |
| const integrity = database.pragma('integrity_check') as Array<{ integrity_check: string }>; | |
| const result = integrity[0]?.integrity_check; | |
| if (result !== 'ok') { | |
| console.error(`[db] Integrity check failed: ${result}. Recreating.`); | |
| database.close(); | |
| nukeDatabaseFiles(); | |
| database = new Database(config.databasePath); | |
| database.pragma('journal_mode = DELETE'); | |
| database.pragma('foreign_keys = ON'); | |
| } | |
| } catch (err) { | |
| console.error('[db] Integrity check threw, recreating:', err); | |
| try { database.close(); } catch {} | |
| nukeDatabaseFiles(); | |
| database = new Database(config.databasePath); | |
| database.pragma('journal_mode = DELETE'); | |
| database.pragma('foreign_keys = ON'); | |
| } | |
| return database; | |
| } | |
| function migrateSchema(): void { | |
| const database = db; | |
| const userCols = database.prepare("PRAGMA table_info(users)").all() as any[]; | |
| const userNames = userCols.map((c: any) => c.name); | |
| if (!userNames.includes('google_id')) { | |
| database.exec("ALTER TABLE users ADD COLUMN google_id TEXT"); | |
| } | |
| if (!userNames.includes('avatar_url')) { | |
| database.exec("ALTER TABLE users ADD COLUMN avatar_url TEXT"); | |
| } | |
| if (!userNames.includes('avatar_path')) { | |
| database.exec("ALTER TABLE users ADD COLUMN avatar_path TEXT"); | |
| } | |
| database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id)"); | |
| const sessionCols = database.prepare("PRAGMA table_info(sessions)").all() as any[]; | |
| const sessionNames = sessionCols.map((c: any) => c.name); | |
| if (!sessionNames.includes('device_info')) { | |
| database.exec("ALTER TABLE sessions ADD COLUMN device_info TEXT DEFAULT ''"); | |
| } | |
| if (!sessionNames.includes('ip')) { | |
| database.exec("ALTER TABLE sessions ADD COLUMN ip TEXT DEFAULT ''"); | |
| } | |
| if (!sessionNames.includes('user_agent')) { | |
| database.exec("ALTER TABLE sessions ADD COLUMN user_agent TEXT DEFAULT ''"); | |
| } | |
| if (!sessionNames.includes('last_active')) { | |
| database.exec("ALTER TABLE sessions ADD COLUMN last_active INTEGER DEFAULT 0"); | |
| } | |
| } | |
| export function getDatabase(): Database.Database { | |
| if (!db) { | |
| db = openDatabase(); | |
| initializeSchema(); | |
| migrateSchema(); | |
| } | |
| return db; | |
| } | |
| function initializeSchema(): void { | |
| const database = db; | |
| database.exec(` | |
| CREATE TABLE IF NOT EXISTS users ( | |
| id TEXT PRIMARY KEY, | |
| email TEXT UNIQUE NOT NULL, | |
| username TEXT UNIQUE NOT NULL, | |
| password_hash TEXT NOT NULL, | |
| verified INTEGER DEFAULT 0, | |
| verification_token TEXT, | |
| reset_token TEXT, | |
| reset_token_expires INTEGER, | |
| google_id TEXT, | |
| avatar_url TEXT, | |
| avatar_path TEXT, | |
| created_at INTEGER NOT NULL, | |
| updated_at INTEGER NOT NULL | |
| ); | |
| CREATE TABLE IF NOT EXISTS projects ( | |
| id TEXT PRIMARY KEY, | |
| user_id TEXT NOT NULL, | |
| name TEXT NOT NULL, | |
| framework TEXT NOT NULL, | |
| description TEXT, | |
| encrypted_data TEXT NOT NULL, | |
| encryption_iv TEXT NOT NULL, | |
| created_at INTEGER NOT NULL, | |
| updated_at INTEGER NOT NULL, | |
| FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | |
| ); | |
| CREATE TABLE IF NOT EXISTS sessions ( | |
| id TEXT PRIMARY KEY, | |
| user_id TEXT NOT NULL, | |
| token TEXT UNIQUE NOT NULL, | |
| device_info TEXT DEFAULT '', | |
| ip TEXT DEFAULT '', | |
| user_agent TEXT DEFAULT '', | |
| last_active INTEGER DEFAULT 0, | |
| expires_at INTEGER NOT NULL, | |
| created_at INTEGER NOT NULL, | |
| FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | |
| ); | |
| CREATE TABLE IF NOT EXISTS project_collaborators ( | |
| project_id TEXT NOT NULL, | |
| user_id TEXT NOT NULL, | |
| permission TEXT NOT NULL DEFAULT 'edit' CHECK(permission IN ('view','edit','admin')), | |
| added_by TEXT NOT NULL, | |
| created_at INTEGER NOT NULL, | |
| PRIMARY KEY (project_id, user_id), | |
| FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, | |
| FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, | |
| FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE CASCADE | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id); | |
| CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); | |
| CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); | |
| CREATE INDEX IF NOT EXISTS idx_collaborators_project ON project_collaborators(project_id); | |
| CREATE INDEX IF NOT EXISTS idx_collaborators_user ON project_collaborators(user_id); | |
| `); | |
| } | |
| export function closeDatabase(): void { | |
| if (db) { | |
| try { db.close(); } catch {} | |
| } | |
| } | |