multi script beta
This commit is contained in:
+395
-276
@@ -1,7 +1,7 @@
|
||||
import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, posix } from 'node:path';
|
||||
import { betaArchiveOf, config, destOf } from '../config.js';
|
||||
import { betaArchiveOf, destOf } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { betaFile, oldFile, sha256Buffer } from './files.js';
|
||||
import { ensureDest, getServer, heldByBeta } from './servers.js';
|
||||
@@ -17,13 +17,14 @@ import { recordScript, removeScript, getServerScript } from './inventory.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
import type { BetaStage } from '../pipeline/bus.js';
|
||||
|
||||
export type BetaTriggerKind = 'mark-required' | 'version-update' | 'unscoped';
|
||||
|
||||
export interface BetaRow {
|
||||
id: number;
|
||||
// Deprecated: the columns are still populated (NOT NULL on legacy schemas)
|
||||
// from the first bundled script's identity so existing data continues to
|
||||
// roundtrip, but new code reads beta_scripts for the authoritative list.
|
||||
script_id: number;
|
||||
script_version_id: number;
|
||||
trigger_kind: BetaTriggerKind;
|
||||
trigger_kind: string;
|
||||
wait_for_health: number;
|
||||
wait_for_match: number;
|
||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
@@ -35,8 +36,8 @@ export interface BetaRow {
|
||||
export interface BetaServerRow {
|
||||
beta_id: number;
|
||||
server: string;
|
||||
had_original: number;
|
||||
prior_version_id: number | null;
|
||||
had_original: number; // deprecated; per-script data lives in beta_server_scripts
|
||||
prior_version_id: number | null; // deprecated
|
||||
stage: BetaStage;
|
||||
error: string | null;
|
||||
deployed_at: string | null;
|
||||
@@ -46,6 +47,20 @@ export interface BetaServerRow {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BetaScriptRow {
|
||||
beta_id: number;
|
||||
script_id: number;
|
||||
script_version_id: number;
|
||||
}
|
||||
|
||||
export interface BetaServerScriptRow {
|
||||
beta_id: number;
|
||||
server: string;
|
||||
script_id: number;
|
||||
had_original: number;
|
||||
prior_version_id: number | null;
|
||||
}
|
||||
|
||||
export class BetaError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -53,6 +68,7 @@ export class BetaError extends Error {
|
||||
| 'no-such-script'
|
||||
| 'no-such-version'
|
||||
| 'version-script-mismatch'
|
||||
| 'no-scripts'
|
||||
| 'no-servers'
|
||||
| 'unknown-server'
|
||||
| 'out-of-scope'
|
||||
@@ -62,7 +78,9 @@ export class BetaError extends Error {
|
||||
| 'not-ready-promote'
|
||||
| 'not-running'
|
||||
| 'dest-fail'
|
||||
| 'invalid-state',
|
||||
| 'invalid-state'
|
||||
| 'duplicate-script'
|
||||
| 'duplicate-target',
|
||||
public readonly details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
@@ -92,79 +110,133 @@ export function listBetaServers(betaId: number): BetaServerRow[] {
|
||||
.all(betaId) as BetaServerRow[];
|
||||
}
|
||||
|
||||
export function listBetaScripts(betaId: number): BetaScriptRow[] {
|
||||
return db()
|
||||
.prepare('SELECT * FROM beta_scripts WHERE beta_id = ? ORDER BY script_id')
|
||||
.all(betaId) as BetaScriptRow[];
|
||||
}
|
||||
|
||||
export function getBetaServerScript(betaId: number, server: string, scriptId: number): BetaServerScriptRow | undefined {
|
||||
return db()
|
||||
.prepare('SELECT * FROM beta_server_scripts WHERE beta_id = ? AND server = ? AND script_id = ?')
|
||||
.get(betaId, server, scriptId) as BetaServerScriptRow | undefined;
|
||||
}
|
||||
|
||||
// A script is "in a running beta" if beta_scripts joins to a running beta row.
|
||||
// Existing callers only care about the running-beta id, so return that shape.
|
||||
export function findRunningBetaForScript(scriptId: number): BetaRow | undefined {
|
||||
return db()
|
||||
.prepare(`SELECT * FROM betas WHERE script_id = ? AND state = 'running' LIMIT 1`)
|
||||
.prepare(
|
||||
`SELECT b.* FROM betas b
|
||||
JOIN beta_scripts bs ON bs.beta_id = b.id
|
||||
WHERE bs.script_id = ? AND b.state = 'running'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(scriptId) as BetaRow | undefined;
|
||||
}
|
||||
|
||||
// ---------- openBeta ----------
|
||||
|
||||
export interface OpenBetaInput {
|
||||
export interface OpenBetaScript {
|
||||
scriptId: number;
|
||||
scriptVersionId: number;
|
||||
}
|
||||
|
||||
export interface OpenBetaInput {
|
||||
scripts: OpenBetaScript[];
|
||||
servers: string[];
|
||||
waitForHealth: boolean;
|
||||
waitForMatch: boolean;
|
||||
triggerKind: BetaTriggerKind;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface OpenBetaResult {
|
||||
betaId: number;
|
||||
servers: string[];
|
||||
scripts: OpenBetaScript[];
|
||||
}
|
||||
|
||||
export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
const script = getScript(input.scriptId);
|
||||
if (!script) throw new BetaError(`no such script (id=${input.scriptId})`, 'no-such-script');
|
||||
|
||||
const version = getVersion(input.scriptVersionId);
|
||||
if (!version) throw new BetaError(`no such version (id=${input.scriptVersionId})`, 'no-such-version');
|
||||
if (version.script_id !== script.id) {
|
||||
throw new BetaError('version does not belong to this script', 'version-script-mismatch');
|
||||
if (!input.scripts || input.scripts.length === 0) {
|
||||
throw new BetaError('at least one script is required', 'no-scripts');
|
||||
}
|
||||
|
||||
if (!input.servers || input.servers.length === 0) {
|
||||
throw new BetaError('at least one server is required', 'no-servers');
|
||||
}
|
||||
|
||||
// Look up server rows + validate scope up front.
|
||||
const rows: Array<{ name: string; game: string | null; map: string | null }> = [];
|
||||
// Resolve every (script, version) pair and validate belonging.
|
||||
interface Resolved { script: ScriptRow; version: ScriptVersionRow }
|
||||
const resolved: Resolved[] = [];
|
||||
const seenScriptIds = new Set<number>();
|
||||
const seenTargets = new Map<string, number>();
|
||||
for (const s of input.scripts) {
|
||||
if (seenScriptIds.has(s.scriptId)) {
|
||||
throw new BetaError(`script ${s.scriptId} listed twice in one beta`, 'duplicate-script');
|
||||
}
|
||||
seenScriptIds.add(s.scriptId);
|
||||
const script = getScript(s.scriptId);
|
||||
if (!script) throw new BetaError(`no such script (id=${s.scriptId})`, 'no-such-script');
|
||||
const version = getVersion(s.scriptVersionId);
|
||||
if (!version) throw new BetaError(`no such version (id=${s.scriptVersionId})`, 'no-such-version');
|
||||
if (version.script_id !== script.id) {
|
||||
throw new BetaError(
|
||||
`version ${s.scriptVersionId} does not belong to script ${s.scriptId}`,
|
||||
'version-script-mismatch'
|
||||
);
|
||||
}
|
||||
// Two scripts writing to the same target rel_path in one beta is an
|
||||
// internal race — reject up front.
|
||||
if (seenTargets.has(script.target_rel_path)) {
|
||||
throw new BetaError(
|
||||
`scripts ${seenTargets.get(script.target_rel_path)} and ${script.id} share target ${script.target_rel_path}`,
|
||||
'duplicate-target'
|
||||
);
|
||||
}
|
||||
seenTargets.set(script.target_rel_path, script.id);
|
||||
resolved.push({ script, version });
|
||||
}
|
||||
|
||||
// Look up server rows once.
|
||||
const serverRows: Array<{ name: string; game: string | null; map: string | null }> = [];
|
||||
for (const name of input.servers) {
|
||||
const row = getServer(name);
|
||||
if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
|
||||
rows.push({ name, game: row.game, map: row.map });
|
||||
}
|
||||
if (script.scope_type && script.scope_value) {
|
||||
const outOfScope = rows.filter(
|
||||
(r) => (script.scope_type === 'game' ? r.game : r.map) !== script.scope_value
|
||||
);
|
||||
if (outOfScope.length > 0) {
|
||||
throw new BetaError(
|
||||
`servers not in scope ${script.scope_type}=${script.scope_value}`,
|
||||
'out-of-scope',
|
||||
outOfScope.map((r) => r.name)
|
||||
);
|
||||
}
|
||||
serverRows.push({ name, game: row.game, map: row.map });
|
||||
}
|
||||
|
||||
// Per-server conflict check: is another running beta already holding this
|
||||
// target on this server?
|
||||
const conflicts: Array<{ server: string; betaId: number }> = [];
|
||||
// Per-script scope validation. Aggregate all violations into one error.
|
||||
const outOfScope: Array<{ scriptId: number; scope: string; servers: string[] }> = [];
|
||||
for (const { script } of resolved) {
|
||||
if (!script.scope_type || !script.scope_value) continue;
|
||||
const bad = serverRows.filter(
|
||||
(r) => (script.scope_type === 'game' ? r.game : r.map) !== script.scope_value
|
||||
);
|
||||
if (bad.length > 0) {
|
||||
outOfScope.push({
|
||||
scriptId: script.id,
|
||||
scope: `${script.scope_type}=${script.scope_value}`,
|
||||
servers: bad.map((r) => r.name)
|
||||
});
|
||||
}
|
||||
}
|
||||
if (outOfScope.length > 0) {
|
||||
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
|
||||
}
|
||||
|
||||
// Per-(server, script) conflict + stale-.OLD check.
|
||||
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);
|
||||
const ownerId = held.get(script.target_rel_path);
|
||||
if (ownerId) conflicts.push({ server: name, betaId: ownerId });
|
||||
|
||||
// If the target exists on-disk and .OLD already exists, refuse — that
|
||||
// .OLD is either from a crashed prior beta or manual editing.
|
||||
const dest = destOf(name);
|
||||
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) });
|
||||
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 = destOf(name);
|
||||
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);
|
||||
@@ -176,49 +248,63 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
if (!ok.ok) throw new BetaError(`dest not usable for ${name}: ${ok.reason}`, 'dest-fail');
|
||||
}
|
||||
|
||||
// Insert rows in a transaction — disk work happens after commit so we can
|
||||
// await file I/O without holding the DB lock.
|
||||
// DB writes in one transaction.
|
||||
const primary = resolved[0]; // populates deprecated betas.* NOT NULL cols
|
||||
const insertBeta = db().prepare(
|
||||
`INSERT INTO betas (script_id, script_version_id, trigger_kind, wait_for_health, wait_for_match, state, notes)
|
||||
VALUES (?, ?, ?, ?, ?, 'running', ?)`
|
||||
VALUES (?, ?, 'bundle', ?, ?, 'running', ?)`
|
||||
);
|
||||
const insertBetaScript = db().prepare(
|
||||
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id) VALUES (?, ?, ?)`
|
||||
);
|
||||
const insertBetaServer = db().prepare(
|
||||
`INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'deploying', ?)`
|
||||
VALUES (?, ?, 0, NULL, 'deploying', ?)`
|
||||
);
|
||||
const insertBetaServerScript = db().prepare(
|
||||
`INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original, prior_version_id)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
let betaId!: number;
|
||||
db().transaction(() => {
|
||||
const res = insertBeta.run(
|
||||
script.id,
|
||||
version.id,
|
||||
input.triggerKind,
|
||||
primary.script.id,
|
||||
primary.version.id,
|
||||
input.waitForHealth ? 1 : 0,
|
||||
input.waitForMatch ? 1 : 0,
|
||||
input.notes ?? null
|
||||
);
|
||||
betaId = Number(res.lastInsertRowid);
|
||||
|
||||
for (const r of resolved) {
|
||||
insertBetaScript.run(betaId, r.script.id, r.version.id);
|
||||
}
|
||||
for (const name of input.servers) {
|
||||
const prior = getServerScript(name, script.target_rel_path);
|
||||
insertBetaServer.run(
|
||||
betaId,
|
||||
name,
|
||||
prior ? 1 : 0,
|
||||
prior?.script_version_id ?? null,
|
||||
nowIso()
|
||||
);
|
||||
insertBetaServer.run(betaId, name, nowIso());
|
||||
for (const r of resolved) {
|
||||
const prior = getServerScript(name, r.script.target_rel_path);
|
||||
insertBetaServerScript.run(
|
||||
betaId,
|
||||
name,
|
||||
r.script.id,
|
||||
prior ? 1 : 0,
|
||||
prior?.script_version_id ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// On-disk deploy: for each server, backup target → .OLD (if any) and write
|
||||
// -beta.gsc from the version's content.
|
||||
const bfile = betaFile(script.target_rel_path);
|
||||
const ofile = oldFile(script.target_rel_path);
|
||||
// Archive: one -beta.gsc per script.
|
||||
const archive = betaArchiveOf(betaId);
|
||||
await mkdir(archive, { recursive: true });
|
||||
await writeFile(join(archive, posix.basename(bfile)), version.content);
|
||||
for (const r of resolved) {
|
||||
const bfile = betaFile(r.script.target_rel_path);
|
||||
await writeFile(join(archive, posix.basename(bfile)), r.version.content);
|
||||
}
|
||||
|
||||
// On-disk deploy per (server, script). If any script write fails on a
|
||||
// server, mark the whole server 'failed' — we can't restart a partially
|
||||
// deployed server safely. Remaining scripts on that server are skipped.
|
||||
const updateStage = db().prepare(
|
||||
`UPDATE beta_servers
|
||||
SET stage = ?, error = ?, deployed_at = ?, updated_at = ?
|
||||
@@ -227,38 +313,52 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
||||
|
||||
for (const name of input.servers) {
|
||||
const dest = destOf(name);
|
||||
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);
|
||||
// Move inventory row: target → .OLD (keep prior sha/version).
|
||||
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}`);
|
||||
let firstErr: string | null = null;
|
||||
for (const { script, version } of resolved) {
|
||||
if (firstErr) break;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
await writeFile(betaPath, version.content);
|
||||
recordScript(name, bfile, version.id, version.sha256, version.size, `beta:${betaId}`);
|
||||
} catch (err) {
|
||||
firstErr = `${script.name}: ${(err as Error).message}`;
|
||||
}
|
||||
await writeFile(betaPath, version.content);
|
||||
recordScript(name, bfile, version.id, version.sha256, version.size, `beta:${betaId}`);
|
||||
|
||||
const t = nowIso();
|
||||
}
|
||||
const t = nowIso();
|
||||
if (firstErr) {
|
||||
updateStage.run('failed', `deploy: ${firstErr}`, t, t, betaId, name);
|
||||
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: `deploy: ${firstErr}` });
|
||||
} else {
|
||||
updateStage.run('awaiting-empty', null, t, t, betaId, name);
|
||||
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'awaiting-empty', error: null });
|
||||
} catch (err) {
|
||||
const t = nowIso();
|
||||
const msg = (err as Error).message;
|
||||
updateStage.run('failed', `deploy: ${msg}`, t, t, betaId, name);
|
||||
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: `deploy: ${msg}` });
|
||||
}
|
||||
}
|
||||
|
||||
emit({ type: 'beta-opened', betaId, scriptId: script.id, servers: input.servers });
|
||||
emit({
|
||||
type: 'beta-opened',
|
||||
betaId,
|
||||
scriptIds: resolved.map((r) => r.script.id),
|
||||
servers: input.servers
|
||||
});
|
||||
|
||||
return { betaId, servers: input.servers };
|
||||
return {
|
||||
betaId,
|
||||
servers: input.servers,
|
||||
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- rollback ----------
|
||||
@@ -276,51 +376,59 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
if (beta.state !== 'running' && beta.state !== 'ready-promote') {
|
||||
throw new BetaError(`beta is ${beta.state}, cannot rollback`, 'invalid-state');
|
||||
}
|
||||
const script = getScript(beta.script_id)!;
|
||||
const bfile = betaFile(script.target_rel_path);
|
||||
const ofile = oldFile(script.target_rel_path);
|
||||
const bundleRows = listBetaScripts(betaId);
|
||||
if (bundleRows.length === 0) {
|
||||
throw new BetaError('beta has no scripts', 'no-scripts');
|
||||
}
|
||||
const bundle = bundleRows.map((br) => ({
|
||||
script: getScript(br.script_id)!,
|
||||
versionId: br.script_version_id
|
||||
}));
|
||||
|
||||
const rows = listBetaServers(betaId);
|
||||
const restored: string[] = [];
|
||||
const dropped: string[] = [];
|
||||
const servers = listBetaServers(betaId);
|
||||
const restored = new Set<string>();
|
||||
const dropped = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const bs of rows) {
|
||||
for (const bs of servers) {
|
||||
const dest = destOf(bs.server);
|
||||
const betaPath = join(dest, bfile);
|
||||
const backupPath = join(dest, ofile);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
for (const { script } of bundle) {
|
||||
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.gsc regardless of stage.
|
||||
if (existsSync(betaPath)) await rm(betaPath, { force: true });
|
||||
removeScript(bs.server, bfile);
|
||||
// Remove -beta.gsc regardless of stage.
|
||||
if (existsSync(betaPath)) await rm(betaPath, { force: true });
|
||||
removeScript(bs.server, bfile);
|
||||
|
||||
if (bs.had_original) {
|
||||
if (existsSync(backupPath)) {
|
||||
await rename(backupPath, targetPath);
|
||||
removeScript(bs.server, ofile);
|
||||
// Restore inventory row for target with prior version bytes.
|
||||
const prior = bs.prior_version_id ? getVersion(bs.prior_version_id) : null;
|
||||
if (prior) {
|
||||
recordScript(bs.server, script.target_rel_path, prior.id, prior.sha256, prior.size, `rollback:${betaId}`);
|
||||
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}`);
|
||||
} else {
|
||||
const buf = await readFile(targetPath);
|
||||
recordScript(
|
||||
bs.server,
|
||||
script.target_rel_path,
|
||||
null,
|
||||
sha256Buffer(buf),
|
||||
buf.byteLength,
|
||||
`rollback:${betaId}`
|
||||
);
|
||||
}
|
||||
restored.add(bs.server);
|
||||
} else {
|
||||
// We had_original but lost the prior version reference — hash the file to record what's there.
|
||||
const buf = await readFile(targetPath);
|
||||
recordScript(
|
||||
bs.server,
|
||||
script.target_rel_path,
|
||||
null,
|
||||
sha256Buffer(buf),
|
||||
buf.byteLength,
|
||||
`rollback:${betaId}`
|
||||
);
|
||||
warnings.push(`${bs.server}/${script.name}: no ${ofile} to restore`);
|
||||
}
|
||||
restored.push(bs.server);
|
||||
} else {
|
||||
warnings.push(`${bs.server}: no ${ofile} to restore`);
|
||||
dropped.add(bs.server);
|
||||
}
|
||||
} else {
|
||||
dropped.push(bs.server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,37 +440,44 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
||||
.prepare(`UPDATE betas SET state = 'rolled-back', closed_at = ? WHERE id = ?`)
|
||||
.run(nowIso(), betaId);
|
||||
|
||||
// Mark the version test-failed unless another beta for the same version
|
||||
// still gates it as tested/promoted. Skip if the version is the currently-
|
||||
// promoted one — that's authoritative, not a failure signal.
|
||||
const stillOk = db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM betas
|
||||
WHERE script_version_id = ?
|
||||
AND id <> ?
|
||||
AND state IN ('ready-promote','promoted')
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(beta.script_version_id, betaId);
|
||||
const isCurrent = script.current_version_id === beta.script_version_id;
|
||||
if (!stillOk && !isCurrent) {
|
||||
db()
|
||||
.prepare(`UPDATE script_versions SET state = 'test-failed' WHERE id = ?`)
|
||||
.run(beta.script_version_id);
|
||||
// Per-version test-failed transition. Skip a version if (a) another beta
|
||||
// still gates it at ready-promote/promoted, or (b) it's the currently-
|
||||
// promoted version of its script (already-live code isn't "failed" by
|
||||
// rejecting a redundant beta).
|
||||
for (const { script, versionId } of bundle) {
|
||||
const stillOk = db()
|
||||
.prepare(
|
||||
`SELECT 1 FROM beta_scripts bs
|
||||
JOIN betas b ON b.id = bs.beta_id
|
||||
WHERE bs.script_version_id = ?
|
||||
AND b.id <> ?
|
||||
AND b.state IN ('ready-promote','promoted')
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(versionId, betaId);
|
||||
const isCurrent = script.current_version_id === versionId;
|
||||
if (!stillOk && !isCurrent) {
|
||||
db().prepare(`UPDATE script_versions SET state = 'test-failed' WHERE id = ?`).run(versionId);
|
||||
}
|
||||
}
|
||||
|
||||
emit({ type: 'beta-state', betaId, state: 'rolled-back' });
|
||||
|
||||
return { betaId, restored, dropped, warnings };
|
||||
return {
|
||||
betaId,
|
||||
restored: [...restored].sort(),
|
||||
dropped: [...dropped].sort(),
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- promote ----------
|
||||
|
||||
export interface PromoteResult {
|
||||
betaId: number;
|
||||
landed: string[]; // servers we wrote the promoted target to
|
||||
versionId: number;
|
||||
deploymentId: string;
|
||||
landed: string[]; // unique servers that received at least one file
|
||||
scriptIds: number[];
|
||||
}
|
||||
|
||||
function promoteDeploymentId(betaId: number): string {
|
||||
@@ -376,127 +491,114 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
if (beta.state !== 'ready-promote') {
|
||||
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote');
|
||||
}
|
||||
const script = getScript(beta.script_id)!;
|
||||
const version = getVersion(beta.script_version_id)!;
|
||||
const bfile = betaFile(script.target_rel_path);
|
||||
const ofile = oldFile(script.target_rel_path);
|
||||
const bundleRows = listBetaScripts(betaId);
|
||||
if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts');
|
||||
const bundle = bundleRows.map((br) => ({
|
||||
script: getScript(br.script_id)!,
|
||||
version: getVersion(br.script_version_id)!
|
||||
}));
|
||||
|
||||
const betaServerSet = new Set(listBetaServers(betaId).map((r) => r.server));
|
||||
// For scoped scripts, promotion applies to every matching server (whether
|
||||
// or not they were in the beta). For unscoped, just the beta servers.
|
||||
const scopeServers = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type, script.scope_value)
|
||||
: [];
|
||||
const allTargets = Array.from(new Set([...betaServerSet, ...scopeServers]));
|
||||
|
||||
// Snapshot what's on-disk at every target BEFORE we write, so the promote
|
||||
// deployment can be rolled back later. For beta servers the real "before"
|
||||
// state is the .OLD file (or nothing), not the current -beta.gsc.
|
||||
interface Snapshot {
|
||||
// Build a per-(script, server) plan: prior snapshot + intended write.
|
||||
interface Plan {
|
||||
script: ScriptRow;
|
||||
version: ScriptVersionRow;
|
||||
server: string;
|
||||
priorVersionId: number | null;
|
||||
priorSha: string | null;
|
||||
priorSize: number | null;
|
||||
priorContent: Buffer | null;
|
||||
hadFile: boolean;
|
||||
}
|
||||
const snapshots: Snapshot[] = [];
|
||||
for (const name of allTargets) {
|
||||
const dest = destOf(name);
|
||||
if (betaServerSet.has(name)) {
|
||||
// Prior state is captured in the beta_servers row + .OLD file.
|
||||
const bs = db()
|
||||
.prepare(`SELECT had_original, prior_version_id FROM beta_servers WHERE beta_id = ? AND server = ?`)
|
||||
.get(betaId, name) as { had_original: number; prior_version_id: number | null } | undefined;
|
||||
if (bs?.had_original) {
|
||||
const backupPath = join(dest, ofile);
|
||||
let priorContent: Buffer | null = null;
|
||||
let priorSha: string | null = null;
|
||||
let priorSize: number | null = null;
|
||||
if (existsSync(backupPath)) {
|
||||
priorContent = await readFile(backupPath);
|
||||
priorSha = sha256Buffer(priorContent);
|
||||
priorSize = priorContent.byteLength;
|
||||
} else if (bs.prior_version_id) {
|
||||
const pv = getVersion(bs.prior_version_id);
|
||||
if (pv) {
|
||||
priorContent = pv.content;
|
||||
priorSha = pv.sha256;
|
||||
priorSize = pv.size;
|
||||
const plans: Plan[] = [];
|
||||
|
||||
for (const { script, version } of bundle) {
|
||||
const scopeServers = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type, script.scope_value)
|
||||
: [];
|
||||
const targets = new Set<string>([...betaServerSet, ...scopeServers]);
|
||||
|
||||
for (const name of targets) {
|
||||
let plan: Plan;
|
||||
if (betaServerSet.has(name)) {
|
||||
const bss = getBetaServerScript(betaId, name, script.id);
|
||||
if (bss?.had_original) {
|
||||
const dest = destOf(name);
|
||||
const backupPath = join(dest, oldFile(script.target_rel_path));
|
||||
let priorContent: Buffer | null = null;
|
||||
let priorSha: string | null = null;
|
||||
if (existsSync(backupPath)) {
|
||||
priorContent = await readFile(backupPath);
|
||||
priorSha = sha256Buffer(priorContent);
|
||||
} else if (bss.prior_version_id) {
|
||||
const pv = getVersion(bss.prior_version_id);
|
||||
if (pv) { priorContent = pv.content; priorSha = pv.sha256; }
|
||||
}
|
||||
plan = {
|
||||
script, version, server: name,
|
||||
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true
|
||||
};
|
||||
} else {
|
||||
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
|
||||
}
|
||||
snapshots.push({
|
||||
server: name,
|
||||
priorVersionId: bs.prior_version_id,
|
||||
priorSha,
|
||||
priorSize,
|
||||
priorContent,
|
||||
hadFile: true
|
||||
});
|
||||
} else {
|
||||
snapshots.push({ server: name, priorVersionId: null, priorSha: null, priorSize: null, priorContent: null, hadFile: false });
|
||||
}
|
||||
} else {
|
||||
// Non-beta matching-scope server — the current on-disk target is
|
||||
// its "before".
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
if (existsSync(targetPath)) {
|
||||
const priorContent = await readFile(targetPath);
|
||||
const inv = getServerScript(name, script.target_rel_path);
|
||||
snapshots.push({
|
||||
server: name,
|
||||
priorVersionId: inv?.script_version_id ?? null,
|
||||
priorSha: sha256Buffer(priorContent),
|
||||
priorSize: priorContent.byteLength,
|
||||
priorContent,
|
||||
hadFile: true
|
||||
});
|
||||
} else {
|
||||
snapshots.push({ server: name, priorVersionId: null, priorSha: null, priorSize: null, priorContent: null, hadFile: false });
|
||||
// Non-beta matching-scope server: current on-disk file (if any)
|
||||
// is the "before" state for a promote-then-rollback round trip.
|
||||
const dest = destOf(name);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
if (existsSync(targetPath)) {
|
||||
const priorContent = await readFile(targetPath);
|
||||
const inv = getServerScript(name, script.target_rel_path);
|
||||
plan = {
|
||||
script, version, server: name,
|
||||
priorVersionId: inv?.script_version_id ?? null,
|
||||
priorSha: sha256Buffer(priorContent),
|
||||
priorContent, hadFile: true
|
||||
};
|
||||
} else {
|
||||
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
|
||||
}
|
||||
}
|
||||
plans.push(plan);
|
||||
}
|
||||
}
|
||||
|
||||
// Now write files. Failures are per-server and don't abort the promote —
|
||||
// the deployment record captures who succeeded and who didn't.
|
||||
const landed: string[] = [];
|
||||
const failures = new Map<string, string>();
|
||||
for (const name of allTargets) {
|
||||
const dest = destOf(name);
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
// Write files. Track per-plan success and per-server first-error.
|
||||
const planWritten = new Map<Plan, boolean>();
|
||||
const serverErr = new Map<string, string>();
|
||||
for (const p of plans) {
|
||||
const dest = destOf(p.server);
|
||||
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(script.target_rel_path)), { recursive: true });
|
||||
if (existsSync(betaPath)) {
|
||||
await rm(betaPath, { force: true });
|
||||
removeScript(name, bfile);
|
||||
}
|
||||
if (existsSync(backupPath)) {
|
||||
await rm(backupPath, { force: true });
|
||||
removeScript(name, ofile);
|
||||
}
|
||||
await writeFile(targetPath, version.content);
|
||||
landed.push(name);
|
||||
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); }
|
||||
await writeFile(targetPath, p.version.content);
|
||||
planWritten.set(p, true);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
failures.set(name, msg);
|
||||
console.error(`[promote] ${name} failed:`, err);
|
||||
planWritten.set(p, false);
|
||||
const msg = `${p.script.name}: ${(err as Error).message}`;
|
||||
if (!serverErr.has(p.server)) serverErr.set(p.server, msg);
|
||||
console.error(`[promote] ${p.server}/${p.script.name} failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the deployment record. Only landed servers get deployment_files
|
||||
// rows — a failed write leaves the prior file on disk, so recording it as
|
||||
// if it changed would corrupt any future rollback.
|
||||
// Build the aggregate deployment.
|
||||
const depId = promoteDeploymentId(betaId);
|
||||
const startedAt = nowIso();
|
||||
const priorScriptVersionId = script.current_version_id;
|
||||
|
||||
const insertDep = db().prepare(
|
||||
`INSERT INTO deployments
|
||||
(id, action, started_at, triggered_by, script_id, script_version_id, prior_version_id, beta_id)
|
||||
VALUES (?, 'promote', ?, 'ui', ?, ?, ?, ?)`
|
||||
VALUES (?, 'promote', ?, 'ui', NULL, NULL, NULL, ?)`
|
||||
);
|
||||
const insertDepScript = db().prepare(
|
||||
`INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
const insertDepServer = db().prepare(
|
||||
`INSERT INTO deployment_servers
|
||||
@@ -509,59 +611,77 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
const uniqueServers = Array.from(new Set(plans.map((p) => p.server)));
|
||||
|
||||
db().transaction(() => {
|
||||
insertDep.run(depId, startedAt, script.id, version.id, priorScriptVersionId, betaId);
|
||||
for (const snap of snapshots) {
|
||||
if (failures.has(snap.server)) {
|
||||
// Record the failure but no file row — nothing changed on disk.
|
||||
insertDepServer.run(depId, snap.server, 0, 'failed', null, failures.get(snap.server)!);
|
||||
continue;
|
||||
}
|
||||
const verb = snap.hadFile ? 'updated' : 'added';
|
||||
insertDep.run(depId, startedAt, betaId);
|
||||
for (const { script, version } of bundle) {
|
||||
insertDepScript.run(depId, script.id, version.id, script.current_version_id);
|
||||
}
|
||||
// deployment_files — one row per successful (script, server) write.
|
||||
for (const p of plans) {
|
||||
if (!planWritten.get(p)) continue;
|
||||
const verb = p.hadFile ? 'updated' : 'added';
|
||||
insertDepFile.run(
|
||||
depId,
|
||||
snap.server,
|
||||
p.server,
|
||||
verb,
|
||||
script.target_rel_path,
|
||||
snap.priorSha,
|
||||
version.sha256,
|
||||
snap.priorContent,
|
||||
version.content
|
||||
p.script.target_rel_path,
|
||||
p.priorSha,
|
||||
p.version.sha256,
|
||||
p.priorContent,
|
||||
p.version.content
|
||||
);
|
||||
|
||||
// Beta servers are already at the new bytes AND already restarted
|
||||
// during the beta's own state machine. Non-beta matching servers
|
||||
// got the file just now and still need to drain-and-restart, which
|
||||
// the deployment sweep handles.
|
||||
if (betaServerSet.has(snap.server)) {
|
||||
recordScript(
|
||||
p.server,
|
||||
p.script.target_rel_path,
|
||||
p.version.id,
|
||||
p.version.sha256,
|
||||
p.version.size,
|
||||
`promote:${depId}`
|
||||
);
|
||||
}
|
||||
// deployment_servers — one row per unique server. Beta servers are
|
||||
// already restarted; scope-only servers still need the sweep to drain
|
||||
// and restart them.
|
||||
for (const name of uniqueServers) {
|
||||
const err = serverErr.get(name);
|
||||
if (err) {
|
||||
insertDepServer.run(depId, name, 0, 'failed', null, err);
|
||||
continue;
|
||||
}
|
||||
const filesChanged = plans.filter((p) => p.server === name && planWritten.get(p)).length;
|
||||
if (betaServerSet.has(name)) {
|
||||
const bsRestartedAt = db()
|
||||
.prepare(`SELECT restarted_at FROM beta_servers WHERE beta_id = ? AND server = ?`)
|
||||
.get(betaId, snap.server) as { restarted_at: string | null } | undefined;
|
||||
insertDepServer.run(depId, snap.server, 1, 'done', bsRestartedAt?.restarted_at ?? startedAt, null);
|
||||
.get(betaId, name) as { restarted_at: string | null } | undefined;
|
||||
insertDepServer.run(depId, name, filesChanged, 'done', bsRestartedAt?.restarted_at ?? startedAt, null);
|
||||
} else {
|
||||
insertDepServer.run(depId, snap.server, 1, 'pending', null, null);
|
||||
insertDepServer.run(depId, name, filesChanged, 'pending', null, null);
|
||||
}
|
||||
|
||||
recordScript(snap.server, script.target_rel_path, version.id, version.sha256, version.size, `promote:${depId}`);
|
||||
}
|
||||
// Update each script's current_version_id in the same transaction.
|
||||
for (const { script, version } of bundle) {
|
||||
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
||||
}
|
||||
})();
|
||||
|
||||
// Update the script's canonical version and close out the beta + wipe archive.
|
||||
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
||||
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
|
||||
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
emit({ type: 'beta-state', betaId, state: 'promoted' });
|
||||
emit({
|
||||
type: 'deployment-opened',
|
||||
deploymentId: depId,
|
||||
action: 'promote',
|
||||
servers: landed
|
||||
});
|
||||
const landed = Array.from(new Set(plans.filter((p) => planWritten.get(p)).map((p) => p.server))).sort();
|
||||
|
||||
return { betaId, landed, versionId: version.id, deploymentId: depId };
|
||||
emit({ type: 'beta-state', betaId, state: 'promoted' });
|
||||
emit({ type: 'deployment-opened', deploymentId: depId, action: 'promote', servers: landed });
|
||||
|
||||
return {
|
||||
betaId,
|
||||
deploymentId: depId,
|
||||
landed,
|
||||
scriptIds: bundle.map((b) => b.script.id)
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- helpers used by API layer ----------
|
||||
@@ -575,8 +695,7 @@ export function latestVersionForScript(script: ScriptRow): ScriptVersionRow | nu
|
||||
|
||||
/**
|
||||
* Un-set a script's scope. When scope is being removed and matching servers
|
||||
* currently have the script, remove it from them for consistency (per plan:
|
||||
* "un-marking auto-removes from matching servers, no beta"). Returns the
|
||||
* currently have the script, remove it from them for consistency. Returns the
|
||||
* server names that had files deleted.
|
||||
*/
|
||||
export async function unsetScopeAndCleanup(scriptId: number): Promise<string[]> {
|
||||
|
||||
@@ -7,19 +7,11 @@ import { emit } from '../pipeline/bus.js';
|
||||
import { recordScript, removeScript } from './inventory.js';
|
||||
import { getVersion } from './scripts.js';
|
||||
|
||||
// Roll a promoted script back to whatever version was current before the
|
||||
// promote landed. Uses the deployment_files snapshots already captured at
|
||||
// promote time — no on-disk archaeology needed.
|
||||
//
|
||||
// Semantics:
|
||||
// - Reverses only the file writes recorded in deployment_files. Servers that
|
||||
// failed at promote time (no file row) are left alone.
|
||||
// - Restores scripts.current_version_id to the promote row's prior_version_id.
|
||||
// - Marks the original promote as rolled_back_by = <new deployment id>.
|
||||
// - Records a fresh action='rollback' deployment with pending server rows so
|
||||
// the sweep drains-and-restarts every affected server. Beta servers that
|
||||
// were still running the promoted bytes get restarted the same as any
|
||||
// other; the beta itself is not touched (it stays in state='promoted').
|
||||
// Roll a promoted deployment back to whatever versions were current before
|
||||
// the promote landed. Reads deployment_scripts as the source of truth for
|
||||
// which scripts were involved (and their prior versions); falls back to the
|
||||
// deprecated deployments.script_id/version_id/prior_version_id columns for
|
||||
// pre-multi-script legacy rows.
|
||||
|
||||
export class RollbackError extends Error {
|
||||
constructor(message: string, public readonly kind: 'no-such-deployment' | 'not-promote' | 'already-rolled-back') {
|
||||
@@ -36,6 +28,13 @@ interface DeploymentRow {
|
||||
rolled_back_by: string | null;
|
||||
}
|
||||
|
||||
interface DepScriptRow {
|
||||
deployment_id: string;
|
||||
script_id: number;
|
||||
script_version_id: number;
|
||||
prior_version_id: number | null;
|
||||
}
|
||||
|
||||
interface FileRow {
|
||||
id: number;
|
||||
server: string;
|
||||
@@ -47,6 +46,11 @@ interface FileRow {
|
||||
content_after: Buffer | null;
|
||||
}
|
||||
|
||||
interface ScriptTargetRow {
|
||||
id: number;
|
||||
target_rel_path: string;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@@ -57,10 +61,9 @@ function rollbackDeploymentId(promoteId: string): string {
|
||||
}
|
||||
|
||||
export interface RollbackDeploymentResult {
|
||||
deploymentId: string; // the new rollback deployment
|
||||
reverses: string; // the promote deployment id
|
||||
scriptId: number | null;
|
||||
priorVersionId: number | null;
|
||||
deploymentId: string; // the new rollback deployment
|
||||
reverses: string; // the promote deployment id
|
||||
scriptIds: number[]; // scripts whose current_version_id got reverted
|
||||
affectedServers: string[];
|
||||
}
|
||||
|
||||
@@ -70,8 +73,27 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
FROM deployments WHERE id = ?`)
|
||||
.get(promoteId) as DeploymentRow | undefined;
|
||||
if (!dep) throw new RollbackError(`no such deployment ${promoteId}`, 'no-such-deployment');
|
||||
if (dep.action !== 'promote') throw new RollbackError(`deployment ${promoteId} is action='${dep.action}', not 'promote'`, 'not-promote');
|
||||
if (dep.rolled_back_by) throw new RollbackError(`deployment ${promoteId} already rolled back by ${dep.rolled_back_by}`, 'already-rolled-back');
|
||||
if (dep.action !== 'promote') {
|
||||
throw new RollbackError(`deployment ${promoteId} is action='${dep.action}', not 'promote'`, 'not-promote');
|
||||
}
|
||||
if (dep.rolled_back_by) {
|
||||
throw new RollbackError(`deployment ${promoteId} already rolled back by ${dep.rolled_back_by}`, 'already-rolled-back');
|
||||
}
|
||||
|
||||
// Source of truth for which scripts (and prior versions) this promote
|
||||
// touched. Prefer deployment_scripts; fall back to the deprecated
|
||||
// singular columns for legacy single-script deployments.
|
||||
let scripts = db()
|
||||
.prepare(`SELECT * FROM deployment_scripts WHERE deployment_id = ? ORDER BY script_id`)
|
||||
.all(promoteId) as DepScriptRow[];
|
||||
if (scripts.length === 0 && dep.script_id !== null && dep.script_version_id !== null) {
|
||||
scripts = [{
|
||||
deployment_id: promoteId,
|
||||
script_id: dep.script_id,
|
||||
script_version_id: dep.script_version_id,
|
||||
prior_version_id: dep.prior_version_id
|
||||
}];
|
||||
}
|
||||
|
||||
const fileRows = db()
|
||||
.prepare(
|
||||
@@ -80,10 +102,26 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
)
|
||||
.all(promoteId) as FileRow[];
|
||||
|
||||
// Map rel_path → scriptId (and prior_version_id) so we can restore the
|
||||
// inventory row's script_version_id correctly during file restore.
|
||||
const scriptByTarget = new Map<string, DepScriptRow>();
|
||||
if (scripts.length > 0) {
|
||||
const placeholders = scripts.map(() => '?').join(',');
|
||||
const targetRows = db()
|
||||
.prepare(`SELECT id, target_rel_path FROM scripts WHERE id IN (${placeholders})`)
|
||||
.all(...scripts.map((s) => s.script_id)) as ScriptTargetRow[];
|
||||
const targetById = new Map<number, string>();
|
||||
for (const t of targetRows) targetById.set(t.id, t.target_rel_path);
|
||||
for (const s of scripts) {
|
||||
const t = targetById.get(s.script_id);
|
||||
if (t) scriptByTarget.set(t, s);
|
||||
}
|
||||
}
|
||||
|
||||
const newDepId = rollbackDeploymentId(promoteId);
|
||||
const startedAt = nowIso();
|
||||
|
||||
// Group by server so each server gets exactly one deployment_servers row.
|
||||
// Group files by server so each server gets exactly one deployment_servers row.
|
||||
const perServer = new Map<string, FileRow[]>();
|
||||
for (const f of fileRows) {
|
||||
const arr = perServer.get(f.server);
|
||||
@@ -100,14 +138,15 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
for (const f of files) {
|
||||
const abs = join(dest, f.rel_path);
|
||||
await mkdir(join(dest, dirname(f.rel_path)), { recursive: true });
|
||||
const owningScript = scriptByTarget.get(f.rel_path);
|
||||
if (f.verb === 'added') {
|
||||
// Promote created this file — undo by removing it.
|
||||
if (existsSync(abs)) await rm(abs, { force: true });
|
||||
removeScript(server, f.rel_path);
|
||||
} else if (f.verb === 'updated' && f.content_before) {
|
||||
await writeFile(abs, f.content_before);
|
||||
// Restore inventory to the prior version if we can identify it.
|
||||
const priorVer = dep.prior_version_id ? getVersion(dep.prior_version_id) : null;
|
||||
const priorVer = owningScript?.prior_version_id
|
||||
? getVersion(owningScript.prior_version_id)
|
||||
: null;
|
||||
recordScript(
|
||||
server,
|
||||
f.rel_path,
|
||||
@@ -117,7 +156,6 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
`rollback:${newDepId}`
|
||||
);
|
||||
} else if (f.verb === 'removed' && f.content_before) {
|
||||
// Should not occur on a promote row, but handle symmetrically.
|
||||
await writeFile(abs, f.content_before);
|
||||
recordScript(server, f.rel_path, null, f.sha256_before!, f.content_before.byteLength, `rollback:${newDepId}`);
|
||||
}
|
||||
@@ -132,15 +170,17 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
const insertDep = db().prepare(
|
||||
`INSERT INTO deployments
|
||||
(id, action, started_at, triggered_by, script_id, script_version_id, prior_version_id, beta_id)
|
||||
VALUES (?, 'rollback', ?, 'ui', ?, ?, ?, NULL)`
|
||||
VALUES (?, 'rollback', ?, 'ui', NULL, NULL, NULL, NULL)`
|
||||
);
|
||||
const insertDepScript = db().prepare(
|
||||
`INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
const insertDepServer = db().prepare(
|
||||
`INSERT INTO deployment_servers
|
||||
(deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error)
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, NULL, 0, ?)`
|
||||
);
|
||||
// Mirror the promote's deployment_files into the rollback row so it's
|
||||
// browsable as a diff (before/after swapped).
|
||||
const insertDepFile = db().prepare(
|
||||
`INSERT INTO deployment_files
|
||||
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
|
||||
@@ -148,13 +188,13 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
);
|
||||
|
||||
db().transaction(() => {
|
||||
insertDep.run(
|
||||
newDepId,
|
||||
startedAt,
|
||||
dep.script_id,
|
||||
dep.prior_version_id, // this rollback lands the *prior* version, so that's now current
|
||||
dep.script_version_id, // what was in place immediately before this rollback
|
||||
);
|
||||
insertDep.run(newDepId, startedAt);
|
||||
// Mirror the promote's script list — swapped: the "current" version
|
||||
// after rollback is the promote's prior; the "prior" of the rollback
|
||||
// is what was current up until this call (i.e. the promote's version).
|
||||
for (const s of scripts) {
|
||||
insertDepScript.run(newDepId, s.script_id, s.prior_version_id ?? s.script_version_id, s.script_version_id);
|
||||
}
|
||||
for (const [server, files] of perServer) {
|
||||
if (perServerFailures.has(server)) {
|
||||
insertDepServer.run(newDepId, server, 0, 'failed', perServerFailures.get(server)!);
|
||||
@@ -179,9 +219,9 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
}
|
||||
}
|
||||
db().prepare(`UPDATE deployments SET rolled_back_by = ? WHERE id = ?`).run(newDepId, promoteId);
|
||||
// Restore the script's current version pointer.
|
||||
if (dep.script_id !== null) {
|
||||
db().prepare(`UPDATE scripts SET current_version_id = ? WHERE id = ?`).run(dep.prior_version_id, dep.script_id);
|
||||
// Restore each affected script's current_version_id.
|
||||
for (const s of scripts) {
|
||||
db().prepare(`UPDATE scripts SET current_version_id = ? WHERE id = ?`).run(s.prior_version_id, s.script_id);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -190,8 +230,7 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
return {
|
||||
deploymentId: newDepId,
|
||||
reverses: promoteId,
|
||||
scriptId: dep.script_id,
|
||||
priorVersionId: dep.prior_version_id,
|
||||
scriptIds: scripts.map((s) => s.script_id),
|
||||
affectedServers
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,7 +207,11 @@ export function deleteVersion(versionId: number): void {
|
||||
}
|
||||
|
||||
const beta = db()
|
||||
.prepare(`SELECT id FROM betas WHERE script_version_id = ? AND state = 'running'`)
|
||||
.prepare(
|
||||
`SELECT b.id FROM betas b
|
||||
JOIN beta_scripts bs ON bs.beta_id = b.id
|
||||
WHERE bs.script_version_id = ? AND b.state = 'running' LIMIT 1`
|
||||
)
|
||||
.get(versionId) as { id: number } | undefined;
|
||||
if (beta) throw new ScriptError(`beta ${beta.id} is still running against this version`, 'in-use-beta');
|
||||
|
||||
@@ -221,7 +225,11 @@ export function deleteScript(scriptId: number): void {
|
||||
throw new ScriptError('un-set scope before deleting a script', 'has-scope');
|
||||
}
|
||||
const beta = db()
|
||||
.prepare(`SELECT id FROM betas WHERE script_id = ? AND state = 'running'`)
|
||||
.prepare(
|
||||
`SELECT b.id FROM betas b
|
||||
JOIN beta_scripts bs ON bs.beta_id = b.id
|
||||
WHERE bs.script_id = ? AND b.state = 'running' LIMIT 1`
|
||||
)
|
||||
.get(scriptId) as { id: number } | undefined;
|
||||
if (beta) throw new ScriptError(`beta ${beta.id} is still running against this script`, 'has-active-beta');
|
||||
db().prepare('DELETE FROM scripts WHERE id = ?').run(scriptId);
|
||||
|
||||
@@ -153,10 +153,11 @@ export function heldByBeta(server: string): Map<string, number> {
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT s.target_rel_path AS target, b.id AS beta_id
|
||||
FROM beta_servers bs
|
||||
JOIN betas b ON b.id = bs.beta_id
|
||||
JOIN scripts s ON s.id = b.script_id
|
||||
WHERE bs.server = ? AND b.state = 'running'`
|
||||
FROM beta_servers bsv
|
||||
JOIN betas b ON b.id = bsv.beta_id
|
||||
JOIN beta_scripts bs ON bs.beta_id = b.id
|
||||
JOIN scripts s ON s.id = bs.script_id
|
||||
WHERE bsv.server = ? AND b.state = 'running'`
|
||||
)
|
||||
.all(server) as Array<{ target: string; beta_id: number }>;
|
||||
const map = new Map<string, number>();
|
||||
@@ -171,10 +172,11 @@ export function ownerOf(server: string, target: string): number | null {
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT b.id AS beta_id
|
||||
FROM beta_servers bs
|
||||
JOIN betas b ON b.id = bs.beta_id
|
||||
JOIN scripts s ON s.id = b.script_id
|
||||
WHERE bs.server = ? AND s.target_rel_path = ? AND b.state = 'running'
|
||||
FROM beta_servers bsv
|
||||
JOIN betas b ON b.id = bsv.beta_id
|
||||
JOIN beta_scripts bs ON bs.beta_id = b.id
|
||||
JOIN scripts s ON s.id = bs.script_id
|
||||
WHERE bsv.server = ? AND s.target_rel_path = ? AND b.state = 'running'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(server, target) as { beta_id: number } | undefined;
|
||||
|
||||
Reference in New Issue
Block a user