Replace -beta sidecar and .OLD backup with DB snapshots

Beta deploys used to rename foo.gsc -> foo.OLD and write beta bytes to
a foo-beta.gsc sidecar, forcing the mod loader to know the naming
convention. Now the beta content lives at the actual target filename
(same as configs already did) and the pre-beta bytes are captured in
a new beta_file_snapshots table for rollback.

- schema.sql: new beta_file_snapshots(beta_id, server, rel_path,
  prior_content BLOB, prior_sha256, prior_size). Handles scripts,
  plugins, and the composed dedicated_sp.cfg uniformly.
- db/index.ts: assertSnapshotSchemeSafe boot guard refuses to start
  if a legacy running/ready-promote beta is present on first upgrade.
  A settings sentinel (snapshots_v1) makes it one-shot.
- beta.ts openBeta: read prior bytes, insert snapshot, overwrite
  target directly.
- beta.ts rollbackBeta: restore from snapshot if had_original, else
  delete the fresh file. Drop snapshot rows after restore.
- beta.ts promoteBeta: no more rm -beta / rm .OLD calls, pull prior
  content from the snapshot table for the deployment audit, drop
  snapshots after promote.
- files.ts: delete betaFile() and oldFile().
- drift.ts: docstring updated; no special-casing needed since beta
  bytes and inventory now agree on the target rel_path.
- servers/[name]/+page.svelte: drop dead -beta/.OLD row filters.
- tests: plugin write asserts the target filename; unused
  betaFile/oldFile tests removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 17:57:43 -04:00
co-authored by Claude Opus 4.7
parent 30b0d1d4da
commit 8c15cae80d
8 changed files with 154 additions and 163 deletions
+25
View File
@@ -149,6 +149,30 @@ function dropLegacyBetaTables(handle: Database.Database): void {
}
}
// One-shot guard for the -beta/.OLD → DB-snapshot cutover. Legacy running/
// ready-promote betas from the old code have -beta.<ext> and .OLD files on
// disk with no corresponding beta_file_snapshots rows — restoring them under
// the new rollback path would delete-instead-of-restore. If a legacy active
// beta is detected on the first boot after upgrade, refuse to start so the
// operator can rollback/promote under the old code. A settings sentinel marks
// the cutover complete so subsequent boots with (legitimately new-scheme)
// active betas aren't blocked.
function assertSnapshotSchemeSafe(handle: Database.Database): void {
const done = handle.prepare("SELECT value FROM settings WHERE key = 'snapshots_v1'").get() as
| { value: string }
| undefined;
if (done) return;
const active = handle
.prepare("SELECT id FROM betas WHERE state IN ('running','ready-promote') LIMIT 1")
.get() as { id: number } | undefined;
if (active) {
throw new Error(
`close active beta ${active.id} before upgrading — snapshot scheme is incompatible with legacy -beta/.OLD state`
);
}
handle.prepare("INSERT INTO settings (key, value) VALUES ('snapshots_v1', '1')").run();
}
export function db(): Database.Database {
if (_db) return _db;
@@ -179,6 +203,7 @@ export function db(): Database.Database {
);
handle.exec(`CREATE INDEX IF NOT EXISTS idx_scripts_kind ON scripts(kind)`);
backfillMultiScriptJoins(handle);
assertSnapshotSchemeSafe(handle);
} catch (err) {
handle.close();
throw err;
+18 -2
View File
@@ -123,8 +123,9 @@ 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 what each script's target
-- looked like on that server before the beta wrote its -beta.gsc.
-- 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),
@@ -135,6 +136,21 @@ CREATE TABLE IF NOT EXISTS beta_server_scripts (
);
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'
+100 -102
View File
@@ -1,9 +1,9 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, posix } from 'node:path';
import { betaArchiveOf, config, destOf, destOfKind, type ArtifactKind } from '../config.js';
import { db } from '../db/index.js';
import { betaFile, oldFile, sha256Buffer } from './files.js';
import { sha256Buffer } from './files.js';
import { ensureDest, getServer, heldByBeta } from './servers.js';
import {
getBaselineForScope,
@@ -338,24 +338,18 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
}
// Per-(server, script) conflict + stale-.OLD check.
// Per-(server, script) conflict check. heldByBeta covers "another running
// beta already claimed this target"; the previous stale-.OLD check is no
// longer relevant now that snapshots live in the DB.
const conflicts: Array<{ server: string; scriptId: number; heldByBetaId: number }> = [];
const blocked: Array<{ server: string; ofile: string }> = [];
for (const name of input.servers) {
const held = heldByBeta(name);
for (const { script } of resolved) {
const owner = held.get(script.target_rel_path);
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
const dest = destOfKind(name, script.kind);
const targetPath = join(dest, script.target_rel_path);
const backupPath = join(dest, oldFile(script.target_rel_path));
if (existsSync(targetPath) && existsSync(backupPath)) {
blocked.push({ server: name, ofile: oldFile(script.target_rel_path) });
}
}
}
if (conflicts.length > 0) throw new BetaError('target already held by another beta', 'conflict', conflicts);
if (blocked.length > 0) throw new BetaError('stale .OLD backups in the way', 'blocked-backup', blocked);
// Ensure destinations exist. The `ensureDest` helper covers only the
// script destination today; config/plugin destinations are created
@@ -417,15 +411,20 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
const collapsingOverlays = resolved.filter((r) => r.script.kind === 'config');
const collapsingBaseline = resolved.find((r) => r.script.kind === 'baseline');
// Archive: one -beta.gsc per non-collapsing artifact, plus one composed
// preview per beta server for the config layer.
// Archive: one copy per non-collapsing artifact under its target basename,
// plus one composed preview per beta server for the config layer.
const archive = betaArchiveOf(betaId);
await mkdir(archive, { recursive: true });
for (const r of nonCollapsing) {
const bfile = betaFile(r.script.target_rel_path);
await writeFile(join(archive, posix.basename(bfile)), r.version.content);
const base = posix.basename(r.script.target_rel_path);
await writeFile(join(archive, base), r.version.content);
}
const insertSnapshot = db().prepare(
`INSERT INTO beta_file_snapshots (beta_id, server, rel_path, prior_content, prior_sha256, prior_size)
VALUES (?, ?, ?, ?, ?, ?)`
);
// On-disk deploy per server. If any write fails on a server, mark the
// whole server 'failed' — we can't restart a partially deployed server
// safely. Remaining artifacts on that server are skipped.
@@ -438,35 +437,35 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
for (const name of input.servers) {
const serverRow = serverRows.find((r) => r.name === name)!;
let firstErr: string | null = null;
// 1. Non-collapsing artifacts: script/plugin — one file each.
// 1. Non-collapsing artifacts: script/plugin — write directly to the
// target filename. Pre-beta bytes go into beta_file_snapshots so
// rollback can restore them.
for (const { script, version } of nonCollapsing) {
if (firstErr) break;
const dest = destOfKind(name, script.kind);
const bfile = betaFile(script.target_rel_path);
const ofile = oldFile(script.target_rel_path);
const targetPath = join(dest, script.target_rel_path);
const backupPath = join(dest, ofile);
const betaPath = join(dest, bfile);
try {
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
if (existsSync(targetPath)) {
await rename(targetPath, backupPath);
const prior = getServerScript(name, script.target_rel_path);
removeScript(name, script.target_rel_path);
if (prior) {
recordScript(name, ofile, prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`);
}
const priorBytes = await readFile(targetPath);
insertSnapshot.run(
betaId,
name,
script.target_rel_path,
priorBytes,
sha256Buffer(priorBytes),
priorBytes.byteLength
);
}
await writeFile(betaPath, version.content);
recordScript(name, bfile, version.id, version.sha256, version.size, `beta:${betaId}`);
await writeFile(targetPath, version.content);
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `beta:${betaId}`);
} catch (err) {
firstErr = `${script.name}: ${(err as Error).message}`;
}
}
// 2. Collapsing layer: compose the effective dedicated_sp.cfg from
// baseline + all applicable overlays, write in place (no -beta
// suffix — the game reads the exact filename), backup the prior
// file as .OLD once.
// baseline + all applicable overlays, write in place. Pre-beta cfg
// bytes go into beta_file_snapshots keyed on the target rel_path.
const hasCollapsing = collapsingBaseline || collapsingOverlays.length > 0;
if (!firstErr && hasCollapsing) {
try {
@@ -483,14 +482,16 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
await mkdir(dest, { recursive: true });
const targetRel = config.configTargetFile;
const targetPath = join(dest, targetRel);
const backupPath = join(dest, oldFile(targetRel));
if (existsSync(targetPath)) {
await rename(targetPath, backupPath);
const prior = getServerScript(name, targetRel);
removeScript(name, targetRel);
if (prior) {
recordScript(name, oldFile(targetRel), prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`);
}
const priorBytes = await readFile(targetPath);
insertSnapshot.run(
betaId,
name,
targetRel,
priorBytes,
sha256Buffer(priorBytes),
priorBytes.byteLength
);
}
const composedBuf = Buffer.from(composedResult.text, 'utf8');
await writeFile(targetPath, composedBuf);
@@ -563,44 +564,38 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
const getSnapshot = db().prepare(
`SELECT prior_content, prior_sha256, prior_size FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?`
);
for (const bs of servers) {
for (const { script } of bundle) {
if (isCollapsingKind(script.kind)) continue; // handled once per server below
const dest = destOfKind(bs.server, script.kind);
const bfile = betaFile(script.target_rel_path);
const ofile = oldFile(script.target_rel_path);
const betaPath = join(dest, bfile);
const backupPath = join(dest, ofile);
const targetPath = join(dest, script.target_rel_path);
// Remove -beta.<ext> regardless of stage.
if (existsSync(betaPath)) await rm(betaPath, { force: true });
removeScript(bs.server, bfile);
const bss = getBetaServerScript(betaId, bs.server, script.id);
if (bss?.had_original) {
if (existsSync(backupPath)) {
await rename(backupPath, targetPath);
removeScript(bs.server, ofile);
const prior = bss.prior_version_id ? getVersion(bss.prior_version_id) : null;
if (prior) {
recordScript(bs.server, script.target_rel_path, prior.id, prior.sha256, prior.size, `rollback:${betaId}`);
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
| undefined;
if (snap) {
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
await writeFile(targetPath, snap.prior_content);
if (bss.prior_version_id) {
recordScript(bs.server, script.target_rel_path, bss.prior_version_id, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
} else {
const buf = await readFile(targetPath);
recordScript(
bs.server,
script.target_rel_path,
null,
sha256Buffer(buf),
buf.byteLength,
`rollback:${betaId}`
);
recordScript(bs.server, script.target_rel_path, null, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
}
restored.add(bs.server);
} else {
warnings.push(`${bs.server}/${script.name}: no ${ofile} to restore`);
warnings.push(`${bs.server}/${script.name}: snapshot missing — cannot restore`);
}
} else {
// No prior file — the beta wrote a fresh file. Drop it.
if (existsSync(targetPath)) await rm(targetPath, { force: true });
removeScript(bs.server, script.target_rel_path);
dropped.add(bs.server);
}
}
@@ -609,24 +604,17 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
if (hasCollapsing) {
const dest = destOfKind(bs.server, 'config');
const targetRel = config.configTargetFile;
const backupPath = join(dest, oldFile(targetRel));
const targetPath = join(dest, targetRel);
if (existsSync(backupPath)) {
if (existsSync(targetPath)) await rm(targetPath, { force: true });
await rename(backupPath, targetPath);
removeScript(bs.server, oldFile(targetRel));
const buf = await readFile(targetPath);
recordScript(
bs.server,
targetRel,
null,
sha256Buffer(buf),
buf.byteLength,
`rollback:${betaId}`
);
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
| undefined;
if (snap) {
await mkdir(dest, { recursive: true });
await writeFile(targetPath, snap.prior_content);
recordScript(bs.server, targetRel, null, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
restored.add(bs.server);
} else if (existsSync(targetPath)) {
// No .OLD ⇒ the beta wrote the cfg into a server that had
// No snapshot ⇒ the beta wrote the cfg into a server that had
// no pre-existing file. Remove the beta-written cfg to leave
// the server clean.
await rm(targetPath, { force: true });
@@ -636,6 +624,9 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
}
}
// Snapshots are single-use — safe to drop now that the disk is restored.
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
// Wipe archive + close out beta.
const archive = betaArchiveOf(betaId);
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
@@ -722,6 +713,11 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
}
const plans: Plan[] = [];
const getSnapshotForPromote = db().prepare(
`SELECT prior_content, prior_sha256 FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?`
);
for (const { script, version } of nonCollapsing) {
const scopeServers = script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value)
@@ -734,12 +730,14 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
if (betaServerSet.has(name)) {
const bss = getBetaServerScript(betaId, name, script.id);
if (bss?.had_original) {
const backupPath = join(dest, oldFile(script.target_rel_path));
const snap = getSnapshotForPromote.get(betaId, name, script.target_rel_path) as
| { prior_content: Buffer; prior_sha256: string }
| undefined;
let priorContent: Buffer | null = null;
let priorSha: string | null = null;
if (existsSync(backupPath)) {
priorContent = await readFile(backupPath);
priorSha = sha256Buffer(priorContent);
if (snap) {
priorContent = snap.prior_content;
priorSha = snap.prior_sha256;
} else if (bss.prior_version_id) {
const pv = getVersion(bss.prior_version_id);
if (pv) { priorContent = pv.content; priorSha = pv.sha256; }
@@ -777,15 +775,12 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
const serverErr = new Map<string, string>();
for (const p of plans) {
const dest = destOfKind(p.server, p.script.kind);
const bfile = betaFile(p.script.target_rel_path);
const ofile = oldFile(p.script.target_rel_path);
const targetPath = join(dest, p.script.target_rel_path);
const betaPath = join(dest, bfile);
const backupPath = join(dest, ofile);
try {
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
if (existsSync(betaPath)) { await rm(betaPath, { force: true }); removeScript(p.server, bfile); }
if (existsSync(backupPath)) { await rm(backupPath, { force: true }); removeScript(p.server, ofile); }
// Beta servers already have the version bytes at targetPath from
// openBeta; scope-expansion servers need the write. Idempotent
// either way — we just overwrite with the promoted content.
await writeFile(targetPath, p.version.content);
planWritten.set(p, true);
} catch (err) {
@@ -835,18 +830,24 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
const dest = destOfKind(name, 'config');
const targetRel = config.configTargetFile;
const targetPath = join(dest, targetRel);
const backupPath = join(dest, oldFile(targetRel));
let priorContent: Buffer | null = null;
let priorSha: string | null = null;
let hadFile = false;
// Beta server: prior was the pre-beta content saved as .OLD.
if (betaServerSet.has(name) && existsSync(backupPath)) {
priorContent = await readFile(backupPath);
priorSha = sha256Buffer(priorContent);
hadFile = true;
} else if (existsSync(targetPath)) {
// Scope-expansion server: current on-disk cfg is the "before".
// Beta server: prior cfg bytes were snapshotted at openBeta.
if (betaServerSet.has(name)) {
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
| { prior_content: Buffer; prior_sha256: string }
| undefined;
if (snap) {
priorContent = snap.prior_content;
priorSha = snap.prior_sha256;
hadFile = true;
}
}
if (!hadFile && existsSync(targetPath)) {
// Scope-expansion server (or beta server with no snapshot):
// current on-disk cfg is the "before".
priorContent = await readFile(targetPath);
priorSha = sha256Buffer(priorContent);
hadFile = true;
@@ -863,15 +864,9 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
await mkdir(dest, { recursive: true });
const targetRel = config.configTargetFile;
const targetPath = join(dest, targetRel);
const backupPath = join(dest, oldFile(targetRel));
// Beta server already has composed content at targetPath; overwrite
// idempotently. Scope-expansion server: write in place. Either way
// clear any lingering .OLD from the beta stage.
// idempotently. Scope-expansion server: write in place.
await writeFile(targetPath, cp.composedContent);
if (existsSync(backupPath)) {
await rm(backupPath, { force: true });
removeScript(cp.server, oldFile(targetRel));
}
cfgWritten.set(cp, true);
} catch (err) {
cfgWritten.set(cp, false);
@@ -990,6 +985,9 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
})();
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
// Snapshots are single-use — the promoted content is live on disk, so the
// pre-beta bytes are no longer needed.
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
const archive = betaArchiveOf(betaId);
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
+5 -3
View File
@@ -78,9 +78,11 @@ async function walkFiles(root: string): Promise<string[]> {
/**
* Scan every managed destination tree on a server (scripts, configs, plugins)
* and return every file that isn't tracked in server_scripts. Beta artifacts
* (-beta.gsc, .OLD, -beta.dll, composed dedicated_sp.cfg) are tracked while
* a beta is active so they don't appear as drift.
* and return every file that isn't tracked in server_scripts. During a beta,
* the beta bytes live at the actual target filename and the inventory row is
* updated to reflect them, so beta files register as tracked with no
* special-casing (pre-beta bytes are snapshotted to beta_file_snapshots for
* rollback rather than left on disk as .OLD).
*
* The inventory table (server_scripts) keys on (server, rel_path) with no
* destination discriminator. Rel_paths across destinations don't collide in
-30
View File
@@ -1,35 +1,5 @@
import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { posix } from 'node:path';
// Naming rules for beta artifacts, preserved from the legacy shell script.
/**
* `foo/bar.gsc` → `foo/bar-beta.gsc`. Used to name the beta copy that lives
* next to (or in place of) the target file on each server during a beta.
*/
export function betaFile(rel: string): string {
const dir = posix.dirname(rel);
const base = posix.basename(rel);
const dot = base.lastIndexOf('.');
const stem = dot === -1 ? base : base.slice(0, dot);
const ext = dot === -1 ? '' : base.slice(dot);
const named = `${stem}-beta${ext}`;
return dir === '.' ? named : `${dir}/${named}`;
}
/**
* `foo/bar.gsc` → `foo/bar.OLD`. Used to back up the previous target when a
* beta lands on top of it, so rollback can restore.
*/
export function oldFile(rel: string): string {
const dir = posix.dirname(rel);
const base = posix.basename(rel);
const dot = base.lastIndexOf('.');
const stem = dot === -1 ? base : base.slice(0, dot);
const named = `${stem}.OLD`;
return dir === '.' ? named : `${dir}/${named}`;
}
export async function sha256(path: string): Promise<string> {
const buf = await readFile(path);
+3 -5
View File
@@ -466,13 +466,13 @@
</section>
<section class="panel">
<h2>Scripts on this server ({inventory.filter((f) => !f.relPath.endsWith('.OLD') && !/-beta\./.test(f.relPath)).length})</h2>
<h2>Scripts on this server ({inventory.length})</h2>
{#if inventory.length === 0}
<p class="empty">no scripts tracked — upload one from the Scripts page and open a beta to deploy it here</p>
{:else}
<div class="inv-list">
{#each inventory as f (f.relPath)}
<div class="inv-row inv-{f.relPath.endsWith('.OLD') ? 'old' : /-beta\./.test(f.relPath) ? 'beta' : 'live'}">
<div class="inv-row">
<span class="rel mono">{f.relPath}</span>
<span class="size mono">{f.size}B</span>
<span class="src mono">{f.source ?? '—'}</span>
@@ -910,9 +910,7 @@
font-size: var(--text-mono-sm);
align-items: center;
}
.inv-row.inv-old { opacity: 0.55; border-left: 3px solid var(--ink-500); }
.inv-row.inv-beta { border-left: 3px solid var(--amber-500); }
.inv-row.inv-live { border-left: 3px solid var(--spark-500); }
.inv-row { border-left: 3px solid var(--spark-500); }
.inv-row .rel { color: var(--cream); word-break: break-all; }
.inv-row .size, .inv-row .src, .inv-row .sha { color: var(--ink-500); }
</style>