diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index 402e3b8..3cbfb21 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -20,6 +20,20 @@ function getAppliedSchemaMigrations( .all() as Array<{ version: number; name: string }>; } +function getExpectedSchemaMigrations(): Array<{ + version: number; + name: string; +}> { + return [ + { version: 1, name: 'legacy_schema_bundle' }, + { version: 2, name: 'scheduled_task_columns' }, + { version: 3, name: 'room_registration_canonical_columns' }, + { version: 4, name: 'message_seq_and_work_item_indexes' }, + { version: 5, name: 'sessions_composite_key' }, + { version: 6, name: 'chat_channel_metadata' }, + ]; +} + describe('initializeDatabaseSchema', () => { const tempDirs: string[] = []; @@ -35,12 +49,9 @@ describe('initializeDatabaseSchema', () => { try { initializeDatabaseSchema(database); - expect(getAppliedSchemaMigrations(database)).toEqual([ - { - version: 1, - name: 'legacy_schema_bundle', - }, - ]); + expect(getAppliedSchemaMigrations(database)).toEqual( + getExpectedSchemaMigrations(), + ); } finally { database.close(); } @@ -53,12 +64,9 @@ describe('initializeDatabaseSchema', () => { initializeDatabaseSchema(database); initializeDatabaseSchema(database); - expect(getAppliedSchemaMigrations(database)).toEqual([ - { - version: 1, - name: 'legacy_schema_bundle', - }, - ]); + expect(getAppliedSchemaMigrations(database)).toEqual( + getExpectedSchemaMigrations(), + ); } finally { database.close(); } @@ -86,12 +94,9 @@ describe('initializeDatabaseSchema', () => { try { initializeDatabaseSchema(reopened); - expect(getAppliedSchemaMigrations(reopened)).toEqual([ - { - version: 1, - name: 'legacy_schema_bundle', - }, - ]); + expect(getAppliedSchemaMigrations(reopened)).toEqual( + getExpectedSchemaMigrations(), + ); } finally { reopened.close(); } diff --git a/src/db/migrations/002_scheduled-task-columns.ts b/src/db/migrations/002_scheduled-task-columns.ts new file mode 100644 index 0000000..fe18641 --- /dev/null +++ b/src/db/migrations/002_scheduled-task-columns.ts @@ -0,0 +1,41 @@ +import type { SchemaMigrationDefinition } from './types.js'; +import { tryExecMigration } from './helpers.js'; + +export const SCHEDULED_TASK_COLUMNS_MIGRATION = { + version: 2, + name: 'scheduled_task_columns', + apply(database) { + tryExecMigration( + database, + `ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`, + ); + tryExecMigration( + database, + `ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`, + ); + tryExecMigration( + database, + `ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE scheduled_tasks ADD COLUMN suspended_until TEXT`, + ); + }, +} satisfies SchemaMigrationDefinition; diff --git a/src/db/migrations/003_room-registration-canonical-columns.ts b/src/db/migrations/003_room-registration-canonical-columns.ts new file mode 100644 index 0000000..200026b --- /dev/null +++ b/src/db/migrations/003_room-registration-canonical-columns.ts @@ -0,0 +1,72 @@ +import type { SchemaMigrationDefinition } from './types.js'; +import { tableHasColumn, tryExecMigration } from './helpers.js'; + +export const ROOM_REGISTRATION_CANONICAL_COLUMNS_MIGRATION = { + version: 3, + name: 'room_registration_canonical_columns', + apply(database) { + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN mode_source TEXT NOT NULL DEFAULT 'explicit'`, + ); + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN name TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN folder TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`, + ); + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`, + ); + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN work_dir TEXT`, + ); + tryExecMigration( + database, + `ALTER TABLE room_settings ADD COLUMN created_at TEXT`, + ); + + if (tableHasColumn(database, 'room_settings', 'created_at')) { + database.exec(` + UPDATE room_settings + SET created_at = COALESCE(created_at, updated_at, CURRENT_TIMESTAMP) + `); + } + + database.exec(` + CREATE TABLE IF NOT EXISTS room_role_overrides ( + chat_jid TEXT NOT NULL, + role TEXT NOT NULL, + agent_type TEXT NOT NULL, + agent_config_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (chat_jid, role), + CHECK (role IN ('owner', 'reviewer', 'arbiter')), + CHECK (agent_type IN ('claude-code', 'codex')) + ) + `); + + database.exec( + `UPDATE room_settings + SET mode_source = 'explicit' + WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`, + ); + }, +} satisfies SchemaMigrationDefinition; diff --git a/src/db/migrations/004_message-seq-and-work-item-indexes.ts b/src/db/migrations/004_message-seq-and-work-item-indexes.ts new file mode 100644 index 0000000..c842f3a --- /dev/null +++ b/src/db/migrations/004_message-seq-and-work-item-indexes.ts @@ -0,0 +1,36 @@ +import type { SchemaMigrationDefinition } from './types.js'; +import { backfillMessageSeq, tryExecMigration } from './helpers.js'; + +export const MESSAGE_SEQ_AND_WORK_ITEM_INDEXES_MIGRATION = { + version: 4, + name: 'message_seq_and_work_item_indexes', + apply(database, args) { + try { + database.exec( + `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, + ); + database + .prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`) + .run(`${args.assistantName}:%`); + } catch { + /* column already exists */ + } + + tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`); + backfillMessageSeq(database); + + database.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq); + CREATE INDEX IF NOT EXISTS idx_messages_chat_jid_seq ON messages(chat_jid, seq); + `); + database.exec(`DROP INDEX IF EXISTS idx_work_items_group_agent;`); + database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_work_items_group_agent + ON work_items(chat_jid, agent_type, service_id, delivery_role, status); + CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open + ON work_items(chat_jid, agent_type, IFNULL(service_id, ''), IFNULL(delivery_role, '')) + WHERE status IN ('produced', 'delivery_retry'); + `); + }, +} satisfies SchemaMigrationDefinition; diff --git a/src/db/migrations/005_sessions-composite-key.ts b/src/db/migrations/005_sessions-composite-key.ts new file mode 100644 index 0000000..97c6d25 --- /dev/null +++ b/src/db/migrations/005_sessions-composite-key.ts @@ -0,0 +1,17 @@ +import { inferAgentTypeFromServiceShadow } from '../../role-service-shadow.js'; +import { + backfillLegacyServiceSessions, + dropLegacyServiceSessionsTable, + migrateSessionsTableToCompositePk, +} from '../sessions.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +export const SESSIONS_COMPOSITE_KEY_MIGRATION = { + version: 5, + name: 'sessions_composite_key', + apply(database) { + migrateSessionsTableToCompositePk(database, 'claude-code'); + backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow); + dropLegacyServiceSessionsTable(database); + }, +} satisfies SchemaMigrationDefinition; diff --git a/src/db/migrations/006_chat-channel-metadata.ts b/src/db/migrations/006_chat-channel-metadata.ts new file mode 100644 index 0000000..5bfadf6 --- /dev/null +++ b/src/db/migrations/006_chat-channel-metadata.ts @@ -0,0 +1,26 @@ +import type { SchemaMigrationDefinition } from './types.js'; + +export const CHAT_CHANNEL_METADATA_MIGRATION = { + version: 6, + name: 'chat_channel_metadata', + apply(database) { + try { + database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); + database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`); + database.exec( + `UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`, + ); + database.exec( + `UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`, + ); + database.exec( + `UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`, + ); + database.exec( + `UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`, + ); + } catch { + /* columns already exist */ + } + }, +} satisfies SchemaMigrationDefinition; diff --git a/src/db/migrations/helpers.ts b/src/db/migrations/helpers.ts new file mode 100644 index 0000000..92d368b --- /dev/null +++ b/src/db/migrations/helpers.ts @@ -0,0 +1,64 @@ +import { Database } from 'bun:sqlite'; + +function getTableColumns(database: Database, tableName: string): string[] { + return ( + database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ + name: string; + }> + ).map((column) => column.name); +} + +export function tableHasColumn( + database: Database, + tableName: string, + columnName: string, +): boolean { + return getTableColumns(database, tableName).includes(columnName); +} + +export function tryExecMigration(database: Database, sql: string): void { + try { + database.exec(sql); + } catch { + /* column already exists */ + } +} + +export function backfillMessageSeq(database: Database): void { + const rows = database + .prepare( + `SELECT rowid, seq + FROM messages + ORDER BY CASE WHEN seq IS NULL THEN 1 ELSE 0 END, seq, timestamp, rowid`, + ) + .all() as Array<{ rowid: number; seq: number | null }>; + if (rows.length === 0) { + return; + } + + let nextSeq = 1; + const assignSeq = database.prepare( + 'UPDATE messages SET seq = ? WHERE rowid = ? AND seq IS NULL', + ); + const tx = database.transaction(() => { + for (const row of rows) { + if (row.seq === null) { + assignSeq.run(nextSeq, row.rowid); + } + nextSeq = Math.max(nextSeq, (row.seq ?? nextSeq) + 1); + } + }); + tx(); + + const maxSeqRow = database + .prepare('SELECT MAX(seq) AS maxSeq FROM messages') + .get() as { maxSeq: number | null }; + const maxSeq = maxSeqRow.maxSeq ?? 0; + if (maxSeq > 0) { + database + .prepare('INSERT OR IGNORE INTO message_sequence (id) VALUES (?)') + .run(maxSeq); + } +} + +export { getTableColumns }; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 1dc69f9..44fd457 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -1,6 +1,11 @@ import type { Database } from 'bun:sqlite'; import { LEGACY_SCHEMA_BUNDLE_MIGRATION } from './001_legacy-schema-bundle.js'; +import { SCHEDULED_TASK_COLUMNS_MIGRATION } from './002_scheduled-task-columns.js'; +import { ROOM_REGISTRATION_CANONICAL_COLUMNS_MIGRATION } from './003_room-registration-canonical-columns.js'; +import { MESSAGE_SEQ_AND_WORK_ITEM_INDEXES_MIGRATION } from './004_message-seq-and-work-item-indexes.js'; +import { SESSIONS_COMPOSITE_KEY_MIGRATION } from './005_sessions-composite-key.js'; +import { CHAT_CHANNEL_METADATA_MIGRATION } from './006_chat-channel-metadata.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -10,6 +15,11 @@ const SCHEMA_MIGRATIONS_TABLE = 'schema_migrations'; const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ LEGACY_SCHEMA_BUNDLE_MIGRATION, + SCHEDULED_TASK_COLUMNS_MIGRATION, + ROOM_REGISTRATION_CANONICAL_COLUMNS_MIGRATION, + MESSAGE_SEQ_AND_WORK_ITEM_INDEXES_MIGRATION, + SESSIONS_COMPOSITE_KEY_MIGRATION, + CHAT_CHANNEL_METADATA_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index 3100af6..90d5880 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,10 +1,7 @@ import { Database } from 'bun:sqlite'; import { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../config.js'; -import { - inferAgentTypeFromServiceShadow, - resolveRoleServiceShadow, -} from '../role-service-shadow.js'; +import { resolveRoleServiceShadow } from '../role-service-shadow.js'; import { fillCanonicalChannelOwnerLeaseMetadata, fillCanonicalPairedTaskMetadata, @@ -15,33 +12,17 @@ import { backfillPairedTurnAttemptsFromTurns, buildPairedTurnAttemptId, } from './paired-turn-attempts.js'; -import { normalizeStoredAgentType } from './room-registration.js'; import { - backfillLegacyServiceSessions, - dropLegacyServiceSessionsTable, - migrateSessionsTableToCompositePk, -} from './sessions.js'; + getTableColumns, + tableHasColumn, + tryExecMigration, +} from './migrations/helpers.js'; +import { normalizeStoredAgentType } from './room-registration.js'; // Legacy monolithic migration bundle kept for compatibility with databases // that predate ordered schema migration tracking. New schema changes belong in // src/db/migrations/*. -function getTableColumns(database: Database, tableName: string): string[] { - return ( - database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ - name: string; - }> - ).map((column) => column.name); -} - -export function tableHasColumn( - database: Database, - tableName: string, - columnName: string, -): boolean { - return getTableColumns(database, tableName).includes(columnName); -} - function getTableSql(database: Database, tableName: string): string | null { const row = database .prepare( @@ -56,14 +37,6 @@ function getTableSql(database: Database, tableName: string): string | null { return row?.sql ?? null; } -function tryExecMigration(database: Database, sql: string): void { - try { - database.exec(sql); - } catch { - /* column already exists */ - } -} - function buildPairedTurnAttemptIdSql( turnIdExpr: string, attemptNoExpr: string, @@ -183,43 +156,6 @@ function tableHasForeignKey( return false; } -function backfillMessageSeq(database: Database): void { - const rows = database - .prepare( - `SELECT rowid, seq - FROM messages - ORDER BY CASE WHEN seq IS NULL THEN 1 ELSE 0 END, seq, timestamp, rowid`, - ) - .all() as Array<{ rowid: number; seq: number | null }>; - if (rows.length === 0) { - return; - } - - let nextSeq = 1; - const assignSeq = database.prepare( - 'UPDATE messages SET seq = ? WHERE rowid = ? AND seq IS NULL', - ); - const tx = database.transaction(() => { - for (const row of rows) { - if (row.seq === null) { - assignSeq.run(nextSeq, row.rowid); - } - nextSeq = Math.max(nextSeq, (row.seq ?? nextSeq) + 1); - } - }); - tx(); - - const maxSeqRow = database - .prepare('SELECT MAX(seq) AS maxSeq FROM messages') - .get() as { maxSeq: number | null }; - const maxSeq = maxSeqRow.maxSeq ?? 0; - if (maxSeq > 0) { - database - .prepare('INSERT OR IGNORE INTO message_sequence (id) VALUES (?)') - .run(maxSeq); - } -} - interface LegacyExecutionLeaseServiceRow { rowid: number; role: string; @@ -2574,98 +2510,10 @@ function backfillCanonicalWorkItemServiceIds(database: Database): void { export function applyLegacySchemaMigrations( database: Database, - args: { + _args: { assistantName: string; }, ): void { - const { assistantName } = args; - - tryExecMigration( - database, - `ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`, - ); - tryExecMigration( - database, - `ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`, - ); - tryExecMigration( - database, - `ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`, - ); - tryExecMigration( - database, - `ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`, - ); - tryExecMigration( - database, - `ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`, - ); - tryExecMigration( - database, - `ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`, - ); - tryExecMigration( - database, - `ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`, - ); - tryExecMigration( - database, - `ALTER TABLE scheduled_tasks ADD COLUMN suspended_until TEXT`, - ); - - tryExecMigration( - database, - `ALTER TABLE room_settings ADD COLUMN mode_source TEXT NOT NULL DEFAULT 'explicit'`, - ); - tryExecMigration(database, `ALTER TABLE room_settings ADD COLUMN name TEXT`); - tryExecMigration( - database, - `ALTER TABLE room_settings ADD COLUMN folder TEXT`, - ); - tryExecMigration( - database, - `ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`, - ); - tryExecMigration( - database, - `ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`, - ); - tryExecMigration( - database, - `ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`, - ); - tryExecMigration( - database, - `ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`, - ); - tryExecMigration( - database, - `ALTER TABLE room_settings ADD COLUMN work_dir TEXT`, - ); - tryExecMigration( - database, - `ALTER TABLE room_settings ADD COLUMN created_at TEXT`, - ); - if (tableHasColumn(database, 'room_settings', 'created_at')) { - database.exec(` - UPDATE room_settings - SET created_at = COALESCE(created_at, updated_at, CURRENT_TIMESTAMP) - `); - } - database.exec(` - CREATE TABLE IF NOT EXISTS room_role_overrides ( - chat_jid TEXT NOT NULL, - role TEXT NOT NULL, - agent_type TEXT NOT NULL, - agent_config_json TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (chat_jid, role), - CHECK (role IN ('owner', 'reviewer', 'arbiter')), - CHECK (agent_type IN ('claude-code', 'codex')) - ) - `); - tryExecMigration( database, `ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`, @@ -2935,41 +2783,6 @@ export function applyLegacySchemaMigrations( backfillCanonicalServiceHandoffServiceIds(database); backfillCanonicalWorkItemServiceIds(database); - database.exec( - `UPDATE room_settings - SET mode_source = 'explicit' - WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`, - ); - - try { - database.exec( - `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, - ); - database - .prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`) - .run(`${assistantName}:%`); - } catch { - /* column already exists */ - } - - tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`); - - backfillMessageSeq(database); - - database.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq); - CREATE INDEX IF NOT EXISTS idx_messages_chat_jid_seq ON messages(chat_jid, seq); - `); - database.exec(`DROP INDEX IF EXISTS idx_work_items_group_agent;`); - database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_work_items_group_agent - ON work_items(chat_jid, agent_type, service_id, delivery_role, status); - CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open - ON work_items(chat_jid, agent_type, IFNULL(service_id, ''), IFNULL(delivery_role, '')) - WHERE status IN ('produced', 'delivery_retry'); - `); - const pairedTasksSqlRow = database .prepare( `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`, @@ -3134,29 +2947,6 @@ export function applyLegacySchemaMigrations( ); `); } - - migrateSessionsTableToCompositePk(database, 'claude-code'); - backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow); - dropLegacyServiceSessionsTable(database); - - try { - database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); - database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`); - database.exec( - `UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`, - ); - database.exec( - `UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`, - ); - database.exec( - `UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`, - ); - database.exec( - `UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`, - ); - } catch { - /* columns already exist */ - } } /** @deprecated New schema changes should be added under src/db/migrations/*. */