Skip to content

Commit 4b26718

Browse files
7418claude
andcommitted
fix: DB migration race condition with concurrent Next.js workers
Next.js build uses multiple workers for prerendering, which can trigger concurrent getDb() → migrateDb() calls. PRAGMA table_info check and ALTER TABLE ADD COLUMN are not atomic, causing "duplicate column name" errors when two workers race on the same migration. Fix: wrap all ALTER TABLE ADD COLUMN calls in safeAddColumn() helper that catches and ignores "duplicate column name" SqliteError. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f06b14d commit 4b26718

1 file changed

Lines changed: 38 additions & 28 deletions

File tree

src/lib/db.ts

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -281,21 +281,31 @@ function initDb(db: Database.Database): void {
281281
migrateDb(db);
282282
}
283283

284+
/** Safely add a column — ignores "duplicate column name" errors from concurrent workers. */
285+
function safeAddColumn(db: Database.Database, sql: string): void {
286+
try {
287+
db.exec(sql);
288+
} catch (err: unknown) {
289+
if (err instanceof Error && err.message.includes('duplicate column name')) return;
290+
throw err;
291+
}
292+
}
293+
284294
function migrateDb(db: Database.Database): void {
285295
const columns = db.prepare("PRAGMA table_info(chat_sessions)").all() as { name: string }[];
286296
const colNames = columns.map(c => c.name);
287297

288298
if (!colNames.includes('model')) {
289-
db.exec("ALTER TABLE chat_sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''");
299+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''");
290300
}
291301
if (!colNames.includes('system_prompt')) {
292-
db.exec("ALTER TABLE chat_sessions ADD COLUMN system_prompt TEXT NOT NULL DEFAULT ''");
302+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN system_prompt TEXT NOT NULL DEFAULT ''");
293303
}
294304
if (!colNames.includes('sdk_session_id')) {
295-
db.exec("ALTER TABLE chat_sessions ADD COLUMN sdk_session_id TEXT NOT NULL DEFAULT ''");
305+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN sdk_session_id TEXT NOT NULL DEFAULT ''");
296306
}
297307
if (!colNames.includes('project_name')) {
298-
db.exec("ALTER TABLE chat_sessions ADD COLUMN project_name TEXT NOT NULL DEFAULT ''");
308+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN project_name TEXT NOT NULL DEFAULT ''");
299309
// Backfill project_name from working_directory for existing rows
300310
db.exec(`
301311
UPDATE chat_sessions
@@ -307,33 +317,33 @@ function migrateDb(db: Database.Database): void {
307317
`);
308318
}
309319
if (!colNames.includes('status')) {
310-
db.exec("ALTER TABLE chat_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
320+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
311321
}
312322
if (!colNames.includes('mode')) {
313-
db.exec("ALTER TABLE chat_sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'code'");
323+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'code'");
314324
}
315325
if (!colNames.includes('provider_name')) {
316-
db.exec("ALTER TABLE chat_sessions ADD COLUMN provider_name TEXT NOT NULL DEFAULT ''");
326+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN provider_name TEXT NOT NULL DEFAULT ''");
317327
}
318328
if (!colNames.includes('provider_id')) {
319-
db.exec("ALTER TABLE chat_sessions ADD COLUMN provider_id TEXT NOT NULL DEFAULT ''");
329+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN provider_id TEXT NOT NULL DEFAULT ''");
320330
}
321331
if (!colNames.includes('sdk_cwd')) {
322-
db.exec("ALTER TABLE chat_sessions ADD COLUMN sdk_cwd TEXT NOT NULL DEFAULT ''");
332+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN sdk_cwd TEXT NOT NULL DEFAULT ''");
323333
// Backfill sdk_cwd from working_directory for existing sessions
324334
db.exec("UPDATE chat_sessions SET sdk_cwd = working_directory WHERE sdk_cwd = '' AND working_directory != ''");
325335
}
326336
if (!colNames.includes('runtime_status')) {
327-
db.exec("ALTER TABLE chat_sessions ADD COLUMN runtime_status TEXT NOT NULL DEFAULT 'idle'");
337+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN runtime_status TEXT NOT NULL DEFAULT 'idle'");
328338
}
329339
if (!colNames.includes('runtime_updated_at')) {
330-
db.exec("ALTER TABLE chat_sessions ADD COLUMN runtime_updated_at TEXT NOT NULL DEFAULT ''");
340+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN runtime_updated_at TEXT NOT NULL DEFAULT ''");
331341
}
332342
if (!colNames.includes('runtime_error')) {
333-
db.exec("ALTER TABLE chat_sessions ADD COLUMN runtime_error TEXT NOT NULL DEFAULT ''");
343+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN runtime_error TEXT NOT NULL DEFAULT ''");
334344
}
335345
if (!colNames.includes('permission_profile')) {
336-
db.exec("ALTER TABLE chat_sessions ADD COLUMN permission_profile TEXT NOT NULL DEFAULT 'default'");
346+
safeAddColumn(db, "ALTER TABLE chat_sessions ADD COLUMN permission_profile TEXT NOT NULL DEFAULT 'default'");
337347
}
338348
db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_runtime_status ON chat_sessions(runtime_status)");
339349

@@ -352,7 +362,7 @@ function migrateDb(db: Database.Database): void {
352362
const msgColNames = msgColumns.map(c => c.name);
353363

354364
if (!msgColNames.includes('token_usage')) {
355-
db.exec("ALTER TABLE messages ADD COLUMN token_usage TEXT");
365+
safeAddColumn(db, "ALTER TABLE messages ADD COLUMN token_usage TEXT");
356366
}
357367

358368
// Ensure tasks table exists for databases created before this migration
@@ -374,10 +384,10 @@ function migrateDb(db: Database.Database): void {
374384
const taskColumns = db.prepare("PRAGMA table_info(tasks)").all() as { name: string }[];
375385
const taskColNames = taskColumns.map(c => c.name);
376386
if (!taskColNames.includes('source')) {
377-
db.exec("ALTER TABLE tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'user'");
387+
safeAddColumn(db, "ALTER TABLE tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'user'");
378388
}
379389
if (!taskColNames.includes('sort_order')) {
380-
db.exec("ALTER TABLE tasks ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0");
390+
safeAddColumn(db, "ALTER TABLE tasks ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0");
381391
}
382392

383393
// Ensure api_providers table exists for databases created before this migration
@@ -402,19 +412,19 @@ function migrateDb(db: Database.Database): void {
402412
const providerCols = db.prepare("PRAGMA table_info(api_providers)").all() as { name: string }[];
403413
const provColNames = providerCols.map(c => c.name);
404414
if (!provColNames.includes('protocol')) {
405-
db.exec("ALTER TABLE api_providers ADD COLUMN protocol TEXT NOT NULL DEFAULT ''");
415+
safeAddColumn(db, "ALTER TABLE api_providers ADD COLUMN protocol TEXT NOT NULL DEFAULT ''");
406416
}
407417
if (!provColNames.includes('headers_json')) {
408-
db.exec("ALTER TABLE api_providers ADD COLUMN headers_json TEXT NOT NULL DEFAULT '{}'");
418+
safeAddColumn(db, "ALTER TABLE api_providers ADD COLUMN headers_json TEXT NOT NULL DEFAULT '{}'");
409419
}
410420
if (!provColNames.includes('env_overrides_json')) {
411-
db.exec("ALTER TABLE api_providers ADD COLUMN env_overrides_json TEXT NOT NULL DEFAULT ''");
421+
safeAddColumn(db, "ALTER TABLE api_providers ADD COLUMN env_overrides_json TEXT NOT NULL DEFAULT ''");
412422
}
413423
if (!provColNames.includes('role_models_json')) {
414-
db.exec("ALTER TABLE api_providers ADD COLUMN role_models_json TEXT NOT NULL DEFAULT '{}'");
424+
safeAddColumn(db, "ALTER TABLE api_providers ADD COLUMN role_models_json TEXT NOT NULL DEFAULT '{}'");
415425
}
416426
if (!provColNames.includes('options_json')) {
417-
db.exec("ALTER TABLE api_providers ADD COLUMN options_json TEXT NOT NULL DEFAULT '{}'");
427+
safeAddColumn(db, "ALTER TABLE api_providers ADD COLUMN options_json TEXT NOT NULL DEFAULT '{}'");
418428
}
419429
}
420430

@@ -534,7 +544,7 @@ function migrateDb(db: Database.Database): void {
534544

535545
// Add favorited column to media_generations if missing
536546
try {
537-
db.exec("ALTER TABLE media_generations ADD COLUMN favorited INTEGER NOT NULL DEFAULT 0");
547+
safeAddColumn(db, "ALTER TABLE media_generations ADD COLUMN favorited INTEGER NOT NULL DEFAULT 0");
538548
} catch {
539549
// Column already exists
540550
}
@@ -690,13 +700,13 @@ function migrateDb(db: Database.Database): void {
690700
const permLinkCols = db.prepare("PRAGMA table_info(channel_permission_links)").all() as { name: string }[];
691701
const permLinkColNames = permLinkCols.map(c => c.name);
692702
if (permLinkColNames.length > 0 && !permLinkColNames.includes('tool_name')) {
693-
db.exec("ALTER TABLE channel_permission_links ADD COLUMN tool_name TEXT NOT NULL DEFAULT ''");
703+
safeAddColumn(db, "ALTER TABLE channel_permission_links ADD COLUMN tool_name TEXT NOT NULL DEFAULT ''");
694704
}
695705
if (permLinkColNames.length > 0 && !permLinkColNames.includes('suggestions')) {
696-
db.exec("ALTER TABLE channel_permission_links ADD COLUMN suggestions TEXT NOT NULL DEFAULT ''");
706+
safeAddColumn(db, "ALTER TABLE channel_permission_links ADD COLUMN suggestions TEXT NOT NULL DEFAULT ''");
697707
}
698708
if (permLinkColNames.length > 0 && !permLinkColNames.includes('resolved')) {
699-
db.exec("ALTER TABLE channel_permission_links ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0");
709+
safeAddColumn(db, "ALTER TABLE channel_permission_links ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0");
700710
}
701711

702712
// Channel configs table (structured config for channel plugins)
@@ -770,18 +780,18 @@ function migrateDb(db: Database.Database): void {
770780
{
771781
const descCols = db.prepare("PRAGMA table_info(cli_tool_descriptions)").all() as { name: string }[];
772782
if (!descCols.some(c => c.name === 'structured_json')) {
773-
db.exec("ALTER TABLE cli_tool_descriptions ADD COLUMN structured_json TEXT NOT NULL DEFAULT ''");
783+
safeAddColumn(db, "ALTER TABLE cli_tool_descriptions ADD COLUMN structured_json TEXT NOT NULL DEFAULT ''");
774784
}
775785
}
776786

777787
// Migration: add install_method column to cli_tools_custom
778788
{
779789
const customCols = db.prepare("PRAGMA table_info(cli_tools_custom)").all() as { name: string }[];
780790
if (!customCols.some(c => c.name === 'install_method')) {
781-
db.exec("ALTER TABLE cli_tools_custom ADD COLUMN install_method TEXT NOT NULL DEFAULT 'unknown'");
791+
safeAddColumn(db, "ALTER TABLE cli_tools_custom ADD COLUMN install_method TEXT NOT NULL DEFAULT 'unknown'");
782792
}
783793
if (!customCols.some(c => c.name === 'install_package')) {
784-
db.exec("ALTER TABLE cli_tools_custom ADD COLUMN install_package TEXT NOT NULL DEFAULT ''");
794+
safeAddColumn(db, "ALTER TABLE cli_tools_custom ADD COLUMN install_package TEXT NOT NULL DEFAULT ''");
785795
}
786796
}
787797
}

0 commit comments

Comments
 (0)