db: add versioned schema migration runner

This commit is contained in:
ejclaw
2026-04-11 06:20:33 +09:00
parent c6b86f6dac
commit f81a6032f6
6 changed files with 272 additions and 4 deletions

99
src/db/bootstrap.test.ts Normal file
View File

@@ -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();
}
});
});

View File

@@ -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);

View File

@@ -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<SchemaMigrationDefinition['apply']>[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<SchemaMigrationDefinition['apply']>[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;

View File

@@ -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<number> {
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);
}
}
}

View File

@@ -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;
}

View File

@@ -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;