diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts new file mode 100644 index 0000000..402e3b8 --- /dev/null +++ b/src/db/bootstrap.test.ts @@ -0,0 +1,99 @@ +import { Database } from 'bun:sqlite'; +import fs from 'fs'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { applyBaseSchema } from './base-schema.js'; +import { initializeDatabaseSchema } from './bootstrap.js'; +import { applyLegacySchemaMigrations } from './schema.js'; + +function getAppliedSchemaMigrations( + database: Database, +): Array<{ version: number; name: string }> { + return database + .prepare( + `SELECT version, name + FROM schema_migrations + ORDER BY version ASC`, + ) + .all() as Array<{ version: number; name: string }>; +} + +describe('initializeDatabaseSchema', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('records the legacy schema bundle as version 1 on a fresh database', () => { + const database = new Database(':memory:'); + + try { + initializeDatabaseSchema(database); + + expect(getAppliedSchemaMigrations(database)).toEqual([ + { + version: 1, + name: 'legacy_schema_bundle', + }, + ]); + } finally { + database.close(); + } + }); + + it('does not duplicate schema migration rows on repeated initialization', () => { + const database = new Database(':memory:'); + + try { + initializeDatabaseSchema(database); + initializeDatabaseSchema(database); + + expect(getAppliedSchemaMigrations(database)).toEqual([ + { + version: 1, + name: 'legacy_schema_bundle', + }, + ]); + } finally { + database.close(); + } + }); + + it('backfills schema migration tracking for an existing pre-versioned database', () => { + const tempDir = fs.mkdtempSync( + path.join('/tmp', 'ejclaw-schema-migrations-'), + ); + tempDirs.push(tempDir); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + try { + applyBaseSchema(legacyDb); + applyLegacySchemaMigrations(legacyDb, { + assistantName: 'Andy', + }); + } finally { + legacyDb.close(); + } + + const reopened = new Database(dbPath); + + try { + initializeDatabaseSchema(reopened); + + expect(getAppliedSchemaMigrations(reopened)).toEqual([ + { + version: 1, + name: 'legacy_schema_bundle', + }, + ]); + } finally { + reopened.close(); + } + }); +}); diff --git a/src/db/bootstrap.ts b/src/db/bootstrap.ts index d836f7e..84511e1 100644 --- a/src/db/bootstrap.ts +++ b/src/db/bootstrap.ts @@ -4,7 +4,22 @@ import path from 'path'; import { ASSISTANT_NAME, STORE_DIR } from '../config.js'; import { applyBaseSchema } from './base-schema.js'; -import { applySchemaMigrations } from './schema.js'; +import { applyVersionedSchemaMigrations } from './migrations/index.js'; + +function isFreshDatabase(database: Database): boolean { + const row = database + .prepare( + ` + SELECT COUNT(*) AS count + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name != 'schema_migrations' + `, + ) + .get() as { count: number }; + return row.count === 0; +} function setForeignKeys(database: Database, enabled: boolean): void { database.exec(`PRAGMA foreign_keys = ${enabled ? 'ON' : 'OFF'}`); @@ -31,8 +46,10 @@ function assertNoForeignKeyViolations(database: Database): void { export function initializeDatabaseSchema(database: Database): void { setForeignKeys(database, false); - applyBaseSchema(database); - applySchemaMigrations(database, { + if (isFreshDatabase(database)) { + applyBaseSchema(database); + } + applyVersionedSchemaMigrations(database, { assistantName: ASSISTANT_NAME, }); setForeignKeys(database, true); diff --git a/src/db/migrations/001_legacy-schema-bundle.ts b/src/db/migrations/001_legacy-schema-bundle.ts new file mode 100644 index 0000000..bc51eeb --- /dev/null +++ b/src/db/migrations/001_legacy-schema-bundle.ts @@ -0,0 +1,67 @@ +import { applyBaseSchema } from '../base-schema.js'; +import { applyLegacySchemaMigrations } from '../schema.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +const LEGACY_BASELINE_TABLES = [ + 'chats', + 'messages', + 'message_sequence', + 'work_items', + 'scheduled_tasks', + 'task_run_logs', + 'router_state', + 'sessions', + 'paired_projects', + 'paired_tasks', + 'paired_workspaces', + 'paired_turn_outputs', + 'paired_turn_reservations', + 'paired_turns', + 'paired_turn_attempts', + 'paired_task_execution_leases', + 'channel_owner', + 'room_settings', + 'room_role_overrides', + 'service_handoffs', + 'memories', +] as const; + +function tableExists( + database: Parameters[0], + tableName: string, +): boolean { + const row = database + .prepare( + ` + SELECT 1 + FROM sqlite_master + WHERE type = 'table' + AND name = ? + `, + ) + .get(tableName); + return row !== null && row !== undefined; +} + +function requiresLegacyBaseSchemaPreflight( + database: Parameters[0], +): boolean { + return LEGACY_BASELINE_TABLES.some( + (tableName) => !tableExists(database, tableName), + ); +} + +export const LEGACY_SCHEMA_BUNDLE_MIGRATION = { + version: 1, + name: 'legacy_schema_bundle', + alwaysRun: true, + apply(database, args) { + // Some pre-versioned test and upgrade fixtures contain only a subset of the + // current base tables. Re-establish the baseline before applying the legacy + // monolithic upgrade bundle. + if (requiresLegacyBaseSchemaPreflight(database)) { + applyBaseSchema(database); + } + applyLegacySchemaMigrations(database, args); + }, +} satisfies SchemaMigrationDefinition; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts new file mode 100644 index 0000000..1dc69f9 --- /dev/null +++ b/src/db/migrations/index.ts @@ -0,0 +1,66 @@ +import type { Database } from 'bun:sqlite'; + +import { LEGACY_SCHEMA_BUNDLE_MIGRATION } from './001_legacy-schema-bundle.js'; +import type { + SchemaMigrationArgs, + SchemaMigrationDefinition, +} from './types.js'; + +const SCHEMA_MIGRATIONS_TABLE = 'schema_migrations'; + +const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ + LEGACY_SCHEMA_BUNDLE_MIGRATION, +]; + +function ensureSchemaMigrationsTable(database: Database): void { + database.exec(` + CREATE TABLE IF NOT EXISTS ${SCHEMA_MIGRATIONS_TABLE} ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); +} + +function getAppliedSchemaVersions(database: Database): Set { + const rows = database + .prepare( + `SELECT version + FROM ${SCHEMA_MIGRATIONS_TABLE} + ORDER BY version ASC`, + ) + .all() as Array<{ version: number }>; + return new Set(rows.map((row) => row.version)); +} + +function recordAppliedSchemaMigration( + database: Database, + migration: SchemaMigrationDefinition, +): void { + database + .prepare( + `INSERT INTO ${SCHEMA_MIGRATIONS_TABLE} (version, name) + VALUES (?, ?)`, + ) + .run(migration.version, migration.name); +} + +export function applyVersionedSchemaMigrations( + database: Database, + args: SchemaMigrationArgs, +): void { + ensureSchemaMigrationsTable(database); + const appliedVersions = getAppliedSchemaVersions(database); + + for (const migration of ORDERED_SCHEMA_MIGRATIONS) { + const alreadyApplied = appliedVersions.has(migration.version); + if (alreadyApplied && !migration.alwaysRun) { + continue; + } + + migration.apply(database, args); + if (!alreadyApplied) { + recordAppliedSchemaMigration(database, migration); + } + } +} diff --git a/src/db/migrations/types.ts b/src/db/migrations/types.ts new file mode 100644 index 0000000..912ad32 --- /dev/null +++ b/src/db/migrations/types.ts @@ -0,0 +1,12 @@ +import type { Database } from 'bun:sqlite'; + +export interface SchemaMigrationArgs { + assistantName: string; +} + +export interface SchemaMigrationDefinition { + version: number; + name: string; + alwaysRun?: boolean; + apply: (database: Database, args: SchemaMigrationArgs) => void; +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 6034278..3100af6 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -22,6 +22,10 @@ import { migrateSessionsTableToCompositePk, } from './sessions.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<{ @@ -2568,7 +2572,7 @@ function backfillCanonicalWorkItemServiceIds(database: Database): void { tx(rows); } -export function applySchemaMigrations( +export function applyLegacySchemaMigrations( database: Database, args: { assistantName: string; @@ -3154,3 +3158,6 @@ export function applySchemaMigrations( /* columns already exist */ } } + +/** @deprecated New schema changes should be added under src/db/migrations/*. */ +export const applySchemaMigrations = applyLegacySchemaMigrations;