From ef9a634a023efeb5a39bf35460ad7852cbb2c6f1 Mon Sep 17 00:00:00 2001 From: chick Date: Thu, 16 Jul 2026 13:20:59 +0800 Subject: [PATCH] feat(storage): add resilient SQLite foundation --- apps/api/package.json | 5 +- apps/api/src/index.ts | 4 + .../api/src/infrastructure/database/backup.ts | 116 ++++++ .../infrastructure/database/database.test.ts | 291 ++++++++++++++ .../src/infrastructure/database/database.ts | 115 ++++++ .../src/infrastructure/database/migrations.ts | 188 +++++++++ .../api/src/infrastructure/database/schema.ts | 192 +++++++++ pnpm-lock.yaml | 371 ++++++++++++++++++ pnpm-workspace.yaml | 1 + 9 files changed, 1282 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/infrastructure/database/backup.ts create mode 100644 apps/api/src/infrastructure/database/database.test.ts create mode 100644 apps/api/src/infrastructure/database/database.ts create mode 100644 apps/api/src/infrastructure/database/migrations.ts create mode 100644 apps/api/src/infrastructure/database/schema.ts diff --git a/apps/api/package.json b/apps/api/package.json index 94d5bd8..8e9bbb6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7ea398f..9113160 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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'; diff --git a/apps/api/src/infrastructure/database/backup.ts b/apps/api/src/infrastructure/database/backup.ts new file mode 100644 index 0000000..c91b131 --- /dev/null +++ b/apps/api/src/infrastructure/database/backup.ts @@ -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 { + 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 { + 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 { + const handle = await open(path, 'wx', 0o600); + await handle.close(); +} + +async function assertValidDatabase(path: string): Promise { + 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 { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +export async function backupDatabase(database: SqliteDatabase, backupPath: string): Promise { + 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 { + 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(); + } +} diff --git a/apps/api/src/infrastructure/database/database.test.ts b/apps/api/src/infrastructure/database/database.test.ts new file mode 100644 index 0000000..ac0f050 --- /dev/null +++ b/apps/api/src/infrastructure/database/database.test.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/api/src/infrastructure/database/database.ts b/apps/api/src/infrastructure/database/database.ts new file mode 100644 index 0000000..9550fab --- /dev/null +++ b/apps/api/src/infrastructure/database/database.ts @@ -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(); +const openIdentities = new Map(); +const restorePaths = new Set(); +const restoreIdentities = new Set(); +const managedIdentities = new WeakMap(); + +function increment(map: Map, key: string): void { + map.set(key, (map.get(key) ?? 0) + 1); +} + +function decrement(map: Map, 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; + } +} diff --git a/apps/api/src/infrastructure/database/migrations.ts b/apps/api/src/infrastructure/database/migrations.ts new file mode 100644 index 0000000..3d2bc02 --- /dev/null +++ b/apps/api/src/infrastructure/database/migrations.ts @@ -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(); + const names = new Set(); + 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; diff --git a/apps/api/src/infrastructure/database/schema.ts b/apps/api/src/infrastructure/database/schema.ts new file mode 100644 index 0000000..68b4320 --- /dev/null +++ b/apps/api/src/infrastructure/database/schema.ts @@ -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, + ), + ], +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb4bc8d..1fed0e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,10 +36,19 @@ importers: apps/api: dependencies: + better-sqlite3: + specifier: 12.11.1 + version: 12.11.1 + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1) fastify: specifier: 5.10.0 version: 5.10.0 devDependencies: + '@types/better-sqlite3': + specifier: 7.6.13 + version: 7.6.13 '@types/node': specifier: 24.13.3 version: 24.13.3 @@ -446,6 +455,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -601,6 +613,19 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} @@ -611,6 +636,9 @@ packages: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -623,6 +651,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -654,6 +685,14 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -665,6 +704,105 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -725,6 +863,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -775,6 +917,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + find-my-way@9.6.0: resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} engines: {node: '>=20'} @@ -790,11 +935,17 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -815,6 +966,9 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -834,6 +988,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ipaddr.js@2.4.0: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} @@ -897,6 +1054,10 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -908,10 +1069,16 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -920,9 +1087,16 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -931,6 +1105,9 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -983,6 +1160,12 @@ packages: resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -998,6 +1181,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1005,6 +1191,14 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -1036,6 +1230,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.1: resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} hasBin: true @@ -1069,6 +1266,12 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1090,6 +1293,13 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1098,6 +1308,13 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -1131,6 +1348,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1153,6 +1373,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite@7.3.6: resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1241,6 +1464,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1512,6 +1738,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 24.13.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1702,6 +1932,23 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 @@ -1715,6 +1962,11 @@ snapshots: dependencies: balanced-match: 4.0.4 + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + callsites@3.1.0: {} chai@6.2.2: {} @@ -1724,6 +1976,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chownr@1.1.4: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1746,12 +2000,29 @@ snapshots: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + deep-is@0.1.4: {} depd@2.0.0: {} dequal@2.0.3: {} + detect-libc@2.1.2: {} + + drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1): + optionalDependencies: + '@types/better-sqlite3': 7.6.13 + better-sqlite3: 12.11.1 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + es-module-lexer@1.7.0: {} esbuild@0.28.1: @@ -1857,6 +2128,8 @@ snapshots: esutils@2.0.3: {} + expand-template@2.0.3: {} + expect-type@1.4.0: {} fast-decode-uri-component@1.0.1: {} @@ -1916,6 +2189,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + find-my-way@9.6.0: dependencies: fast-deep-equal: 3.1.3 @@ -1934,9 +2209,13 @@ snapshots: flatted@3.4.2: {} + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true + github-from-package@0.0.0: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -1959,6 +2238,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.6: {} @@ -1972,6 +2253,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + ipaddr.js@2.4.0: {} is-extglob@2.1.1: {} @@ -2027,6 +2310,8 @@ snapshots: mime@3.0.0: {} + mimic-response@3.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 @@ -2039,18 +2324,32 @@ snapshots: dependencies: brace-expansion: 2.1.2 + minimist@1.2.8: {} + minipass@7.1.3: {} + mkdirp-classic@0.5.3: {} + ms@2.1.3: {} nanoid@3.3.16: {} + napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + obug@2.1.3: {} on-exit-leak-free@2.1.2: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2113,6 +2412,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -2121,10 +2435,28 @@ snapshots: process-warning@5.0.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} quick-format-unescaped@4.0.4: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + real-require@0.2.0: {} real-require@1.0.0: {} @@ -2170,6 +2502,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + safe-buffer@5.2.1: {} + safe-regex2@5.1.1: dependencies: ret: 0.5.0 @@ -2192,6 +2526,14 @@ snapshots: siginfo@2.0.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -2206,12 +2548,33 @@ snapshots: std-env@3.10.0: {} + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -2235,6 +2598,10 @@ snapshots: dependencies: typescript: 5.9.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -2258,6 +2625,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + vite@7.3.6(@types/node@24.13.3): dependencies: esbuild: 0.28.1 @@ -2318,4 +2687,6 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d34ef04..9a7e009 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,4 +5,5 @@ packages: # Vitest uses esbuild for TypeScript transformation. pnpm 11 blocks every # dependency build by default, so keep the allow-list explicit and minimal. allowBuilds: + better-sqlite3: true esbuild: true