db: add versioned schema migration runner
This commit is contained in:
99
src/db/bootstrap.test.ts
Normal file
99
src/db/bootstrap.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
67
src/db/migrations/001_legacy-schema-bundle.ts
Normal file
67
src/db/migrations/001_legacy-schema-bundle.ts
Normal 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;
|
||||
66
src/db/migrations/index.ts
Normal file
66
src/db/migrations/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/db/migrations/types.ts
Normal file
12
src/db/migrations/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user