346 lines
14 KiB
TypeScript
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;
|