Files
multi-simadmin/apps/api/src/infrastructure/database/migrations.ts
T

346 lines
14 KiB
TypeScript

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<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;