import type Database from 'better-sqlite3'; const historyTables = ['beta_servers', 'deployment_servers', 'server_deployments', 'leaderboard_matches', 'watchdog_incidents', 'maintenance_jobs']; const now = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"; /** Detach durable beta records without dropping their evidence, indexes, or activity triggers. */ function detachBetaHistory(handle: Database.Database): void { for (const name of ['beta_servers', 'beta_test_runs', 'beta_server_scripts']) { const { sql } = handle.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as { sql: string }; const replacement = sql.replace(/server\s+TEXT NOT NULL REFERENCES servers\(name\)/, 'server TEXT NOT NULL'); if (replacement === sql) continue; const dependents = handle.prepare("SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL") .all(name) as { sql: string }[]; const columns = (handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]).map(col => col.name).join(', '); handle.pragma('foreign_keys = OFF'); try { handle.transaction(() => { handle.exec(replacement.replace(name, name + '_history_new')); handle.exec(`INSERT INTO ${name}_history_new (rowid, ${columns}) SELECT rowid, ${columns} FROM ${name}; DROP TABLE ${name}; ALTER TABLE ${name}_history_new RENAME TO ${name};`); for (const dependent of dependents) handle.exec(dependent.sql); if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Server history migration found broken references'); })(); } finally { handle.pragma('foreign_keys = ON'); } } } // A rebuild owns the replacement instance. Only its current operation and the // Watchdog records observing it cross the deliberate gap in the live registry. const removingRebuild = `SELECT d.id FROM server_deployments d JOIN server_rebuild_jobs r ON r.deployment_id = d.id WHERE d.server = OLD.name AND d.server_deleted_at IS NULL AND d.action = 'rebuild' AND d.state IN ('waiting','running','failed','interrupted') AND d.stage = 'Removing server files and registry' AND r.removed_at IS NULL AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`; /** Installed after additive migrations so existing databases can boot safely. */ export function migrateServerHistory(handle: Database.Database): void { detachBetaHistory(handle); handle.transaction(() => { for (const table of historyTables) { const columns = handle.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; if (!columns.some(column => column.name === 'server_deleted_at')) handle.exec(`ALTER TABLE ${table} ADD COLUMN server_deleted_at TEXT`); } const collectionColumns = handle.prepare('PRAGMA table_info(server_launch_collections)').all() as { name: string }[]; if (!collectionColumns.some(column => column.name === 'retained_server_deletions')) { handle.exec("ALTER TABLE server_launch_collections ADD COLUMN retained_server_deletions TEXT NOT NULL DEFAULT '{}'"); } // Historical unfinished work cannot claim the next server with this name. handle.exec(`DROP INDEX IF EXISTS idx_maintenance_pending; CREATE UNIQUE INDEX idx_maintenance_pending ON maintenance_jobs(server) WHERE state IN ('queued', 'restarting') AND server_deleted_at IS NULL; DROP INDEX IF EXISTS idx_watchdog_active; CREATE UNIQUE INDEX idx_watchdog_active ON watchdog_incidents(server,kind) WHERE state != 'resolved' AND server_deleted_at IS NULL; DROP TRIGGER IF EXISTS preserve_deleted_server_history; CREATE TRIGGER preserve_deleted_server_history BEFORE DELETE ON servers BEGIN UPDATE server_launch_collections SET retained_server_deletions = json_patch(retained_server_deletions, json_object(OLD.name, ${now})) WHERE EXISTS (SELECT 1 FROM json_each(retained_servers) WHERE value = OLD.name) AND NOT EXISTS (SELECT 1 FROM json_each(retained_server_deletions) WHERE key = OLD.name); UPDATE beta_servers SET server_deleted_at = ${now} WHERE server = OLD.name AND server_deleted_at IS NULL; UPDATE deployment_servers SET server_deleted_at = ${now} WHERE server = OLD.name AND server_deleted_at IS NULL AND deployment_id NOT IN ( SELECT campaign_id FROM watchdog_actions WHERE campaign_id IS NOT NULL AND operation_id IN (${removingRebuild})); UPDATE watchdog_incidents SET server_deleted_at = ${now} WHERE server = OLD.name AND server_deleted_at IS NULL AND id NOT IN ( SELECT incident_id FROM watchdog_actions WHERE operation_id IN (${removingRebuild})); UPDATE maintenance_jobs SET server_deleted_at = ${now}, finished_at = CASE WHEN state IN ('queued','restarting') THEN ${now} ELSE finished_at END, detail = CASE WHEN state IN ('queued','restarting') THEN COALESCE(detail || '; ', '') || 'Server deleted' ELSE detail END, state = CASE WHEN state IN ('queued','restarting') THEN 'cancelled' ELSE state END WHERE server = OLD.name AND server_deleted_at IS NULL; UPDATE leaderboard_matches SET server_deleted_at = ${now}, ended_at = CASE WHEN status = 'active' THEN COALESCE(ended_at, ${now}) ELSE ended_at END, status = CASE WHEN status = 'active' THEN 'interrupted' ELSE status END WHERE server = OLD.name AND server_deleted_at IS NULL; INSERT INTO server_deployment_events (deployment_id, created_at, message) SELECT id, ${now}, 'Cancelled: server deleted' FROM server_deployments WHERE server = OLD.name AND server_deleted_at IS NULL AND state IN ('queued','waiting','running','awaiting-health') AND id NOT IN (${removingRebuild}) AND NOT (action = 'destroy' AND state = 'running' AND stage = 'Removing server files and registry'); UPDATE server_deployments SET state = 'cancelled', error = 'Server deleted', finished_at = ${now} WHERE server = OLD.name AND server_deleted_at IS NULL AND state IN ('queued','waiting','running','awaiting-health') AND id NOT IN (${removingRebuild}) AND NOT (action = 'destroy' AND state = 'running' AND stage = 'Removing server files and registry'); UPDATE server_deployments SET server_deleted_at = ${now} WHERE server = OLD.name AND server_deleted_at IS NULL AND id NOT IN (${removingRebuild}); END;`); if (!handle.prepare("SELECT 1 FROM settings WHERE key = 'server_history_v1'").get()) { // Recover the availability of history already orphaned before this // migration. Queued launches and rebuild registry gaps are not deletions. const liveRebuilds = `SELECT d.id FROM server_deployments d JOIN server_rebuild_jobs r ON r.deployment_id = d.id WHERE d.server_deleted_at IS NULL AND d.action = 'rebuild' AND (d.state IN ('waiting','queued','running','awaiting-health') OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry')) AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`; for (const table of historyTables) { const exception = table === 'server_deployments' ? `AND (action != 'create' OR state IN ('done','warning') OR EXISTS ( SELECT 1 FROM server_deployments removal WHERE removal.server = server_deployments.server AND removal.rowid > server_deployments.rowid AND removal.action = 'destroy' AND removal.state IN ('done','warning'))) AND id NOT IN (${liveRebuilds})` : table === 'deployment_servers' ? `AND deployment_id NOT IN (SELECT campaign_id FROM watchdog_actions WHERE campaign_id IS NOT NULL AND operation_id IN (${liveRebuilds}))` : table === 'watchdog_incidents' ? `AND id NOT IN (SELECT incident_id FROM watchdog_actions WHERE operation_id IN (${liveRebuilds}))` : ''; handle.exec(`UPDATE ${table} SET server_deleted_at = ${now} WHERE server_deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM servers WHERE name = ${table}.server AND destroyed_at IS NULL) ${exception}`); } handle.exec(`UPDATE server_launch_collections SET retained_server_deletions = json_patch(retained_server_deletions, (SELECT json_group_object(value, ${now}) FROM json_each(retained_servers) retained WHERE NOT EXISTS (SELECT 1 FROM servers WHERE name = retained.value AND destroyed_at IS NULL) AND NOT EXISTS (SELECT 1 FROM server_deployments d WHERE d.server = retained.value AND d.server_deleted_at IS NULL AND d.action IN ('create','rebuild') AND d.state IN ('queued','running','awaiting-health','waiting')) AND NOT EXISTS (SELECT 1 FROM json_each(retained_server_deletions) WHERE key = retained.value)));`); handle.exec(`UPDATE maintenance_jobs SET state = 'cancelled', finished_at = ${now}, detail = COALESCE(detail || '; ', '') || 'Server deleted' WHERE server_deleted_at IS NOT NULL AND state IN ('queued','restarting'); UPDATE leaderboard_matches SET status = 'interrupted', ended_at = COALESCE(ended_at, server_deleted_at) WHERE server_deleted_at IS NOT NULL AND status = 'active'; INSERT INTO server_deployment_events (deployment_id, created_at, message) SELECT id, ${now}, 'Cancelled: server deleted' FROM server_deployments WHERE server_deleted_at IS NOT NULL AND state IN ('queued','waiting','running','awaiting-health'); UPDATE server_deployments SET state = 'cancelled', error = 'Server deleted', finished_at = ${now} WHERE server_deleted_at IS NOT NULL AND state IN ('queued','waiting','running','awaiting-health'); INSERT INTO settings (key, value) VALUES ('server_history_v1', '1');`); } })(); }