Retain historical server identities, results, and evidence after deletion. Mark archived entries as Deleted and prevent old work from targeting servers that reuse the same name. Fixes #16.
131 lines
11 KiB
TypeScript
131 lines
11 KiB
TypeScript
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');`);
|
|
}
|
|
})();
|
|
}
|