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)', ], }, { id: 2, name: 'secret-cleanup-task-outbox', statements: [ `CREATE TABLE secret_cleanup_tasks ( reference TEXT PRIMARY KEY, instance_id TEXT NOT NULL, purpose TEXT NOT NULL, provider TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0), queued_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at) SELECT external_reference,instance_id,purpose,provider,1,updated_at,updated_at FROM ( SELECT external_reference,instance_id,purpose,provider,updated_at, ROW_NUMBER() OVER ( PARTITION BY instance_id,purpose ORDER BY updated_at DESC,created_at DESC,id DESC ) AS row_number FROM secret_references WHERE instance_id IS NOT NULL ) ranked WHERE row_number > 1`, `DELETE FROM secret_references WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY instance_id,purpose ORDER BY updated_at DESC,created_at DESC,id DESC ) AS row_number FROM secret_references WHERE instance_id IS NOT NULL ) ranked WHERE row_number > 1 )`, 'CREATE UNIQUE INDEX uq_secret_references_instance_purpose ON secret_references(instance_id, purpose) WHERE instance_id IS NOT NULL', 'CREATE INDEX idx_secret_cleanup_tasks_queued_at ON secret_cleanup_tasks(queued_at)', ], }, { id: 3, name: 'current-status-snapshot-lifecycle', statements: [ `DELETE FROM status_snapshots WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY instance_id, category ORDER BY observed_at DESC, created_at DESC, id DESC ) AS row_number FROM status_snapshots ) ranked WHERE row_number > 1 )`, 'CREATE UNIQUE INDEX uq_status_snapshots_instance_category ON status_snapshots(instance_id, category)', ], }, { id: 4, name: 'r3-preparations-and-durable-job-lineage', statements: [ 'ALTER TABLE jobs ADD COLUMN root_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT', 'ALTER TABLE jobs ADD COLUMN retry_of_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT', 'UPDATE jobs SET root_job_id=id WHERE root_job_id IS NULL', `CREATE TABLE job_items_v4 ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, instance_id TEXT NOT NULL, attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0), status TEXT NOT NULL, result_code TEXT, error_json TEXT, source_job_item_id TEXT REFERENCES job_items_v4(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, updated_at TEXT NOT NULL, UNIQUE (job_id, instance_id, attempt_number) )`, `INSERT INTO job_items_v4 (id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at) SELECT id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at FROM job_items`, 'DROP TABLE job_items', 'ALTER TABLE job_items_v4 RENAME TO job_items', 'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)', `CREATE TABLE job_attempts ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed','cancelled','unknown-result')), started_at TEXT NOT NULL, finished_at TEXT, created_at TEXT NOT NULL )`, 'CREATE INDEX idx_job_attempts_job_started_at ON job_attempts(job_id, started_at)', `CREATE TABLE operation_preparations ( id TEXT PRIMARY KEY, operation_id TEXT NOT NULL CHECK (operation_id = 'deleteInstance'), risk_level TEXT NOT NULL CHECK (risk_level = 'R3'), status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')), target_instance_id TEXT NOT NULL, target_revision INTEGER NOT NULL CHECK (target_revision > 0), parameter_schema_id TEXT NOT NULL, parameters_digest TEXT NOT NULL, token_digest TEXT NOT NULL CHECK (length(token_digest) = 64), requested_by TEXT NOT NULL, request_id TEXT NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, 'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)', ], }, { id: 5, name: 'generic-secure-operation-preparation-binding', statements: [ `CREATE TABLE operation_preparations_v5 ( id TEXT PRIMARY KEY, operation_id TEXT NOT NULL, risk_level TEXT NOT NULL CHECK (risk_level IN ('R2','R3')), status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')), target_instance_id TEXT NOT NULL, target_revision INTEGER NOT NULL CHECK (target_revision > 0), target_origin TEXT NOT NULL, method TEXT NOT NULL, path TEXT NOT NULL, canonical_query TEXT NOT NULL, body_digest TEXT NOT NULL CHECK (length(body_digest) = 64), content_type TEXT NOT NULL, parameter_schema_id TEXT NOT NULL, parameters_digest TEXT NOT NULL CHECK (length(parameters_digest) = 64), nonce TEXT NOT NULL UNIQUE, token_digest TEXT NOT NULL CHECK (length(token_digest) = 64), requested_by TEXT NOT NULL, request_id TEXT NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `INSERT INTO operation_preparations_v5 (id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path, canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest, requested_by,request_id,expires_at,consumed_at,created_at,updated_at) SELECT p.id,p.operation_id,p.risk_level,p.status,p.target_instance_id,p.target_revision, COALESCE(i.base_url,''),'DELETE','/api/v1/instances/' || p.target_instance_id,'', '${createHash('sha256').update('').digest('hex')}','',p.parameter_schema_id,p.parameters_digest, 'legacy-' || p.id,p.token_digest,p.requested_by,p.request_id,p.expires_at,p.consumed_at,p.created_at,p.updated_at FROM operation_preparations p LEFT JOIN instances i ON i.id=p.target_instance_id`, 'DROP TABLE operation_preparations', 'ALTER TABLE operation_preparations_v5 RENAME TO operation_preparations', 'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)', ], }, ]; 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;