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