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:
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
+99
-101
@@ -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);
|
||||
// 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;
|
||||
} else if (existsSync(targetPath)) {
|
||||
// Scope-expansion server: current on-disk cfg is the "before".
|
||||
}
|
||||
}
|
||||
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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,7 +34,7 @@ async function seedFleet(): Promise<void> {
|
||||
}
|
||||
|
||||
describe('config baseline + overlay flow', () => {
|
||||
it('composes baseline + overlay and writes dedicated_sp.cfg on beta open, restores .OLD on rollback', async () => {
|
||||
it('composes baseline + overlay and writes dedicated_sp.cfg on beta open, restores pre-beta state on rollback', async () => {
|
||||
await seedFleet();
|
||||
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
@@ -120,7 +120,7 @@ describe('config baseline + overlay flow', () => {
|
||||
});
|
||||
|
||||
describe('plugin kind writes to plugin destination', () => {
|
||||
it('deploys a plugin .dll under runtime/plutonium/plugins with -beta suffix', async () => {
|
||||
it('deploys a plugin .dll directly to its target filename', async () => {
|
||||
await seedFleet();
|
||||
// Pre-create the plugins dir so the write can succeed.
|
||||
await mkdir(join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins'), { recursive: true });
|
||||
@@ -144,7 +144,7 @@ describe('plugin kind writes to plugin destination', () => {
|
||||
waitForMatch: false
|
||||
});
|
||||
|
||||
const pluginPath = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins', 'demo-beta.dll');
|
||||
const pluginPath = join(fixture.t5, 't5_kino_1', 'runtime/plutonium/plugins', 'demo.dll');
|
||||
expect(existsSync(pluginPath)).toBe(true);
|
||||
const bytes = await readFile(pluginPath);
|
||||
expect(bytes.equals(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(true);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { betaFile, oldFile } from '../../src/lib/server/fleet/files.js';
|
||||
import { slugify } from '../../src/lib/server/fleet/scripts.js';
|
||||
|
||||
describe('slugify', () => {
|
||||
@@ -9,20 +8,3 @@ describe('slugify', () => {
|
||||
expect(slugify('t5_kino_1')).toBe('t5-kino-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('betaFile', () => {
|
||||
it('inserts -beta before the extension', () => {
|
||||
expect(betaFile('hud.gsc')).toBe('hud-beta.gsc');
|
||||
expect(betaFile('perks/zom.gsc')).toBe('perks/zom-beta.gsc');
|
||||
});
|
||||
it('handles files without extension', () => {
|
||||
expect(betaFile('README')).toBe('README-beta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oldFile', () => {
|
||||
it('replaces extension with .OLD', () => {
|
||||
expect(oldFile('hud.gsc')).toBe('hud.OLD');
|
||||
expect(oldFile('perks/zom.gsc')).toBe('perks/zom.OLD');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user