feat(storage): add resilient SQLite foundation

This commit is contained in:
chick
2026-07-16 13:20:59 +08:00
parent 26c1bcc6da
commit ef9a634a02
9 changed files with 1282 additions and 1 deletions
@@ -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;