Rework as scripts-centric, upload-only, gated beta lifecycle
Scripts become first-class: named, versioned, uploaded via the UI, optionally scoped to a game or map. Any change that would land on scope-matched servers (marking-required or new version of a scoped script) must first pass a beta. The beta runs a per-server state machine — deploy -beta.gsc → wait empty → Komodo restart → wait healthy → optionally wait for a real match end (games_sp.log 'ExitLevel: executed') — before it's eligible for promotion. Rollback is always available and restores from .OLD. The shared/scripts mirror and its betas/ staging folder are gone. Every server now has a tracked script inventory (server_scripts table), populated only by explicit upload flows. New: /scripts, /scripts/[id], /betas/[id] UI; /api/scripts + /api/betas + /api/servers/[name]/scripts endpoints; scripts.ts, inventory.ts, beta.ts (rewritten), beta-runner.ts, match-log.ts, komodoInspectHealthy(). Removed: push.ts, /api/push, BetaPanel, legacy importer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,10 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
|
||||
{ name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' },
|
||||
{ name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' },
|
||||
{ name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' },
|
||||
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' }
|
||||
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' },
|
||||
// Script-scope columns — static per server, populated via server-edit UI.
|
||||
{ name: 'game', ddl: 'ALTER TABLE servers ADD COLUMN game TEXT' },
|
||||
{ name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' }
|
||||
];
|
||||
|
||||
function migrateServers(handle: Database.Database): void {
|
||||
@@ -38,6 +41,39 @@ function migrateServers(handle: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
// The pre-script-model beta tables had entirely different shapes (slug PK,
|
||||
// beta_scripts, beta_deploys). schema.sql defines the new shape; CREATE TABLE
|
||||
// IF NOT EXISTS is a no-op if the old tables already exist, so we have to
|
||||
// detect and drop them first. Any outstanding rows abort the boot so the
|
||||
// operator can promote/rollback in the old app before upgrading.
|
||||
function dropLegacyBetaTables(handle: Database.Database): void {
|
||||
const legacy = handle
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('betas','beta_scripts','beta_deploys')")
|
||||
.all() as Array<{ name: string }>;
|
||||
if (legacy.length === 0) return;
|
||||
|
||||
const legacyNames = legacy.map((r) => r.name);
|
||||
// The old `betas` table had a `slug` column; the new one has `id` + `script_id`.
|
||||
// If we see `slug`, we know it's the old shape.
|
||||
const betasCols = handle.prepare("PRAGMA table_info(betas)").all() as Array<{ name: string }>;
|
||||
const isLegacyShape = betasCols.some((c) => c.name === 'slug');
|
||||
if (!isLegacyShape) return;
|
||||
|
||||
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
|
||||
if (!legacyNames.includes(table)) continue;
|
||||
const row = handle.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number };
|
||||
if (row.n > 0) {
|
||||
throw new Error(
|
||||
`legacy ${table} still has ${row.n} rows — promote or rollback outstanding betas in the previous version before upgrading`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Drop in FK-safe order.
|
||||
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
|
||||
if (legacyNames.includes(table)) handle.exec(`DROP TABLE ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function db(): Database.Database {
|
||||
if (_db) return _db;
|
||||
|
||||
@@ -45,6 +81,7 @@ export function db(): Database.Database {
|
||||
_db = new Database(config.dbPath);
|
||||
_db.pragma('journal_mode = WAL');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
dropLegacyBetaTables(_db);
|
||||
_db.exec(schemaSql);
|
||||
migrateServers(_db);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts.
|
||||
-- Mirrors the shapes tracked in the legacy script_metadata.json but in real SQL.
|
||||
-- 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;
|
||||
@@ -11,6 +13,8 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
komodo_server TEXT,
|
||||
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
|
||||
@@ -22,37 +26,74 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
-- One row per script (a logical thing, e.g. "HUD tweaks"). Name is unique and
|
||||
-- doubles as the slug in URLs.
|
||||
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
|
||||
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);
|
||||
|
||||
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,
|
||||
sha256 TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
notes TEXT,
|
||||
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:<id>' | 'promote:<id>' | 'rollback:<id>'
|
||||
PRIMARY KEY (server, rel_path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_scripts_server ON server_scripts(server);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS betas (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
opened_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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, -- 'mark-required' | 'version-update' | 'unscoped'
|
||||
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_scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
beta_slug TEXT NOT NULL REFERENCES betas(slug) ON DELETE CASCADE,
|
||||
staged_as TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
beta_file TEXT NOT NULL,
|
||||
backup TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
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,
|
||||
prior_version_id INTEGER REFERENCES script_versions(id),
|
||||
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_scripts_slug ON beta_scripts(beta_slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS beta_deploys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
beta_script_id INTEGER NOT NULL REFERENCES beta_scripts(id) ON DELETE CASCADE,
|
||||
server TEXT NOT NULL REFERENCES servers(name),
|
||||
had_original INTEGER NOT NULL,
|
||||
UNIQUE(beta_script_id, server)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_beta_deploys_server ON beta_deploys(server);
|
||||
CREATE INDEX IF NOT EXISTS idx_beta_servers_stage ON beta_servers(stage);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deployments (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -83,7 +124,7 @@ 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 | held
|
||||
verb TEXT NOT NULL, -- added | updated | removed
|
||||
rel_path TEXT NOT NULL,
|
||||
sha256_before TEXT,
|
||||
sha256_after TEXT,
|
||||
|
||||
Reference in New Issue
Block a user