mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-30 09:21:33 +00:00
444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
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;
|
|
}
|
|
};
|