import { Database } from 'better-sqlite3'; import { APP_CONFIG_TABLE_SCHEMA_SQL, LAST_SCANNED_AT_SQL, PROJECTS_TABLE_SCHEMA_SQL, PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL, SESSIONS_TABLE_SCHEMA_SQL, USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL, VAPID_KEYS_TABLE_SCHEMA_SQL, } from '@/modules/database/schema.js'; const SQLITE_UUID_SQL = ` lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(6))) `; type TableInfoRow = { name: string; pk: number; }; const addColumnToTableIfNotExists = ( db: Database, tableName: string, columnNames: string[], columnName: string, columnType: string ) => { if (!columnNames.includes(columnName)) { console.log(`Running migration: Adding ${columnName} column to ${tableName} table`); db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType}`); } }; const tableExists = (db: Database, tableName: string): boolean => Boolean( db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") .get(tableName) ); const getTableInfo = (db: Database, tableName: string): TableInfoRow[] => db.prepare(`PRAGMA table_info(${tableName})`).all() as TableInfoRow[]; const migrateLegacySessionNames = (db: Database): void => { const hasLegacySessionNamesTable = tableExists(db, 'session_names'); const hasSessionsTable = tableExists(db, 'sessions'); if (!hasLegacySessionNamesTable) { return; } if (hasSessionsTable) { console.log('Running migration: Merging session_names into sessions'); db.exec(` INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at) SELECT session_id, COALESCE(provider, 'claude'), custom_name, COALESCE(created_at, CURRENT_TIMESTAMP), COALESCE(updated_at, CURRENT_TIMESTAMP) FROM session_names WHERE true ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, custom_name = COALESCE(excluded.custom_name, sessions.custom_name), created_at = COALESCE(sessions.created_at, excluded.created_at), updated_at = COALESCE(excluded.updated_at, sessions.updated_at) `); db.exec('DROP TABLE session_names'); return; } console.log('Running migration: Renaming session_names table to sessions'); db.exec('ALTER TABLE session_names RENAME TO sessions'); }; const migrateLegacyWorkspaceTableIntoProjects = (db: Database): void => { db.exec(PROJECTS_TABLE_SCHEMA_SQL); if (!tableExists(db, 'workspace_original_paths')) { return; } console.log('Running migration: Migrating workspace_original_paths data into projects'); db.exec(` INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived) SELECT CASE WHEN workspace_id IS NULL OR trim(workspace_id) = '' THEN ${SQLITE_UUID_SQL} ELSE workspace_id END, workspace_path, custom_workspace_name, COALESCE(isStarred, 0), 0 FROM workspace_original_paths WHERE workspace_path IS NOT NULL AND trim(workspace_path) <> '' ON CONFLICT(project_path) DO UPDATE SET custom_project_name = COALESCE(projects.custom_project_name, excluded.custom_project_name), isStarred = COALESCE(projects.isStarred, excluded.isStarred) `); }; const rebuildProjectsTableWithPrimaryKeySchema = (db: Database): void => { const hasProjectsTable = tableExists(db, 'projects'); if (!hasProjectsTable) { db.exec(PROJECTS_TABLE_SCHEMA_SQL); return; } const projectsTableInfo = getTableInfo(db, 'projects'); const columnNames = projectsTableInfo.map((column) => column.name); const hasProjectIdPrimaryKey = projectsTableInfo.some( (column) => column.name === 'project_id' && column.pk === 1, ); if (hasProjectIdPrimaryKey) { addColumnToTableIfNotExists(db, 'projects', columnNames, 'custom_project_name', 'TEXT DEFAULT NULL'); addColumnToTableIfNotExists(db, 'projects', columnNames, 'isStarred', 'BOOLEAN DEFAULT 0'); addColumnToTableIfNotExists(db, 'projects', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0'); db.exec(` UPDATE projects SET project_id = ${SQLITE_UUID_SQL} WHERE project_id IS NULL OR trim(project_id) = '' `); return; } console.log('Running migration: Rebuilding projects table to enforce project_id primary key'); const projectPathExpression = columnNames.includes('project_path') ? 'project_path' : columnNames.includes('workspace_path') ? 'workspace_path' : 'NULL'; const customProjectNameExpression = columnNames.includes('custom_project_name') ? 'custom_project_name' : columnNames.includes('custom_workspace_name') ? 'custom_workspace_name' : 'NULL'; const isStarredExpression = columnNames.includes('isStarred') ? 'COALESCE(isStarred, 0)' : '0'; const isArchivedExpression = columnNames.includes('isArchived') ? 'COALESCE(isArchived, 0)' : '0'; const projectIdExpression = columnNames.includes('project_id') ? `CASE WHEN project_id IS NULL OR trim(project_id) = '' THEN ${SQLITE_UUID_SQL} ELSE project_id END` : SQLITE_UUID_SQL; db.exec('PRAGMA foreign_keys = OFF'); try { db.exec('BEGIN TRANSACTION'); db.exec('DROP TABLE IF EXISTS projects__new'); db.exec(` CREATE TABLE projects__new ( project_id TEXT PRIMARY KEY NOT NULL, project_path TEXT NOT NULL UNIQUE, custom_project_name TEXT DEFAULT NULL, isStarred BOOLEAN DEFAULT 0, isArchived BOOLEAN DEFAULT 0 ) `); db.exec(` WITH source_rows AS ( SELECT ${projectPathExpression} AS project_path, ${customProjectNameExpression} AS custom_project_name, ${isStarredExpression} AS isStarred, ${isArchivedExpression} AS isArchived, ${projectIdExpression} AS candidate_project_id, rowid AS source_rowid FROM projects WHERE ${projectPathExpression} IS NOT NULL AND trim(${projectPathExpression}) <> '' ), deduped_paths AS ( SELECT project_path, custom_project_name, isStarred, isArchived, candidate_project_id, source_rowid, ROW_NUMBER() OVER (PARTITION BY project_path ORDER BY source_rowid) AS project_path_rank FROM source_rows ), prepared_rows AS ( SELECT CASE WHEN ROW_NUMBER() OVER (PARTITION BY candidate_project_id ORDER BY source_rowid) = 1 THEN candidate_project_id ELSE ${SQLITE_UUID_SQL} END AS project_id, project_path, custom_project_name, isStarred, isArchived FROM deduped_paths WHERE project_path_rank = 1 ) INSERT INTO projects__new ( project_id, project_path, custom_project_name, isStarred, isArchived ) SELECT project_id, project_path, custom_project_name, isStarred, isArchived FROM prepared_rows `); db.exec('DROP TABLE projects'); db.exec('ALTER TABLE projects__new RENAME TO projects'); db.exec('COMMIT'); } catch (migrationError) { db.exec('ROLLBACK'); throw migrationError; } finally { db.exec('PRAGMA foreign_keys = ON'); } }; const rebuildSessionsTableWithProjectSchema = (db: Database): void => { const hasSessions = tableExists(db, 'sessions'); if (!hasSessions) { db.exec(SESSIONS_TABLE_SCHEMA_SQL); return; } const sessionsTableInfo = getTableInfo(db, 'sessions'); const columnNames = sessionsTableInfo.map((column) => column.name); const primaryKeyColumns = sessionsTableInfo .filter((column) => column.pk > 0) .sort((a, b) => a.pk - b.pk) .map((column) => column.name); const shouldRebuild = !columnNames.includes('project_path') || primaryKeyColumns.length !== 1 || primaryKeyColumns[0] !== 'session_id' || !columnNames.includes('provider'); if (!shouldRebuild) { addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT'); addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME'); addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME'); db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)'); db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)'); return; } console.log('Running migration: Rebuilding sessions table to project-based schema'); const projectPathExpression = columnNames.includes('project_path') ? 'project_path' : columnNames.includes('workspace_path') ? 'workspace_path' : 'NULL'; const providerExpression = columnNames.includes('provider') ? "COALESCE(provider, 'claude')" : "'claude'"; const customNameExpression = columnNames.includes('custom_name') ? 'custom_name' : 'NULL'; const jsonlPathExpression = columnNames.includes('jsonl_path') ? 'jsonl_path' : 'NULL'; const createdAtExpression = columnNames.includes('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; const updatedAtExpression = columnNames.includes('updated_at') ? 'COALESCE(updated_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; db.exec('PRAGMA foreign_keys = OFF'); try { db.exec('BEGIN TRANSACTION'); db.exec('DROP TABLE IF EXISTS sessions__new'); db.exec(` CREATE TABLE sessions__new ( session_id TEXT NOT NULL, provider TEXT NOT NULL DEFAULT 'claude', custom_name TEXT, project_path TEXT, jsonl_path TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (session_id), FOREIGN KEY (project_path) REFERENCES projects(project_path) ON DELETE SET NULL ON UPDATE CASCADE ) `); db.exec(` WITH source_rows AS ( SELECT session_id, ${providerExpression} AS provider, ${customNameExpression} AS custom_name, ${projectPathExpression} AS project_path, ${jsonlPathExpression} AS jsonl_path, ${createdAtExpression} AS created_at, ${updatedAtExpression} AS updated_at, rowid AS source_rowid FROM sessions WHERE session_id IS NOT NULL AND trim(session_id) <> '' ), ranked_rows AS ( SELECT session_id, provider, custom_name, project_path, jsonl_path, created_at, updated_at, ROW_NUMBER() OVER ( PARTITION BY session_id ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, source_rowid DESC ) AS session_rank FROM source_rows ) INSERT INTO sessions__new ( session_id, provider, custom_name, project_path, jsonl_path, created_at, updated_at ) SELECT session_id, provider, custom_name, project_path, jsonl_path, created_at, updated_at FROM ranked_rows WHERE session_rank = 1 `); db.exec('DROP TABLE sessions'); db.exec('ALTER TABLE sessions__new RENAME TO sessions'); db.exec('COMMIT'); } catch (migrationError) { db.exec('ROLLBACK'); throw migrationError; } finally { db.exec('PRAGMA foreign_keys = ON'); } }; const ensureProjectsForSessionPaths = (db: Database): void => { if (!tableExists(db, 'sessions')) { return; } db.exec(` INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived) SELECT ${SQLITE_UUID_SQL}, project_path, NULL, 0, 0 FROM sessions WHERE project_path IS NOT NULL AND trim(project_path) <> '' ON CONFLICT(project_path) DO NOTHING `); }; export const runMigrations = (db: Database) => { try { const usersTableInfo = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]; const userColumnNames = usersTableInfo.map((column) => column.name); addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_name', 'TEXT'); addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_email', 'TEXT'); addColumnToTableIfNotExists( db, 'users', userColumnNames, 'has_completed_onboarding', 'BOOLEAN DEFAULT 0' ); db.exec(APP_CONFIG_TABLE_SCHEMA_SQL); db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL); db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL); db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL); db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)'); db.exec(PROJECTS_TABLE_SCHEMA_SQL); rebuildProjectsTableWithPrimaryKeySchema(db); migrateLegacyWorkspaceTableIntoProjects(db); rebuildSessionsTableWithProjectSchema(db); migrateLegacySessionNames(db); ensureProjectsForSessionPaths(db); db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)'); db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)'); db.exec('DROP INDEX IF EXISTS idx_session_names_lookup'); db.exec('DROP INDEX IF EXISTS idx_sessions_workspace_path'); db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_is_starred'); db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_workspace_id'); if (tableExists(db, 'workspace_original_paths')) { console.log('Running migration: Dropping legacy workspace_original_paths table'); db.exec('DROP TABLE workspace_original_paths'); } db.exec(LAST_SCANNED_AT_SQL); console.log('Database migrations completed successfully'); } catch (error: any) { console.error('Error running migrations:', error.message); throw error; } };