feat(storage): add resilient SQLite foundation
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user