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
+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);