-- 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; 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_state TEXT, -- last observed Komodo runtime state 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, -- wall-clock ISO when we first saw a J; line for the current match cycle game_ended_at TEXT, -- wall-clock ISO when we first saw ExitLevel/ShutdownGame end it 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); 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 REFERENCES servers(name), 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 REFERENCES servers(name), 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 REFERENCES servers(name), 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 REFERENCES servers(name), 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 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 ); -- 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, action TEXT NOT NULL CHECK (action IN ('create', '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 ); -- Launch recipes copy managed files, not credentials from the key/integration stores. -- Independent of the artifact library so history survives version deletion. 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 ); 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 ); 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, collection_id TEXT REFERENCES server_launch_collections(id) ON DELETE CASCADE ); -- 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;