-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts. -- The old betas/beta_scripts/beta_deploys tables are dropped in-code before this -- runs (see dropLegacyBetaTables in db/index.ts); the new script-centric model -- lives here. PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; -- Historical gameplay deliberately has no FK to servers: destroying a server -- must not erase records. Each player's counters are high-water marks per match. CREATE TABLE IF NOT EXISTS leaderboard_matches ( id INTEGER PRIMARY KEY AUTOINCREMENT, server TEXT NOT NULL, server_deleted_at TEXT, game TEXT NOT NULL, map TEXT NOT NULL, map_key TEXT NOT NULL, map_name TEXT NOT NULL, started_at TEXT NOT NULL, ended_at TEXT, last_received_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', -- active | completed | interrupted start_observed INTEGER NOT NULL DEFAULT 0, start_players INTEGER, highest_round INTEGER NOT NULL DEFAULT 0, last_event TEXT NOT NULL, last_fingerprint TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_leaderboard_server ON leaderboard_matches(server, id DESC); CREATE INDEX IF NOT EXISTS idx_leaderboard_started ON leaderboard_matches(started_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_leaderboard_map ON leaderboard_matches(game, map_key); CREATE TABLE IF NOT EXISTS leaderboard_players ( game TEXT NOT NULL, guid TEXT NOT NULL, name TEXT NOT NULL, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, PRIMARY KEY (game, guid) ); CREATE TABLE IF NOT EXISTS leaderboard_match_players ( match_id INTEGER NOT NULL REFERENCES leaderboard_matches(id) ON DELETE CASCADE, guid TEXT NOT NULL, highest_round INTEGER NOT NULL DEFAULT 0, rounds_survived INTEGER NOT NULL DEFAULT 0, kills INTEGER NOT NULL DEFAULT 0, headshots INTEGER NOT NULL DEFAULT 0, downs INTEGER NOT NULL DEFAULT 0, revives INTEGER NOT NULL DEFAULT 0, best_score INTEGER NOT NULL DEFAULT 0, last_round INTEGER NOT NULL DEFAULT 0, present INTEGER NOT NULL DEFAULT 1, has_stats INTEGER NOT NULL DEFAULT 0, completed INTEGER NOT NULL DEFAULT 0, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, PRIMARY KEY (match_id, guid) ); CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id); CREATE TABLE IF NOT EXISTS servers ( name TEXT PRIMARY KEY, komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container komodo_name TEXT NOT NULL, komodo_server TEXT, container_checked_at TEXT, container_state TEXT, -- last observed Komodo runtime state desired_state TEXT NOT NULL DEFAULT 'running' CHECK (desired_state IN ('running','stopped')), game_port INTEGER, -- UDP RCON port; lazy-filled from Komodo rcon_host TEXT, -- optional per-server override of RCON_HOST game TEXT, -- 't5' etc.; static per-server (no map rotation) map TEXT, -- 'zombie_theater' etc.; static per-server last_players INTEGER, last_checked_at TEXT, last_map TEXT, -- from RCON getstatus mapname last_gametype TEXT, -- from RCON getstatus g_gametype last_roster TEXT, -- JSON array of RconPlayer last_seen_at TEXT, -- ISO ts of last successful RCON reply last_error TEXT, -- last RCON failure reason (cleared on success) last_reply_snippet TEXT, -- hex+ASCII of last bad-reply UDP payload -- Provisioning fields — set when Electric Cherry creates the deployment. game_mode TEXT, -- 'zombie' | 'mp' | 'sp' (currently zombie only) map_slug TEXT, -- 'cosmodrome' — short slug used in server name host_dir TEXT, -- absolute host path bind-mounted into the container config_file TEXT, -- e.g. 'dedicated_sp.cfg' (PLUTAINER_CONFIG_FILE) game_id TEXT, -- e.g. 't5sp' (PLUTAINER_GAME) image TEXT, -- e.g. 'ghcr.io/ayymoss/plutainer:latest' created_by TEXT, -- 'ui' | operator identifier destroyed_at TEXT, -- ISO ts when tombstoned (row usually deleted; kept as safety net) -- Occupancy + match timers. active_since / inactive_since are mutually -- exclusive: exactly one is non-null while a server is reachable. RCON -- outages do NOT clear either — timers survive transient unreachability -- and resume from the same anchor when the server comes back. active_since TEXT, -- ISO ts of last 0 -> >0 player transition inactive_since TEXT, -- ISO ts of last >0 -> 0 player transition game_started_at TEXT, -- receipt time of the script's game_start game_ended_at TEXT, -- receipt time of the script's game_end game_cycle_key TEXT, -- opaque marker for the currently-tracked cycle (the first J; line after the latest InitGame); lets us detect same-players game restarts created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); -- One row per artifact (a logical thing, e.g. "HUD tweaks"). Name is unique and -- doubles as the slug in URLs. The `kind` column discriminates: -- 'script' — GSC files deployed to runtime/plutonium/storage/t5/raw/scripts/sp -- 'plugin' — .dll files deployed to runtime/plutonium/plugins -- 'config' — cfg overlay (set K "V" lines) layered onto the baseline -- 'baseline' — full dedicated_sp.cfg; exactly one per scope -- Table + column names keep the "scripts" prefix for continuity; treat them -- polymorphically at the app layer. CREATE TABLE IF NOT EXISTS scripts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, target_rel_path TEXT NOT NULL, -- 'maps/mp_kino/hud.gsc' or 'hud.gsc' scope_type TEXT, -- NULL | 'game' | 'map' scope_value TEXT, -- NULL | 't5' | 'zombie_theater' current_version_id INTEGER, -- FK → script_versions(id) after first promote kind TEXT NOT NULL DEFAULT 'script', -- 'script' | 'config' | 'plugin' | 'baseline' created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_scripts_scope ON scripts(scope_type, scope_value); -- kind-referencing indexes are created in db/index.ts AFTER migrateTable adds -- the column to legacy DBs. Putting them here would fail with "no such column: -- kind" on an existing pre-kind DB, breaking boot. CREATE TABLE IF NOT EXISTS script_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, version INTEGER NOT NULL, release_version TEXT, -- publisher's version/tag, independent of upload order sha256 TEXT NOT NULL, size INTEGER NOT NULL, content BLOB NOT NULL, notes TEXT, state TEXT NOT NULL DEFAULT 'untested', -- 'untested' | 'tested' | 'test-failed' kind TEXT NOT NULL DEFAULT 'script', -- denormalized from scripts.kind for join-free filtering uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(script_id, version) ); CREATE INDEX IF NOT EXISTS idx_script_versions_script ON script_versions(script_id); -- Provenance belongs to the exact binary version, including when it is rolled back. CREATE TABLE IF NOT EXISTS plugin_imports ( version_id INTEGER PRIMARY KEY REFERENCES script_versions(id) ON DELETE CASCADE, repository TEXT NOT NULL, release_id INTEGER NOT NULL, release_url TEXT NOT NULL, asset_id INTEGER NOT NULL, asset_name TEXT NOT NULL, published_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS server_scripts ( server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE, rel_path TEXT NOT NULL, script_version_id INTEGER REFERENCES script_versions(id) ON DELETE SET NULL, sha256 TEXT NOT NULL, size INTEGER NOT NULL, updated_at TEXT NOT NULL, source TEXT, -- 'beta:' | 'promote:' | 'rollback:' PRIMARY KEY (server, rel_path) ); CREATE INDEX IF NOT EXISTS idx_server_scripts_server ON server_scripts(server); CREATE TABLE IF NOT EXISTS betas ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- DEPRECATED: multi-script betas are represented by beta_scripts. These -- three columns are populated from the first bundled script to satisfy -- NOT NULL on legacy schemas; the fleet code doesn't read them anymore. script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, script_version_id INTEGER NOT NULL REFERENCES script_versions(id) ON DELETE CASCADE, trigger_kind TEXT NOT NULL, wait_for_health INTEGER NOT NULL DEFAULT 1, wait_for_match INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL, -- 'running' | 'ready-promote' | 'promoted' | 'rolled-back' opened_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), closed_at TEXT, notes TEXT ); CREATE INDEX IF NOT EXISTS idx_betas_state ON betas(state); CREATE INDEX IF NOT EXISTS idx_betas_script ON betas(script_id); CREATE TABLE IF NOT EXISTS beta_servers ( beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, server TEXT NOT NULL, server_deleted_at TEXT, had_original INTEGER NOT NULL, -- deprecated; see beta_server_scripts prior_version_id INTEGER REFERENCES script_versions(id), -- deprecated; see beta_server_scripts stage TEXT NOT NULL, -- 'deploying'|'awaiting-empty'|'restarting'|'awaiting-health'|'awaiting-match'|'ready'|'failed' error TEXT, deployed_at TEXT, restarted_at TEXT, healthy_at TEXT, match_seen_at TEXT, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), PRIMARY KEY (beta_id, server) ); CREATE INDEX IF NOT EXISTS idx_beta_servers_stage ON beta_servers(stage); -- Immutable test configuration, durable attempts and append-only evidence. -- Keeping test outcome separate preserves artifact holds until explicit closure. CREATE TABLE IF NOT EXISTS beta_test_plans ( beta_id INTEGER PRIMARY KEY REFERENCES betas(id) ON DELETE CASCADE, plan TEXT NOT NULL, outcome TEXT NOT NULL DEFAULT 'active', reason TEXT ); CREATE TABLE IF NOT EXISTS beta_test_runs ( id TEXT PRIMARY KEY, beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, server TEXT NOT NULL, phase INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, observing_at TEXT, retired_at TEXT, metrics TEXT NOT NULL DEFAULT '{}', cursors TEXT NOT NULL DEFAULT '{}' ); CREATE TABLE IF NOT EXISTS beta_test_coverage ( beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, server TEXT NOT NULL, game TEXT, map TEXT, PRIMARY KEY (beta_id, server) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_beta_test_current_run ON beta_test_runs(beta_id, server) WHERE retired_at IS NULL; CREATE TABLE IF NOT EXISTS beta_test_evidence ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL REFERENCES beta_test_runs(id) ON DELETE CASCADE, event_id TEXT NOT NULL, received_at TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, UNIQUE(run_id, event_id) ); CREATE INDEX IF NOT EXISTS idx_beta_evidence_run ON beta_test_evidence(run_id, id); CREATE TABLE IF NOT EXISTS beta_file_changes ( beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, server TEXT NOT NULL, rel_path TEXT NOT NULL, PRIMARY KEY (beta_id, server, rel_path) ); CREATE TABLE IF NOT EXISTS beta_test_presets ( name TEXT PRIMARY KEY, plan TEXT NOT NULL ); -- Many-to-many: which scripts (and pinned versions) are bundled into a beta. -- Replaces the deprecated betas.script_id / betas.script_version_id columns. CREATE TABLE IF NOT EXISTS beta_scripts ( beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, script_version_id INTEGER NOT NULL REFERENCES script_versions(id) ON DELETE CASCADE, kind TEXT NOT NULL DEFAULT 'script', -- denormalized from scripts.kind PRIMARY KEY (beta_id, script_id) ); CREATE INDEX IF NOT EXISTS idx_beta_scripts_beta ON beta_scripts(beta_id); CREATE INDEX IF NOT EXISTS idx_beta_scripts_script ON beta_scripts(script_id); -- Per (beta, server, script) prior-file state. A server row in beta_servers -- covers restart progress; this table covers whether each script's target -- existed on that server before the beta overwrote it — snapshot content -- lives in beta_file_snapshots. CREATE TABLE IF NOT EXISTS beta_server_scripts ( beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, server TEXT NOT NULL, script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, had_original INTEGER NOT NULL, prior_version_id INTEGER REFERENCES script_versions(id), PRIMARY KEY (beta_id, server, script_id) ); CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_beta ON beta_server_scripts(beta_id); -- Pre-beta bytes of every target file the beta overwrote on each server. -- Absence of a row for (beta, server, rel_path) means the file did not exist -- before the beta wrote it — rollback deletes the file rather than restoring. -- Handles scripts, plugins, and the composed dedicated_sp.cfg uniformly. CREATE TABLE IF NOT EXISTS beta_file_snapshots ( beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE, rel_path TEXT NOT NULL, prior_content BLOB NOT NULL, prior_sha256 TEXT NOT NULL, prior_size INTEGER NOT NULL, PRIMARY KEY (beta_id, server, rel_path) ); CREATE INDEX IF NOT EXISTS idx_beta_file_snapshots_beta ON beta_file_snapshots(beta_id); CREATE TABLE IF NOT EXISTS deployments ( id TEXT PRIMARY KEY, action TEXT NOT NULL, -- 'drift' | 'promote' | 'rollback' started_at TEXT NOT NULL, finished_at TEXT, triggered_by TEXT, script_id INTEGER REFERENCES scripts(id), -- promote/rollback only script_version_id INTEGER REFERENCES script_versions(id), -- the version this deployment lands / restores prior_version_id INTEGER REFERENCES script_versions(id), -- current_version_id before this promote beta_id INTEGER REFERENCES betas(id), -- promote only rolled_back_by TEXT REFERENCES deployments(id) -- set on the promote row when a rollback reverses it ); CREATE INDEX IF NOT EXISTS idx_deployments_started ON deployments(started_at DESC); CREATE TABLE IF NOT EXISTS deployment_servers ( deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, server TEXT NOT NULL, -- Campaign observation survives a rebuild's registry gap. server_deleted_at TEXT, files_changed INTEGER NOT NULL, restart_state TEXT NOT NULL, -- pending | done | failed | manual | held players INTEGER, checked_at TEXT, restarted_at TEXT, attempts INTEGER NOT NULL DEFAULT 0, error TEXT, PRIMARY KEY (deployment_id, server) ); CREATE INDEX IF NOT EXISTS idx_deployment_servers_state ON deployment_servers(restart_state); -- Fleet-wide restart slots shared by deployments and betas. A successful API -- response does not release a slot: the server must recover first. Persisting -- these rows also protects the fleet across Electric Cherry process restarts. CREATE TABLE IF NOT EXISTS restart_rollout ( server TEXT PRIMARY KEY REFERENCES servers(name) ON DELETE CASCADE, requested_at TEXT NOT NULL, checked_at TEXT, healthy_at TEXT, players INTEGER, error TEXT ); CREATE TABLE IF NOT EXISTS deployment_files ( id INTEGER PRIMARY KEY AUTOINCREMENT, deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, server TEXT NOT NULL, verb TEXT NOT NULL, -- added | updated | removed rel_path TEXT NOT NULL, sha256_before TEXT, sha256_after TEXT, content_before BLOB, content_after BLOB ); CREATE INDEX IF NOT EXISTS idx_deployment_files_dep ON deployment_files(deployment_id); -- Per-script rows on aggregate promote/rollback deployments. For legacy -- single-script deployments this may also be backfilled from the deprecated -- deployments.script_id / script_version_id / prior_version_id columns. CREATE TABLE IF NOT EXISTS deployment_scripts ( deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, script_version_id INTEGER NOT NULL REFERENCES script_versions(id) ON DELETE CASCADE, prior_version_id INTEGER REFERENCES script_versions(id), kind TEXT NOT NULL DEFAULT 'script', -- denormalized from scripts.kind PRIMARY KEY (deployment_id, script_id) ); CREATE INDEX IF NOT EXISTS idx_deployment_scripts_dep ON deployment_scripts(deployment_id); -- Deliberately independent of servers: credentials survive server destruction. CREATE TABLE IF NOT EXISTS server_keys ( server_id TEXT PRIMARY KEY, pluto_key TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS plutonium_versions ( server TEXT PRIMARY KEY REFERENCES servers(name) ON DELETE CASCADE, revision INTEGER, checked_at TEXT NOT NULL, error TEXT ); CREATE TABLE IF NOT EXISTS maintenance_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, server TEXT NOT NULL, server_deleted_at TEXT, state TEXT NOT NULL DEFAULT 'queued', target_revision INTEGER, requested_at TEXT NOT NULL, restarted_at TEXT, finished_at TEXT, detail TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_maintenance_pending ON maintenance_jobs(server) WHERE state IN ('queued', 'restarting'); CREATE TABLE IF NOT EXISTS server_telemetry ( server TEXT PRIMARY KEY REFERENCES servers(name) ON DELETE CASCADE, round INTEGER NOT NULL, players INTEGER NOT NULL, received_at TEXT NOT NULL, payload TEXT ); -- Request diagnostics survive registry removal and also capture unknown names. -- Bounded independently of the latest scoreboard and permanent leaderboard. CREATE TABLE IF NOT EXISTS telemetry_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, server TEXT NOT NULL, received_at TEXT NOT NULL, status INTEGER NOT NULL, error TEXT, event TEXT, map TEXT, round INTEGER, player_count INTEGER, content_type TEXT, payload TEXT NOT NULL, payload_truncated INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_telemetry_requests_server ON telemetry_requests(server, id DESC); CREATE INDEX IF NOT EXISTS idx_telemetry_requests_received ON telemetry_requests(received_at); -- Lifecycle history deliberately survives removal of the server registry row. CREATE TABLE IF NOT EXISTS server_deployments ( id TEXT PRIMARY KEY, server TEXT NOT NULL, server_deleted_at TEXT, action TEXT NOT NULL CHECK (action IN ('create', 'start', 'restart', 'rebuild', 'stop', 'destroy')), state TEXT NOT NULL, stage TEXT NOT NULL, started_at TEXT NOT NULL, finished_at TEXT, triggered_by TEXT NOT NULL, error TEXT, warnings TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS server_deployment_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, deployment_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE, created_at TEXT NOT NULL, message TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_server_deployment_events ON server_deployment_events(deployment_id, id); CREATE TABLE IF NOT EXISTS server_creation_jobs ( deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE, server TEXT NOT NULL, port INTEGER NOT NULL, game_mode TEXT NOT NULL, map_slug TEXT NOT NULL, expected_map TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS server_rebuild_jobs ( deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE, removed_at TEXT ); -- Stable links from consumers to lifecycle jobs. Sources survive their caller's -- history retention, while current pointers live on the caller's server rows. CREATE TABLE IF NOT EXISTS provisioning_sources ( operation_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('campaign','beta','maintenance','watchdog')), source_id TEXT NOT NULL, server TEXT NOT NULL, PRIMARY KEY (operation_id, kind, source_id, server) ); -- Blueprints retain map/count selections only. The legacy column name remains -- compatible with older snapshot-based definitions, read as selections at runtime. CREATE TABLE IF NOT EXISTS server_blueprints ( id TEXT PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE UNIQUE, description TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, recipe TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS server_launch_previews ( id TEXT PRIMARY KEY, expires_at TEXT NOT NULL, server TEXT, port INTEGER, recipe TEXT NOT NULL, blueprint_name TEXT, deployment_id TEXT REFERENCES server_deployments(id) ON DELETE CASCADE ); -- Historical launch files survive library deletion. New launches resolve current -- promotions instead of replaying these snapshots; credentials stay in their stores. CREATE TABLE IF NOT EXISTS server_launch_recipes ( deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id) ON DELETE CASCADE, recipe TEXT NOT NULL, blueprint_name TEXT ); CREATE TABLE IF NOT EXISTS server_launch_collections ( id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL, mode TEXT NOT NULL DEFAULT 'fill', retained_servers TEXT NOT NULL DEFAULT '[]', retained_server_deletions TEXT NOT NULL DEFAULT '{}' ); CREATE TABLE IF NOT EXISTS server_collection_members ( collection_id TEXT NOT NULL REFERENCES server_launch_collections(id) ON DELETE CASCADE, deployment_id TEXT NOT NULL UNIQUE REFERENCES server_deployments(id) ON DELETE CASCADE, position INTEGER NOT NULL, PRIMARY KEY (collection_id, position) ); CREATE TABLE IF NOT EXISTS server_collection_previews ( id TEXT PRIMARY KEY, expires_at TEXT NOT NULL, blueprint_name TEXT NOT NULL, entries TEXT NOT NULL, servers TEXT NOT NULL, mode TEXT NOT NULL DEFAULT 'fill', fleet_snapshot TEXT, collection_id TEXT REFERENCES server_launch_collections(id) ON DELETE CASCADE ); -- A pending removal can be shared by multiple reviewed collection requests. CREATE TABLE IF NOT EXISTS server_collection_removals ( collection_id TEXT NOT NULL REFERENCES server_launch_collections(id) ON DELETE CASCADE, deployment_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE, position INTEGER NOT NULL, PRIMARY KEY (collection_id, deployment_id) ); -- Reusing a port is safe only after the reviewed removal has completed. CREATE TABLE IF NOT EXISTS server_creation_dependencies ( deployment_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE, removal_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE, PRIMARY KEY (deployment_id, removal_id) ); -- Persist state transitions atomically, including changes made by background workers. -- Polls with unchanged status/error do not produce duplicate activity entries. CREATE TABLE IF NOT EXISTS beta_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), message TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_beta_events ON beta_events(beta_id, id); CREATE TRIGGER IF NOT EXISTS beta_activity_open AFTER INSERT ON betas BEGIN INSERT INTO beta_events (beta_id, created_at, message) VALUES (NEW.id, NEW.opened_at, 'Beta opened'); END; CREATE TABLE IF NOT EXISTS campaign_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), message TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_campaign_events ON campaign_events(deployment_id, id); CREATE TRIGGER IF NOT EXISTS campaign_activity_file AFTER INSERT ON deployment_files BEGIN INSERT INTO campaign_events (deployment_id, message) VALUES (NEW.deployment_id, NEW.server || ': ' || NEW.verb || ' ' || NEW.rel_path); END; CREATE TRIGGER IF NOT EXISTS campaign_activity_open AFTER INSERT ON deployments BEGIN INSERT INTO campaign_events (deployment_id, created_at, message) VALUES (NEW.id, NEW.started_at, 'Campaign started: ' || NEW.action); END; CREATE TRIGGER IF NOT EXISTS beta_activity_state AFTER UPDATE OF state ON betas WHEN OLD.state IS NOT NEW.state BEGIN INSERT INTO beta_events (beta_id, message) VALUES (NEW.id, CASE NEW.state WHEN 'ready-promote' THEN 'All beta servers are ready for review and promotion' WHEN 'promoted' THEN 'Beta promoted' WHEN 'rolled-back' THEN 'Beta rolled back' ELSE 'Beta state: ' || NEW.state END); END; CREATE TRIGGER IF NOT EXISTS campaign_activity_finish AFTER UPDATE OF finished_at ON deployments WHEN OLD.finished_at IS NULL AND NEW.finished_at IS NOT NULL BEGIN INSERT INTO campaign_events (deployment_id, created_at, message) VALUES (NEW.id, NEW.finished_at, 'Campaign processing finished; see server statuses for held or manual work'); END; CREATE TRIGGER IF NOT EXISTS beta_activity_server_insert AFTER INSERT ON beta_servers BEGIN INSERT INTO beta_events (beta_id, message) VALUES (NEW.beta_id, NEW.server || ': ' || CASE WHEN NEW.stage = 'deploying' THEN 'Deploying artifacts' WHEN NEW.stage = 'awaiting-empty' THEN 'Waiting for an empty server' WHEN NEW.stage = 'restarting' THEN 'Restarting server' WHEN NEW.stage = 'awaiting-health' THEN 'Verifying health' WHEN NEW.stage = 'awaiting-match' THEN 'Waiting for a completed match' WHEN NEW.stage = 'ready' THEN 'Ready for promotion' WHEN NEW.stage = 'failed' THEN 'Failed' ELSE NEW.stage END || CASE WHEN NEW.error IS NOT NULL THEN ' — ' || NEW.error ELSE '' END); END; CREATE TRIGGER IF NOT EXISTS beta_activity_server_update AFTER UPDATE ON beta_servers WHEN OLD.stage IS NOT NEW.stage OR OLD.error IS NOT NEW.error OR OLD.restarted_at IS NOT NEW.restarted_at BEGIN INSERT INTO beta_events (beta_id, message) VALUES (NEW.beta_id, NEW.server || ': ' || CASE WHEN NEW.stage = 'deploying' THEN 'Deploying artifacts' WHEN NEW.stage = 'awaiting-empty' THEN 'Waiting for an empty server' WHEN NEW.stage = 'restarting' THEN 'Restarting server' WHEN NEW.stage = 'awaiting-health' THEN 'Verifying health' WHEN NEW.stage = 'awaiting-match' THEN 'Waiting for a completed match' WHEN NEW.stage = 'ready' THEN 'Ready for promotion' WHEN NEW.stage = 'failed' THEN 'Failed' ELSE NEW.stage END || CASE WHEN NEW.error IS NOT NULL THEN ' — ' || NEW.error ELSE '' END); END; CREATE TRIGGER IF NOT EXISTS campaign_activity_server_insert AFTER INSERT ON deployment_servers BEGIN INSERT INTO campaign_events (deployment_id, message) VALUES (NEW.deployment_id, NEW.server || ': ' || CASE WHEN NEW.restart_state = 'pending' AND NEW.restarted_at IS NOT NULL THEN 'Restart requested; verifying health' WHEN NEW.restart_state = 'pending' THEN 'Waiting for an empty server or restart capacity' WHEN NEW.restart_state = 'done' THEN 'Server complete' WHEN NEW.restart_state = 'failed' THEN 'Failed' WHEN NEW.restart_state = 'manual' THEN 'Manual restart required' WHEN NEW.restart_state = 'held' THEN 'Changes held' ELSE NEW.restart_state END || CASE WHEN NEW.error IS NOT NULL THEN ' — ' || NEW.error ELSE '' END); END; CREATE TRIGGER IF NOT EXISTS campaign_activity_server_update AFTER UPDATE ON deployment_servers WHEN OLD.restart_state IS NOT NEW.restart_state OR OLD.error IS NOT NEW.error OR OLD.restarted_at IS NOT NEW.restarted_at BEGIN INSERT INTO campaign_events (deployment_id, message) VALUES (NEW.deployment_id, NEW.server || ': ' || CASE WHEN NEW.restart_state = 'pending' AND NEW.restarted_at IS NOT NULL THEN 'Restart requested; verifying health' WHEN NEW.restart_state = 'pending' THEN 'Waiting for an empty server or restart capacity' WHEN NEW.restart_state = 'done' THEN 'Server complete' WHEN NEW.restart_state = 'failed' THEN 'Failed' WHEN NEW.restart_state = 'manual' THEN 'Manual restart required' WHEN NEW.restart_state = 'held' THEN 'Changes held' ELSE NEW.restart_state END || CASE WHEN NEW.error IS NOT NULL THEN ' — ' || NEW.error ELSE '' END); END; -- Historical occupancy survives server removal; no player identities are stored. CREATE TABLE IF NOT EXISTS metric_samples ( at INTEGER NOT NULL, server TEXT NOT NULL, game TEXT NOT NULL, map TEXT NOT NULL, players INTEGER, capacity INTEGER, PRIMARY KEY (at, server) ); -- Watchdog history deliberately survives server destruction/rebuild. CREATE TABLE IF NOT EXISTS watchdog_incidents ( id TEXT PRIMARY KEY, server TEXT NOT NULL, server_deleted_at TEXT, kind TEXT NOT NULL CHECK (kind IN ('health','drift')), state TEXT NOT NULL CHECK (state IN ('open','recovering','attention','resolved','dismissed')), summary TEXT NOT NULL, details TEXT NOT NULL DEFAULT '{}', first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, finished_at TEXT, failures INTEGER NOT NULL DEFAULT 1, cycle INTEGER NOT NULL DEFAULT 1 ); CREATE UNIQUE INDEX IF NOT EXISTS idx_watchdog_active ON watchdog_incidents(server,kind) WHERE state != 'resolved'; CREATE TABLE IF NOT EXISTS watchdog_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, incident_id TEXT NOT NULL REFERENCES watchdog_incidents(id) ON DELETE CASCADE, created_at TEXT NOT NULL, message TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_watchdog_events ON watchdog_events(incident_id,id); CREATE TABLE IF NOT EXISTS watchdog_actions ( id TEXT PRIMARY KEY, incident_id TEXT NOT NULL REFERENCES watchdog_incidents(id) ON DELETE CASCADE, action TEXT NOT NULL CHECK (action IN ('restart','rebuild')), operation_id TEXT NOT NULL UNIQUE, campaign_id TEXT, created_at TEXT NOT NULL, cycle INTEGER NOT NULL DEFAULT 1, UNIQUE(incident_id,cycle,action) );