Support multi-game betas with automatic server selection

This commit is contained in:
2026-10-07 09:41:17 -04:00
parent a6dee8c5e2
commit c6352d86b4
17 changed files with 881 additions and 173 deletions
+91 -113
View File
@@ -1,6 +1,7 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { getVersionContent } from './scripts.js';
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { defaultPlan, parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { selectBetaServers } from './beta-selection.js';
import { storedPlan, testView, activity, setOutcome } from './beta-tests.js';
import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js';
import type { BetaPromotionPreview } from '../../beta-promotion.js';
@@ -74,7 +75,7 @@ function scopeAppliesToServer(
scope: { scope_type: string | null; scope_value: string | null },
server: { game: string | null; map: string | null }
): boolean {
if (!scope.scope_type || !scope.scope_value) return false;
if (!scope.scope_type || !scope.scope_value) return true;
const field = scope.scope_type === 'game' ? server.game : server.map;
return field === scope.scope_value;
}
@@ -265,10 +266,12 @@ export function isActiveBetaServer(betaId: number, server: string): boolean {
.get(betaId, server);
}
export function listBetaScripts(betaId: number): BetaScriptRow[] {
export function listBetaScripts(betaId: number, server?: string): BetaScriptRow[] {
return db()
.prepare('SELECT * FROM beta_scripts WHERE beta_id = ? ORDER BY script_id')
.all(betaId) as BetaScriptRow[];
.prepare(`SELECT bs.* FROM beta_scripts bs WHERE bs.beta_id = ?
${server === undefined ? '' : 'AND EXISTS (SELECT 1 FROM beta_server_scripts bss WHERE bss.beta_id = bs.beta_id AND bss.script_id = bs.script_id AND bss.server = ?)'}
ORDER BY bs.script_id`)
.all(betaId, ...(server === undefined ? [] : [server])) as BetaScriptRow[];
}
export function getBetaServerScript(betaId: number, server: string, scriptId: number): BetaServerScriptRow | undefined {
@@ -299,7 +302,8 @@ export interface OpenBetaScript {
export interface OpenBetaInput {
scripts: OpenBetaScript[];
servers: string[];
servers?: string[];
instancesPerGame?: Record<string, number>;
testPlan?: BetaPlan;
waitForHealth: boolean;
waitForMatch: boolean;
@@ -313,26 +317,18 @@ export interface OpenBetaResult {
}
export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
const plan = input.testPlan ? parseBetaPlan(input.testPlan) : null;
if (new Set(input.servers).size !== input.servers.length) throw new BetaError("Select each server only once", "invalid-state");
if (plan?.scope === "quorum" && plan.quorum > input.servers.length) throw new BetaError("Quorum exceeds selected servers", "invalid-state");
const automatic = input.instancesPerGame !== undefined;
if (automatic && input.servers !== undefined) throw new BetaError('Choose automatic instance counts or explicit servers, not both', 'invalid-state');
const plan = automatic ? parseBetaPlan({ ...(input.testPlan ?? defaultPlan()), scope: 'all' })
: input.testPlan ? parseBetaPlan(input.testPlan) : null;
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');
}
// Resolve every (script, version) pair and validate belonging.
interface Resolved { script: ScriptRow; version: ScriptVersionRow }
const resolved: Resolved[] = [];
const seenScriptIds = new Set<number>();
// For non-collapsing kinds (script/plugin), two artifacts targeting the
// same rel_path within one beta is an internal race — reject up front.
// Collapsing kinds (config/baseline) intentionally share a target file
// (dedicated_sp.cfg) and compose, so they skip this check.
const seenTargets = new Map<string, number>();
let baselineInBeta: Resolved | null = null;
for (const s of input.scripts) {
if (seenScriptIds.has(s.scriptId)) {
throw new BetaError(`script ${s.scriptId} listed twice in one beta`, 'duplicate-script');
@@ -348,33 +344,23 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
'version-script-mismatch'
);
}
if (!isCollapsingKind(script.kind)) {
const targetKey = `${script.kind}:${script.target_rel_path}`;
if (seenTargets.has(targetKey)) {
throw new BetaError(
`${script.kind}s ${seenTargets.get(targetKey)} and ${script.id} share target ${script.target_rel_path}`,
'duplicate-target'
);
}
seenTargets.set(targetKey, script.id);
}
if (script.kind === 'baseline') {
if (baselineInBeta) {
throw new BetaError(
`two baselines in one beta (${baselineInBeta.script.id}, ${script.id}) — betas support at most one baseline`,
'duplicate-target'
);
}
baselineInBeta = { script, version };
}
resolved.push({ script, version });
}
let selectedServers = input.servers;
if (automatic) {
try { selectedServers = selectBetaServers(resolved.map(row => row.script), input.instancesPerGame); }
catch (error) { throw new BetaError((error as Error).message, 'invalid-state'); }
}
if (!selectedServers?.length) throw new BetaError('at least one server is required', 'no-servers');
if (new Set(selectedServers).size !== selectedServers.length) throw new BetaError('Select each server only once', 'invalid-state');
if (plan?.scope === 'quorum' && plan.quorum > selectedServers.length) throw new BetaError('Quorum exceeds selected servers', 'invalid-state');
// Look up server rows once.
const serverRows: Array<{ name: string; game: string | null; map: string | null }> = [];
for (const name of input.servers) {
for (const name of selectedServers) {
const row = getServer(name);
if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
if (!row || row.destroyed_at) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
// Once teardown is queued or in flight, opening a beta cannot safely
// claim the instance. A failed remote request may still be running.
const removal = db().prepare(`SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL
@@ -391,29 +377,30 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
if (missing) throw new BetaError('Coverage requires game/map metadata on every selected server', 'invalid-state');
}
// 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)
});
// Each server receives only matching bundle members. Unscoped files apply
// to every selected server; every file must be exercised by at least one.
const perServer = new Map(serverRows.map(server => [server.name, resolved.filter(({ script }) => scopeAppliesToServer(script, server))]));
for (const [server, members] of perServer) {
if (!members.length) throw new BetaError(`${server}: no bundled files apply to this server`, 'out-of-scope');
const seenTargets = new Map<string, number>();
for (const { script } of members) {
if (script.kind === 'config') continue;
const target = script.kind === 'baseline' ? 'baseline' : `${script.kind}:${script.target_rel_path}`;
const prior = seenTargets.get(target);
if (prior !== undefined) throw new BetaError(`${server}: files ${prior} and ${script.id} share target ${script.target_rel_path}`, 'duplicate-target');
seenTargets.set(target, script.id);
}
}
if (outOfScope.length > 0) {
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
for (const { script } of resolved) {
if (!serverRows.some(server => scopeAppliesToServer(script, server))) {
throw new BetaError(`${script.name}: select at least one server matching ${script.scope_type}=${script.scope_value}`, 'out-of-scope');
}
}
// A beta outranks campaigns during activation. Do not let it overtake older
// staged writes which would otherwise overwrite the tested version on closure.
// Legacy campaigns have no staged payload and retain their restart-only priority.
const queuedCampaigns = input.servers.flatMap(unresolvedCampaignWrites);
const queuedCampaigns = selectedServers.flatMap(unresolvedCampaignWrites);
if (queuedCampaigns.length) {
const interrupted = queuedCampaigns.some(row => row.state === 'applying');
throw new BetaError(interrupted
@@ -425,9 +412,9 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
// 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 }> = [];
for (const name of input.servers) {
for (const name of selectedServers) {
const held = heldByBeta(name);
for (const { script } of resolved) {
for (const { script } of perServer.get(name)!) {
const owner = held.get(script.target_rel_path);
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
}
@@ -466,13 +453,13 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
for (const r of resolved) {
insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind);
}
for (const name of input.servers) {
for (const name of selectedServers) {
insertBetaServer.run(betaId, name, nowIso());
if (plan) {
const selected = serverRows.find(s => s.name === name)!;
db().prepare('INSERT INTO beta_test_coverage (beta_id, server, game, map) VALUES (?, ?, ?, ?)').run(betaId, name, selected.game, selected.map);
}
for (const r of resolved) {
for (const r of perServer.get(name)!) {
insertBetaServerScript.run(
betaId,
name,
@@ -485,18 +472,18 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
})();
if (plan?.waveSize) db().prepare("UPDATE beta_servers SET stage = 'queued' WHERE beta_id = ?").run(betaId);
await deployBetaServers(betaId, plan?.waveSize ? input.servers.slice(0, plan.waveSize) : input.servers);
await deployBetaServers(betaId, plan?.waveSize ? selectedServers.slice(0, plan.waveSize) : selectedServers);
emit({
type: 'beta-opened',
betaId,
scriptIds: resolved.map((r) => r.script.id),
servers: input.servers
servers: selectedServers
});
return {
betaId,
servers: input.servers,
servers: selectedServers,
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
};
}
@@ -602,7 +589,7 @@ export async function reviseBeta(betaId: number, expectedRevision: number, uploa
}
function revisionFileChanges(betaId: number, name: string): Parameters<typeof queueFileChange>[0][] {
const resolved = listBetaScripts(betaId).map(row => ({ script: getScript(row.script_id)!, version: getVersion(row.script_version_id)! }));
const resolved = listBetaScripts(betaId, name).map(row => ({ script: getScript(row.script_id)!, version: getVersion(row.script_version_id)! }));
const server = getServer(name)!;
const changes: Parameters<typeof queueFileChange>[0][] = resolved.filter(row => !isCollapsingKind(row.script.kind))
.map(({ script, version }) => ({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: script.kind,
@@ -612,7 +599,7 @@ function revisionFileChanges(betaId: number, name: string): Parameters<typeof qu
inBetaBaseline: resolved.find(row => row.script.kind === 'baseline')?.version,
inBetaOverlays: resolved.filter(row => row.script.kind === 'config')
});
if (!composed) throw new BetaError(`No baseline available for ${name}.`, 'invalid-state');
if (!composed) throw new BetaError(`no baseline available for ${name} — upload one before running config overlays`, 'invalid-state');
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: 'config', relPath: configFileOf(name),
content: Buffer.from(composed.text), inventorySource: `beta:${betaId}` });
}
@@ -628,47 +615,24 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
if (!isActiveBetaServer(betaId, name)) throw new BetaError(`${name}: original beta server was deleted; start a new beta`, 'unknown-server');
}
const resolved = listBetaScripts(betaId).map(r => ({ script: getScript(r.script_id)!, version: getVersion(r.script_version_id)! }));
const serverRows = names.map(name => getServer(name)!);
// Split resolved artifacts into non-collapsing (each writes its own file)
// and collapsing (config/baseline overlay onto one shared dedicated_sp.cfg).
const nonCollapsing = resolved.filter((r) => !isCollapsingKind(r.script.kind));
const collapsingOverlays = resolved.filter((r) => r.script.kind === 'config');
const collapsingBaseline = resolved.find((r) => r.script.kind === 'baseline');
// Archive: one copy per non-collapsing artifact under its target basename,
// plus one composed preview per beta server for the config layer.
// Script IDs distinguish same-named files for different games in archives.
const archive = betaArchiveOf(betaId);
await mkdir(archive, { recursive: true });
for (const r of nonCollapsing) {
const base = posix.basename(r.script.target_rel_path);
await writeFile(join(archive, base), getVersionContent(r.version.id));
await writeFile(join(archive, `${r.script.id}.${base}`), getVersionContent(r.version.id));
}
// Persist the requested bytes without touching any live destination. Provisioning
// captures the original file and inventory immediately before guarded activation.
for (const name of names) {
if (!isActiveBetaServer(betaId, name)) continue;
const serverRow = serverRows.find((r) => r.name === name)!;
try {
const changes: Parameters<typeof queueFileChange>[0][] = nonCollapsing.map(({ script, version }) => ({
sourceKind: 'beta' as const, sourceId: String(betaId), server: name,
kind: script.kind, relPath: script.target_rel_path, content: getVersionContent(version.id),
scriptVersionId: version.id, inventorySource: `beta:${betaId}`
}));
if (collapsingBaseline || collapsingOverlays.length > 0) {
const composedResult = composeCfgForServer(name, serverRow.game, serverRow.map, {
inBetaBaseline: collapsingBaseline?.version,
inBetaOverlays: collapsingOverlays
});
if (!composedResult) {
throw new Error(`no baseline available for ${name} (game=${serverRow.game ?? '?'}) — upload one before running config overlays`);
}
const composedBuf = Buffer.from(composedResult.text, 'utf8');
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name,
kind: 'config', relPath: configFileOf(name), content: composedBuf,
scriptVersionId: null, inventorySource: `beta:${betaId}` });
await writeFile(join(archive, `${name}.${configFileOf(name)}`), composedBuf);
}
const changes = revisionFileChanges(betaId, name);
const configChange = changes.find(change => change.kind === 'config');
if (configChange?.content) await writeFile(join(archive, `${name}.${configFileOf(name)}`), configChange.content);
if (!isActiveBetaServer(betaId, name)) continue;
db().transaction(() => {
for (const change of changes) queueFileChange(change);
@@ -732,8 +696,6 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
const dropped = new Set<string>();
const warnings: string[] = [];
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
const getSnapshot = db().prepare(
`SELECT ${blobContent('prior_content_hash')} AS prior_content, prior_sha256, prior_size FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?`
@@ -754,7 +716,8 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
warnings.push(`${bs.server}: original server was deleted; retained history was skipped`);
continue;
}
for (const { script } of bundle) {
const members = bundle.filter(({ script }) => getBetaServerScript(betaId, bs.server, script.id));
for (const { script } of members) {
if (isCollapsingKind(script.kind) || !applied(bs, script.kind, script.target_rel_path)) continue;
const bss = getBetaServerScript(betaId, bs.server, script.id);
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
@@ -769,7 +732,7 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
(snap ? restored : dropped).add(bs.server);
}
const targetRel = configFileOf(bs.server);
if (hasCollapsing && applied(bs, 'config', targetRel)) {
if (members.some(({ script }) => isCollapsingKind(script.kind)) && applied(bs, 'config', targetRel)) {
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
| undefined;
@@ -878,8 +841,12 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
const participants = listBetaServers(betaId);
const tested = new Set(participants.filter(row => isActiveBetaServer(betaId, row.server)).map(row => row.server));
const targets = (script: ScriptRow) => [...new Set([...tested, ...(script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value) : [])])].sort();
const targetsByScript = new Map(bundle.map(({ script }) => {
const participants = [...tested].filter(server => getBetaServerScript(betaId, server, script.id));
const scopeServers = script.scope_type && script.scope_value ? serversMatchingScope(script.scope_type, script.scope_value) : [];
return [script.id, [...new Set([...participants, ...scopeServers])].sort()];
}));
const targets = (script: ScriptRow) => targetsByScript.get(script.id)!;
const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind,
path: script.target_rel_path, versionId: version.id, version: version.version,
release_version: version.release_version, sha256: version.sha256,
@@ -890,15 +857,23 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
const servers = names.map(name => {
const server = getServer(name);
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
const destinations = new Map<string, number>();
for (const file of files.filter(file => file.servers.includes(name) && file.kind !== 'config')) {
const target = file.kind === 'baseline' ? 'baseline' : `${file.kind}:${file.path}`;
const prior = destinations.get(target);
if (prior !== undefined) blockers.push(`${name}: files ${prior} and ${file.id} share target ${file.path}.`);
destinations.set(target, file.id);
}
return { name, tested: tested.has(name), game: server?.game ?? null, map: server?.map ?? null };
});
const collapsing = bundle.filter(({ script }) => isCollapsingKind(script.kind));
const configContent = new Map<string, Buffer>();
const configWrites = [...new Set(collapsing.flatMap(({ script }) => targets(script)))].sort().map(server => {
const row = getServer(server);
const matching = collapsing.filter(({ script }) => targets(script).includes(server));
const composed = row && composeCfgForServer(server, row.game, row.map, {
inBetaBaseline: collapsing.find(({ script }) => script.kind === 'baseline')?.version,
inBetaOverlays: collapsing.filter(({ script }) => script.kind === 'config')
inBetaBaseline: matching.find(({ script }) => script.kind === 'baseline')?.version,
inBetaOverlays: matching.filter(({ script }) => script.kind === 'config')
});
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
if (error) blockers.push(error);
@@ -966,6 +941,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
priorSha: string | null;
priorContent: Buffer | null;
hadFile: boolean;
tested: boolean;
}
const plans: Plan[] = [];
@@ -979,8 +955,8 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
for (const name of targets) {
let plan: Plan;
if (betaServerSet.has(name)) {
const bss = getBetaServerScript(betaId, name, script.id);
const bss = getBetaServerScript(betaId, name, script.id);
if (bss && betaServerSet.has(name)) {
if (bss?.had_original) {
const snap = getSnapshotForPromote.get(betaId, name, script.target_rel_path) as
| { prior_content: Buffer; prior_sha256: string }
@@ -996,15 +972,15 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
}
plan = {
script, version, server: name,
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true, tested: true
};
} else {
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false, tested: true };
}
} else {
// Campaign activation captures the actual before image just
// before replacing this file. Do not wait on remote reads here.
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false, tested: false };
}
plans.push(plan);
}
@@ -1023,6 +999,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
composedSha: string;
hadFile: boolean;
requiresActivation: boolean;
tested: boolean;
}
const cfgPlans: CfgPlan[] = [];
if (collapsing.length > 0) {
@@ -1046,8 +1023,9 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
let priorContent: Buffer | null = null;
let priorSha: string | null = null;
let hadFile = false;
const tested = betaServerSet.has(name) && collapsing.some(({ script }) => getBetaServerScript(betaId, name, script.id));
// Beta server: preserve its original bytes captured at activation.
if (betaServerSet.has(name)) {
if (tested) {
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
| { prior_content: Buffer; prior_sha256: string }
| undefined;
@@ -1057,9 +1035,9 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
hadFile = true;
}
}
const requiresActivation = !betaServerSet.has(name) || !await exists(targetPath)
const requiresActivation = !tested || !await exists(targetPath)
|| sha256Buffer(await readFile(targetPath)) !== composedSha;
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation });
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation, tested });
}
}
@@ -1070,7 +1048,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
// beta was active. Reassert the tested bytes after that older campaign, even
// when they already match the live file, so it cannot overwrite this release.
const queuedByServer = new Map(reviewed.preview.servers.map(server => [server.name, unresolvedCampaignWrites(server.name)]));
const plansToActivate = new Set(plans.filter(p => !betaServerSet.has(p.server)
const plansToActivate = new Set(plans.filter(p => !p.tested
|| queuedByServer.get(p.server)?.some(row => row.kind === p.script.kind && row.rel_path === p.script.target_rel_path)));
for (const cp of cfgPlans) {
if (queuedByServer.get(cp.server)?.some(row => row.kind === 'config' && row.rel_path === configFileOf(cp.server))) cp.requiresActivation = true;
@@ -1124,13 +1102,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
p.version.sha256,
putFileBlob(p.priorContent),
putFileBlob(getVersionContent(p.version.id)),
betaServerSet.has(p.server) ? 0 : 1
p.tested ? 0 : 1
);
if (plansToActivate.has(p)) {
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: p.server,
kind: p.script.kind, relPath: p.script.target_rel_path, content: getVersionContent(p.version.id),
scriptVersionId: p.version.id, inventorySource: `promote:${depId}`,
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(p.server) });
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: p.tested });
}
}
for (const cp of cfgPlans) {
@@ -1144,13 +1122,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
cp.composedSha,
putFileBlob(cp.priorContent),
putFileBlob(cp.composedContent),
betaServerSet.has(cp.server) ? 0 : 1
cp.tested ? 0 : 1
);
if (cp.requiresActivation) {
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: cp.server,
kind: 'config', relPath: configFileOf(cp.server), content: cp.composedContent,
scriptVersionId: null, inventorySource: `promote:${depId}`,
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(cp.server) });
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: cp.tested });
}
}
// deployment_servers — one row per unique server. Beta servers are