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:
+100
-102
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user