feat(storage): add resilient SQLite foundation

This commit is contained in:
chick
2026-07-16 13:20:59 +08:00
parent 26c1bcc6da
commit ef9a634a02
9 changed files with 1282 additions and 1 deletions
+4 -1
View File
@@ -5,13 +5,16 @@
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts",
"test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts apps/api/src/infrastructure/database/database.test.ts",
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
"better-sqlite3": "12.11.1",
"drizzle-orm": "0.45.2",
"fastify": "5.10.0"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"@types/node": "24.13.3"
}
}
+4
View File
@@ -8,3 +8,7 @@ export {
export type { BuildAppOptions, ListenEnvironment, ListenOptions, ReadinessResult } from './app.js';
export { startApi } from './start.js';
export type { StartApiOptions } from './start.js';
export { backupDatabase, restoreDatabase } from './infrastructure/database/backup.js';
export { openDatabase } from './infrastructure/database/database.js';
export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js';
export * as databaseSchema from './infrastructure/database/schema.js';
@@ -0,0 +1,116 @@
import Database from 'better-sqlite3';
import { chmod, copyFile, lstat, mkdir, open, rename, rm, stat } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { dirname, resolve } from 'node:path';
import { beginRestore, getManagedDatabaseIdentity, type SqliteDatabase } from './database.js';
async function assertRegularFile(path: string, label: string): Promise<void> {
const details = await lstat(path);
if (details.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link`);
if (!details.isFile()) throw new Error(`${label} must be a regular file`);
}
async function assertDistinctFiles(databasePath: string, backupPath: string): Promise<void> {
if (resolve(databasePath) === resolve(backupPath))
throw new Error('Live and backup paths are the same');
await assertRegularFile(backupPath, 'Backup');
try {
await assertRegularFile(databasePath, 'Live database');
const [live, backup] = await Promise.all([stat(databasePath), stat(backupPath)]);
if (live.dev === backup.dev && live.ino === backup.ino)
throw new Error('Live and backup paths are aliases of the same file');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
async function reservePrivateFile(path: string): Promise<void> {
const handle = await open(path, 'wx', 0o600);
await handle.close();
}
async function assertValidDatabase(path: string): Promise<void> {
let database: Database.Database | undefined;
try {
database = new Database(path, { fileMustExist: true, readonly: true });
const integrity = database.pragma('integrity_check', { simple: true });
if (integrity !== 'ok') throw new Error(`Backup integrity check failed: ${String(integrity)}`);
} catch (error) {
throw new Error('Backup is not a valid SQLite database', { cause: error });
} finally {
database?.close();
}
}
async function syncFile(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
async function syncDirectory(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
export async function backupDatabase(database: SqliteDatabase, backupPath: string): Promise<void> {
const source = getManagedDatabaseIdentity(database);
const directory = dirname(backupPath);
await mkdir(directory, { recursive: true });
try {
const destination = await stat(backupPath);
if (`${destination.dev}:${destination.ino}` === source.deviceInode) {
throw new Error('Backup destination aliases the live database identity');
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const temporaryPath = `${backupPath}.${randomUUID()}.tmp`;
try {
await reservePrivateFile(temporaryPath);
await database.backup(temporaryPath);
await chmod(temporaryPath, 0o600);
await assertValidDatabase(temporaryPath);
await syncFile(temporaryPath);
await rename(temporaryPath, backupPath);
await chmod(backupPath, 0o600);
await syncDirectory(directory);
} finally {
await rm(temporaryPath, { force: true });
}
}
export async function restoreDatabase(databasePath: string, backupPath: string): Promise<void> {
const endRestore = beginRestore(databasePath);
const directory = dirname(databasePath);
const temporaryPath = `${databasePath}.${randomUUID()}.restore`;
try {
await assertDistinctFiles(databasePath, backupPath);
await assertValidDatabase(backupPath);
await mkdir(directory, { recursive: true });
await reservePrivateFile(temporaryPath);
await copyFile(backupPath, temporaryPath);
await chmod(temporaryPath, 0o600);
await assertValidDatabase(temporaryPath);
await syncFile(temporaryPath);
// Atomic replacement comes first: any failure before this point leaves live and sidecars intact.
await rename(temporaryPath, databasePath);
await chmod(databasePath, 0o600);
await syncDirectory(directory);
// No managed connection can exist under the exclusive gate; stale sidecars are now safe to remove.
await rm(`${databasePath}-wal`, { force: true });
await rm(`${databasePath}-shm`, { force: true });
await syncDirectory(directory);
} finally {
await rm(temporaryPath, { force: true });
endRestore();
}
}
@@ -0,0 +1,291 @@
import { link, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { backupDatabase, restoreDatabase } from './backup.js';
import { openDatabase } from './database.js';
import { MIGRATIONS, migrateDatabase } from './migrations.js';
const directories: string[] = [];
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-database-'));
directories.push(directory);
return directory;
}
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })),
);
});
describe('database migrations', () => {
it('rejects duplicate, unordered, backfilled, and drifted migration catalogs', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
const one = { id: 1, name: 'one', statements: ['CREATE TABLE one (id TEXT)'] };
migrateDatabase(database, [one]);
expect(() =>
migrateDatabase(database, [{ ...one, statements: ['CREATE TABLE changed (id TEXT)'] }]),
).toThrow(/checksum|drift/i);
expect(() => migrateDatabase(database, [one, { ...one, name: 'two' }])).toThrow(
/duplicate.*id/i,
);
expect(() => migrateDatabase(database, [one, { id: 2, name: 'one', statements: [] }])).toThrow(
/duplicate.*name/i,
);
expect(() => migrateDatabase(database, [{ id: 2, name: 'two', statements: [] }, one])).toThrow(
/increasing/i,
);
migrateDatabase(database, [{ id: 2, name: 'two', statements: [] }]);
expect(() => migrateDatabase(database, [{ id: 0, name: 'backfill', statements: [] }])).toThrow(
/backfill/i,
);
database.close();
});
it('creates all business tables, migration metadata, indexes, foreign keys, and UTC timestamp fields', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
migrateDatabase(database);
const tables = database
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
.all()
.map((row) => (row as { name: string }).name);
expect(tables).toEqual([
'app_settings',
'audit_events',
'capabilities',
'instance_tags',
'instances',
'job_items',
'jobs',
'schema_migrations',
'secret_references',
'status_snapshots',
]);
expect(database.pragma('foreign_keys', { simple: true })).toBe(1);
const instanceColumns = database.pragma('table_info(instances)') as Array<{
name: string;
notnull: number;
}>;
expect(instanceColumns).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'config_revision', notnull: 1 }),
expect.objectContaining({ name: 'created_at', notnull: 1 }),
expect.objectContaining({ name: 'updated_at', notnull: 1 }),
]),
);
const foreignKeys = database.pragma('foreign_key_list(job_items)') as Array<{
from: string;
on_delete: string;
table: string;
}>;
expect(foreignKeys).toEqual(
expect.arrayContaining([
expect.objectContaining({ from: 'job_id', on_delete: 'CASCADE', table: 'jobs' }),
expect.objectContaining({ from: 'instance_id', on_delete: 'RESTRICT', table: 'instances' }),
]),
);
const indexes = database
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%' ORDER BY name",
)
.all()
.map((row) => (row as { name: string }).name);
expect(indexes).toEqual(
expect.arrayContaining([
'idx_audit_events_created_at',
'idx_jobs_status_created_at',
'idx_status_snapshots_instance_observed_at',
]),
);
database.close();
});
it('replays without resetting persisted data', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
migrateDatabase(database);
database
.prepare(
'INSERT INTO instances (id, name, base_url, config_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
)
.run(
'one',
'One',
'http://example.invalid',
7,
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
migrateDatabase(database);
expect(
database.prepare('SELECT name, config_revision FROM instances WHERE id = ?').get('one'),
).toEqual({
config_revision: 7,
name: 'One',
});
expect(database.prepare('SELECT COUNT(*) AS count FROM schema_migrations').get()).toEqual({
count: MIGRATIONS.length,
});
database.close();
});
it('rolls back the entire migration when a statement fails', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
expect(() =>
migrateDatabase(database, [
{
id: 999,
name: 'deliberately-broken',
statements: ['CREATE TABLE should_rollback (id TEXT PRIMARY KEY)', 'THIS IS NOT SQL'],
},
]),
).toThrow();
expect(
database.prepare("SELECT name FROM sqlite_master WHERE name = 'should_rollback'").get(),
).toBeUndefined();
expect(
database.prepare("SELECT name FROM sqlite_master WHERE name = 'schema_migrations'").get(),
).toBeUndefined();
database.close();
});
});
describe('database backup and restore', () => {
it('refuses restore while a managed live connection is open', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const backupPath = join(directory, 'backup.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
await backupDatabase(database, backupPath);
await expect(restoreDatabase(databasePath, backupPath)).rejects.toThrow(/open|connection/i);
database.close();
});
it('refuses restore through hardlink and symlinked-parent aliases while a managed connection is open', async () => {
const directory = await temporaryDirectory();
const realDirectory = join(directory, 'real');
const aliasDirectory = join(directory, 'alias');
await mkdir(realDirectory);
await symlink(realDirectory, aliasDirectory);
const databasePath = join(realDirectory, 'app.sqlite');
const hardlinkPath = join(directory, 'hardlink.sqlite');
const backupPath = join(directory, 'backup.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
await backupDatabase(database, backupPath);
await link(databasePath, hardlinkPath);
await expect(restoreDatabase(hardlinkPath, backupPath)).rejects.toThrow(
/open|connection|identity/i,
);
await expect(restoreDatabase(join(aliasDirectory, 'app.sqlite'), backupPath)).rejects.toThrow(
/open|connection|identity/i,
);
database.close();
});
it('rejects backup destinations that alias the managed live database', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const hardlinkPath = join(directory, 'hardlink.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
await link(databasePath, hardlinkPath);
await expect(backupDatabase(database, hardlinkPath)).rejects.toThrow(/same|alias|identity/i);
database.close();
});
it('creates backups with owner-only permissions', async () => {
const directory = await temporaryDirectory();
const database = openDatabase(join(directory, 'app.sqlite'));
migrateDatabase(database);
const backupPath = join(directory, 'backup.sqlite');
await backupDatabase(database, backupPath);
expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
database.close();
});
it('rejects same-path, symlink, and hardlink restore aliases', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const backupPath = join(directory, 'backup.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
await backupDatabase(database, backupPath);
database.close();
await expect(restoreDatabase(databasePath, databasePath)).rejects.toThrow(/same|alias/i);
const symlinkPath = join(directory, 'backup-link.sqlite');
await symlink(backupPath, symlinkPath);
await expect(restoreDatabase(databasePath, symlinkPath)).rejects.toThrow(/symbolic|symlink/i);
const hardlinkPath = join(directory, 'live-hardlink.sqlite');
await link(databasePath, hardlinkPath);
await expect(restoreDatabase(databasePath, hardlinkPath)).rejects.toThrow(/same|alias/i);
});
it('backs up an active WAL database, then atomically restores its earlier state', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const backupPath = join(directory, 'backup.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
database
.prepare(
'INSERT INTO app_settings (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)',
)
.run('colour', '"blue"', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
await backupDatabase(database, backupPath);
database.prepare('UPDATE app_settings SET value_json = ? WHERE key = ?').run('"red"', 'colour');
database.close();
await restoreDatabase(databasePath, backupPath);
const restored = openDatabase(databasePath);
expect(
restored.prepare('SELECT value_json FROM app_settings WHERE key = ?').get('colour'),
).toEqual({ value_json: '"blue"' });
expect(restored.pragma('integrity_check', { simple: true })).toBe('ok');
restored.close();
});
it('rejects a corrupt backup without changing the live database', async () => {
const directory = await temporaryDirectory();
const databasePath = join(directory, 'app.sqlite');
const corruptPath = join(directory, 'corrupt.sqlite');
const database = openDatabase(databasePath);
migrateDatabase(database);
database
.prepare(
'INSERT INTO app_settings (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)',
)
.run('safe', 'true', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
database.close();
const before = await readFile(databasePath);
await writeFile(corruptPath, 'not a sqlite database');
await expect(restoreDatabase(databasePath, corruptPath)).rejects.toThrow(
/backup|integrity|database/i,
);
expect(await readFile(databasePath)).toEqual(before);
const unchanged = openDatabase(databasePath);
expect(
unchanged.prepare('SELECT value_json FROM app_settings WHERE key = ?').get('safe'),
).toEqual({ value_json: 'true' });
unchanged.close();
});
});
@@ -0,0 +1,115 @@
import Database from 'better-sqlite3';
import { mkdirSync, realpathSync, statSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
export type SqliteDatabase = Database.Database;
export interface DatabaseIdentity {
readonly canonicalPath: string;
readonly deviceInode: string;
}
const openPaths = new Map<string, number>();
const openIdentities = new Map<string, number>();
const restorePaths = new Set<string>();
const restoreIdentities = new Set<string>();
const managedIdentities = new WeakMap<SqliteDatabase, DatabaseIdentity>();
function increment(map: Map<string, number>, key: string): void {
map.set(key, (map.get(key) ?? 0) + 1);
}
function decrement(map: Map<string, number>, key: string): void {
const remaining = (map.get(key) ?? 1) - 1;
if (remaining === 0) map.delete(key);
else map.set(key, remaining);
}
function canonicalPath(path: string): string {
const absolute = resolve(path);
return join(realpathSync(dirname(absolute)), basename(absolute));
}
function identityForExistingPath(path: string): DatabaseIdentity {
const canonical = realpathSync(path);
const details = statSync(canonical);
return {
canonicalPath: canonical,
deviceInode: `${details.dev}:${details.ino}`,
};
}
export function getManagedDatabaseIdentity(database: SqliteDatabase): DatabaseIdentity {
const identity = managedIdentities.get(database);
if (!identity) throw new Error('Database connection is not managed by openDatabase');
return identity;
}
export function hasOpenDatabase(path: string): boolean {
const canonical = canonicalPath(path);
if ((openPaths.get(canonical) ?? 0) > 0) return true;
try {
return (openIdentities.get(identityForExistingPath(path).deviceInode) ?? 0) > 0;
} catch {
return false;
}
}
export function beginRestore(path: string): () => void {
const identity = identityForExistingPath(path);
if (restorePaths.has(identity.canonicalPath) || restoreIdentities.has(identity.deviceInode)) {
throw new Error('A restore is already in progress for this database identity');
}
if (
(openPaths.get(identity.canonicalPath) ?? 0) > 0 ||
(openIdentities.get(identity.deviceInode) ?? 0) > 0
) {
throw new Error('Cannot restore database while a managed connection is open for this identity');
}
restorePaths.add(identity.canonicalPath);
restoreIdentities.add(identity.deviceInode);
return () => {
restorePaths.delete(identity.canonicalPath);
restoreIdentities.delete(identity.deviceInode);
};
}
export function openDatabase(path: string): SqliteDatabase {
const absolute = resolve(path);
mkdirSync(dirname(absolute), { recursive: true });
const preOpenPath = canonicalPath(absolute);
if (restorePaths.has(preOpenPath))
throw new Error('Cannot open database while restore is in progress');
const database = new Database(absolute);
const identity = identityForExistingPath(absolute);
if (restorePaths.has(identity.canonicalPath) || restoreIdentities.has(identity.deviceInode)) {
database.close();
throw new Error('Cannot open database while restore is in progress for this identity');
}
increment(openPaths, identity.canonicalPath);
increment(openIdentities, identity.deviceInode);
managedIdentities.set(database, identity);
const close = database.close.bind(database);
let closed = false;
database.close = () => {
if (!closed) {
closed = true;
decrement(openPaths, identity.canonicalPath);
decrement(openIdentities, identity.deviceInode);
managedIdentities.delete(database);
}
close();
return database;
};
try {
database.pragma('journal_mode = WAL');
database.pragma('foreign_keys = ON');
database.pragma('busy_timeout = 5000');
return database;
} catch (error) {
database.close();
throw error;
}
}
@@ -0,0 +1,188 @@
import type { SqliteDatabase } from './database.js';
import { createHash } from 'node:crypto';
export interface Migration {
readonly id: number;
readonly name: string;
readonly statements: readonly string[];
}
export const MIGRATIONS: readonly Migration[] = [
{
id: 1,
name: 'initial-control-plane-schema',
statements: [
`CREATE TABLE instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL UNIQUE,
auth_mode TEXT NOT NULL DEFAULT 'password',
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
config_revision INTEGER NOT NULL DEFAULT 1 CHECK (config_revision > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE instance_tags (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (instance_id, tag)
)`,
`CREATE TABLE capabilities (
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
operation_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('supported', 'unsupported', 'auth-required', 'degraded', 'unknown')),
upstream_version TEXT,
detail_code TEXT,
observed_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (instance_id, operation_id)
)`,
`CREATE TABLE status_snapshots (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
category TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('fresh', 'stale', 'expired', 'unknown')),
payload_json TEXT NOT NULL,
observed_at TEXT NOT NULL,
expires_at TEXT,
created_at TEXT NOT NULL
)`,
`CREATE TABLE jobs (
id TEXT PRIMARY KEY,
parent_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT,
operation_id TEXT NOT NULL,
risk_level TEXT NOT NULL CHECK (risk_level IN ('R0', 'R1', 'R2', 'R3')),
status TEXT NOT NULL,
requested_by TEXT NOT NULL,
request_id TEXT NOT NULL,
parameters_digest TEXT NOT NULL,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE job_items (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE RESTRICT,
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0),
status TEXT NOT NULL,
result_code TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
updated_at TEXT NOT NULL,
UNIQUE (job_id, instance_id, attempt_number)
)`,
`CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
instance_id TEXT REFERENCES instances(id) ON DELETE SET NULL,
job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL,
actor TEXT NOT NULL,
operation_id TEXT NOT NULL,
risk_level TEXT NOT NULL CHECK (risk_level IN ('R0', 'R1', 'R2', 'R3')),
request_id TEXT NOT NULL,
parameters_summary_json TEXT NOT NULL,
body_digest TEXT,
result_code TEXT NOT NULL,
duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0),
created_at TEXT NOT NULL
)`,
`CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE secret_references (
id TEXT PRIMARY KEY,
instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE,
purpose TEXT NOT NULL,
provider TEXT NOT NULL,
external_reference TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (provider, external_reference)
)`,
'CREATE INDEX idx_status_snapshots_instance_observed_at ON status_snapshots(instance_id, observed_at DESC)',
'CREATE INDEX idx_jobs_status_created_at ON jobs(status, created_at DESC)',
'CREATE INDEX idx_audit_events_created_at ON audit_events(created_at DESC)',
'CREATE INDEX idx_capabilities_state ON capabilities(state)',
'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)',
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
)`;
function checksum(migration: Migration): string {
return createHash('sha256').update(JSON.stringify(migration.statements)).digest('hex');
}
function validateCatalog(migrations: readonly Migration[]): void {
const ids = new Set<number>();
const names = new Set<string>();
let previous = -Infinity;
for (const migration of migrations) {
if (!Number.isSafeInteger(migration.id) || migration.id < 0)
throw new Error(`Migration id ${migration.id} is invalid`);
if (ids.has(migration.id)) throw new Error(`Duplicate migration id ${migration.id}`);
if (names.has(migration.name)) throw new Error(`Duplicate migration name ${migration.name}`);
if (migration.id <= previous) throw new Error('Migration ids must be strictly increasing');
ids.add(migration.id);
names.add(migration.name);
previous = migration.id;
}
}
export function migrateDatabase(
database: SqliteDatabase,
migrations: readonly Migration[] = MIGRATIONS,
): void {
validateCatalog(migrations);
database.transaction(() => {
database.exec(
'CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)',
);
const maximum = database.prepare('SELECT MAX(id) AS id FROM schema_migrations').get() as {
id: number | null;
};
const applied = database.prepare('SELECT name, checksum FROM schema_migrations WHERE id = ?');
const appliedByName = database.prepare('SELECT id FROM schema_migrations WHERE name = ?');
const record = database.prepare(
'INSERT INTO schema_migrations (id, name, checksum, applied_at) VALUES (?, ?, ?, ?)',
);
for (const migration of migrations) {
const digest = checksum(migration);
const existing = applied.get(migration.id) as { name: string; checksum: string } | undefined;
if (existing) {
if (existing.name !== migration.name)
throw new Error(`Migration ${migration.id} name mismatch`);
if (existing.checksum !== digest)
throw new Error(`Migration ${migration.id} checksum drift detected`);
continue;
}
const existingName = appliedByName.get(migration.name) as { id: number } | undefined;
if (existingName)
throw new Error(
`Migration ${migration.name} drift detected: applied as id ${existingName.id}`,
);
if (maximum.id !== null && migration.id < maximum.id)
throw new Error(
`Migration ${migration.id} is an unapplied backfill below applied id ${maximum.id}`,
);
for (const statement of migration.statements) database.exec(statement);
record.run(migration.id, migration.name, digest, new Date().toISOString());
}
})();
}
// Kept separate so a failed first migration transaction removes metadata too.
export const migrationMetadataSql = createMigrationsTable;
@@ -0,0 +1,192 @@
import { desc, sql } from 'drizzle-orm';
import {
type AnySQLiteColumn,
check,
index,
integer,
primaryKey,
sqliteTable,
text,
unique,
} from 'drizzle-orm/sqlite-core';
const timestamps = {
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
};
export const instances = sqliteTable(
'instances',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
baseUrl: text('base_url').notNull(),
authMode: text('auth_mode').notNull().default('password'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
configRevision: integer('config_revision').notNull().default(1),
...timestamps,
},
(table) => [
unique('instances_base_url_unique').on(table.baseUrl),
check('instances_enabled_check', sql`${table.enabled} in (0, 1)`),
check('instances_config_revision_check', sql`${table.configRevision} > 0`),
],
);
export const instanceTags = sqliteTable(
'instance_tags',
{
instanceId: text('instance_id')
.notNull()
.references(() => instances.id, { onDelete: 'cascade' }),
tag: text('tag').notNull(),
createdAt: text('created_at').notNull(),
},
(table) => [primaryKey({ columns: [table.instanceId, table.tag] })],
);
export const capabilities = sqliteTable(
'capabilities',
{
instanceId: text('instance_id')
.notNull()
.references(() => instances.id, { onDelete: 'cascade' }),
operationId: text('operation_id').notNull(),
state: text('state').notNull(),
upstreamVersion: text('upstream_version'),
detailCode: text('detail_code'),
observedAt: text('observed_at').notNull(),
...timestamps,
},
(table) => [
primaryKey({ columns: [table.instanceId, table.operationId] }),
check(
'capabilities_state_check',
sql`${table.state} in ('supported', 'unsupported', 'auth-required', 'degraded', 'unknown')`,
),
index('idx_capabilities_state').on(table.state),
],
);
export const statusSnapshots = sqliteTable(
'status_snapshots',
{
id: text('id').primaryKey(),
instanceId: text('instance_id')
.notNull()
.references(() => instances.id, { onDelete: 'cascade' }),
category: text('category').notNull(),
state: text('state').notNull(),
payloadJson: text('payload_json').notNull(),
observedAt: text('observed_at').notNull(),
expiresAt: text('expires_at'),
createdAt: text('created_at').notNull(),
},
(table) => [
check(
'status_snapshots_state_check',
sql`${table.state} in ('fresh', 'stale', 'expired', 'unknown')`,
),
index('idx_status_snapshots_instance_observed_at').on(table.instanceId, desc(table.observedAt)),
],
);
export const jobs = sqliteTable(
'jobs',
{
id: text('id').primaryKey(),
parentJobId: text('parent_job_id').references((): AnySQLiteColumn => jobs.id, {
onDelete: 'restrict',
}),
operationId: text('operation_id').notNull(),
riskLevel: text('risk_level').notNull(),
status: text('status').notNull(),
requestedBy: text('requested_by').notNull(),
requestId: text('request_id').notNull(),
parametersDigest: text('parameters_digest').notNull(),
createdAt: text('created_at').notNull(),
startedAt: text('started_at'),
finishedAt: text('finished_at'),
updatedAt: text('updated_at').notNull(),
},
(table) => [
check('jobs_risk_level_check', sql`${table.riskLevel} in ('R0', 'R1', 'R2', 'R3')`),
index('idx_jobs_status_created_at').on(table.status, desc(table.createdAt)),
],
);
export const jobItems = sqliteTable(
'job_items',
{
id: text('id').primaryKey(),
jobId: text('job_id')
.notNull()
.references(() => jobs.id, { onDelete: 'cascade' }),
instanceId: text('instance_id')
.notNull()
.references(() => instances.id, { onDelete: 'restrict' }),
attemptNumber: integer('attempt_number').notNull().default(1),
status: text('status').notNull(),
resultCode: text('result_code'),
createdAt: text('created_at').notNull(),
startedAt: text('started_at'),
finishedAt: text('finished_at'),
updatedAt: text('updated_at').notNull(),
},
(table) => [
unique('job_items_job_instance_attempt_unique').on(
table.jobId,
table.instanceId,
table.attemptNumber,
),
check('job_items_attempt_number_check', sql`${table.attemptNumber} > 0`),
index('idx_job_items_job_status').on(table.jobId, table.status),
],
);
export const auditEvents = sqliteTable(
'audit_events',
{
id: text('id').primaryKey(),
instanceId: text('instance_id').references(() => instances.id, { onDelete: 'set null' }),
jobId: text('job_id').references(() => jobs.id, { onDelete: 'set null' }),
actor: text('actor').notNull(),
operationId: text('operation_id').notNull(),
riskLevel: text('risk_level').notNull(),
requestId: text('request_id').notNull(),
parametersSummaryJson: text('parameters_summary_json').notNull(),
bodyDigest: text('body_digest'),
resultCode: text('result_code').notNull(),
durationMs: integer('duration_ms').notNull(),
createdAt: text('created_at').notNull(),
},
(table) => [
check('audit_events_risk_level_check', sql`${table.riskLevel} in ('R0', 'R1', 'R2', 'R3')`),
check('audit_events_duration_ms_check', sql`${table.durationMs} >= 0`),
index('idx_audit_events_created_at').on(desc(table.createdAt)),
],
);
export const appSettings = sqliteTable('app_settings', {
key: text('key').primaryKey(),
valueJson: text('value_json').notNull(),
...timestamps,
});
export const secretReferences = sqliteTable(
'secret_references',
{
id: text('id').primaryKey(),
instanceId: text('instance_id').references(() => instances.id, { onDelete: 'cascade' }),
purpose: text('purpose').notNull(),
provider: text('provider').notNull(),
externalReference: text('external_reference').notNull(),
...timestamps,
},
(table) => [
unique('secret_references_provider_external_unique').on(
table.provider,
table.externalReference,
),
],
);