Fix delete FK exhaustively; unset-scope surfaces as drift
deleteScript: previous fix missed beta_servers.prior_version_id, beta_server_scripts.prior_version_id, and deployments.beta_id. Cascade from betas.script_id -> beta_servers/beta_server_scripts left dangling prior_version_id refs, and deletions of cascaded betas dangled deployments.beta_id. Now nulls every NO ACTION reference before the DELETE inside a single transaction. Scope unset no longer touches disk from the API. Instead it detaches inventory rows for scripts/plugins so drift Pass 1 flags the on-disk copies as untracked; configs rely on drift Pass 2's compose-diff, which already handles scope=null naturally. The operator reconciles from /deployments using the existing rescan + reconcile buttons — same visible flow as any other drift, no more silent synchronous file removal. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1020,26 +1020,36 @@ export function latestVersionForScript(script: ScriptRow): ScriptVersionRow | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Un-set a script's scope. When scope is being removed and matching servers
|
* Un-set a script's scope. Files are left on disk so drift picks up the
|
||||||
* currently have the script, remove it from them for consistency. Returns the
|
* change on the next /deployments rescan and the operator reconciles from
|
||||||
* server names that had files deleted.
|
* there like any other drift — the same visible flow as manual drift.
|
||||||
|
*
|
||||||
|
* For scripts/plugins, we detach the inventory rows for this artifact's
|
||||||
|
* versions so drift Pass 1 flags the on-disk copies as untracked. For
|
||||||
|
* configs/baselines, no detach is needed: drift Pass 2 recomposes the
|
||||||
|
* expected dedicated_sp.cfg against the current promoted overlays, and
|
||||||
|
* scope=null naturally drops the overlay from the compose, surfacing the
|
||||||
|
* disparity. Returns the servers whose inventory was detached (empty for
|
||||||
|
* configs/baselines).
|
||||||
*/
|
*/
|
||||||
export async function unsetScopeAndCleanup(scriptId: number): Promise<string[]> {
|
export function unsetScopeAndDetach(scriptId: number): string[] {
|
||||||
const script = getScript(scriptId);
|
const script = getScript(scriptId);
|
||||||
if (!script) throw new BetaError(`no such script (id=${scriptId})`, 'no-such-script');
|
if (!script) throw new BetaError(`no such script (id=${scriptId})`, 'no-such-script');
|
||||||
if (!script.scope_type || !script.scope_value) return [];
|
|
||||||
|
|
||||||
const matched = serversMatchingScope(script.scope_type, script.scope_value);
|
const detached: string[] = [];
|
||||||
const cleaned: string[] = [];
|
if (script.kind === 'script' || script.kind === 'plugin') {
|
||||||
for (const name of matched) {
|
const rows = db()
|
||||||
const dest = destOfKind(name, script.kind);
|
.prepare(
|
||||||
const targetPath = join(dest, script.target_rel_path);
|
`SELECT server FROM server_scripts
|
||||||
if (existsSync(targetPath)) {
|
WHERE rel_path = ?
|
||||||
await rm(targetPath, { force: true });
|
AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?)`
|
||||||
removeScript(name, script.target_rel_path);
|
)
|
||||||
cleaned.push(name);
|
.all(script.target_rel_path, scriptId) as Array<{ server: string }>;
|
||||||
|
for (const { server } of rows) {
|
||||||
|
removeScript(server, script.target_rel_path);
|
||||||
|
detached.push(server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db().prepare('UPDATE scripts SET scope_type = NULL, scope_value = NULL WHERE id = ?').run(scriptId);
|
db().prepare('UPDATE scripts SET scope_type = NULL, scope_value = NULL WHERE id = ?').run(scriptId);
|
||||||
return cleaned;
|
return detached;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,35 +338,29 @@ export function deleteScript(scriptId: number): void {
|
|||||||
.get(scriptId) as { id: number } | undefined;
|
.get(scriptId) as { id: number } | undefined;
|
||||||
if (beta) throw new ScriptError(`beta ${beta.id} is still running against this script`, 'has-active-beta');
|
if (beta) throw new ScriptError(`beta ${beta.id} is still running against this script`, 'has-active-beta');
|
||||||
|
|
||||||
// Null out deployment-history FKs that would otherwise block the cascade.
|
// Every FK that references scripts(id) or script_versions(id) with NO
|
||||||
// deployments.script_id/script_version_id/prior_version_id and
|
// ACTION (the default) will abort the delete unless we null it first.
|
||||||
// deployment_scripts.prior_version_id are historical audit references
|
// ON DELETE CASCADE / SET NULL fires automatically. The list below covers
|
||||||
// with no ON DELETE action; keep the deployment rows intact but drop
|
// every NO ACTION reference in schema.sql; add to it if new ones appear.
|
||||||
// the FK to the script so we can remove it.
|
// deployments.script_id, script_version_id, prior_version_id, beta_id
|
||||||
|
// deployment_scripts.prior_version_id
|
||||||
|
// beta_servers.prior_version_id
|
||||||
|
// beta_server_scripts.prior_version_id
|
||||||
|
// deployments.beta_id needs nulling because betas.script_id ON DELETE
|
||||||
|
// CASCADE nukes any beta bundled against this script, which would then
|
||||||
|
// dangle deployments.beta_id.
|
||||||
const handle = db();
|
const handle = db();
|
||||||
const tx = handle.transaction((id: number) => {
|
const tx = handle.transaction((id: number) => {
|
||||||
handle
|
const inMyVersions = 'IN (SELECT id FROM script_versions WHERE script_id = ?)';
|
||||||
.prepare(
|
const inMyBetas = 'IN (SELECT id FROM betas WHERE script_id = ? UNION SELECT beta_id FROM beta_scripts WHERE script_id = ?)';
|
||||||
`UPDATE deployments
|
|
||||||
SET script_version_id = NULL
|
handle.prepare(`UPDATE beta_servers SET prior_version_id = NULL WHERE prior_version_id ${inMyVersions}`).run(id);
|
||||||
WHERE script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?)`
|
handle.prepare(`UPDATE beta_server_scripts SET prior_version_id = NULL WHERE prior_version_id ${inMyVersions}`).run(id);
|
||||||
)
|
handle.prepare(`UPDATE deployment_scripts SET prior_version_id = NULL WHERE prior_version_id ${inMyVersions}`).run(id);
|
||||||
.run(id);
|
handle.prepare(`UPDATE deployments SET prior_version_id = NULL WHERE prior_version_id ${inMyVersions}`).run(id);
|
||||||
handle
|
handle.prepare(`UPDATE deployments SET script_version_id = NULL WHERE script_version_id ${inMyVersions}`).run(id);
|
||||||
.prepare(
|
handle.prepare(`UPDATE deployments SET beta_id = NULL WHERE beta_id ${inMyBetas}`).run(id, id);
|
||||||
`UPDATE deployments
|
|
||||||
SET prior_version_id = NULL
|
|
||||||
WHERE prior_version_id IN (SELECT id FROM script_versions WHERE script_id = ?)`
|
|
||||||
)
|
|
||||||
.run(id);
|
|
||||||
handle.prepare('UPDATE deployments SET script_id = NULL WHERE script_id = ?').run(id);
|
handle.prepare('UPDATE deployments SET script_id = NULL WHERE script_id = ?').run(id);
|
||||||
handle
|
|
||||||
.prepare(
|
|
||||||
`UPDATE deployment_scripts
|
|
||||||
SET prior_version_id = NULL
|
|
||||||
WHERE prior_version_id IN (SELECT id FROM script_versions WHERE script_id = ?)`
|
|
||||||
)
|
|
||||||
.run(id);
|
|
||||||
handle.prepare('DELETE FROM scripts WHERE id = ?').run(id);
|
handle.prepare('DELETE FROM scripts WHERE id = ?').run(id);
|
||||||
});
|
});
|
||||||
tx(scriptId);
|
tx(scriptId);
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getScript, setScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
|
import { getScript, setScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
|
||||||
import { unsetScopeAndCleanup } from '$lib/server/fleet/beta.js';
|
import { unsetScopeAndDetach } from '$lib/server/fleet/beta.js';
|
||||||
import { runDriftDeployment } from '$lib/server/fleet/drift.js';
|
|
||||||
import { kick } from '$lib/server/pipeline/worker.js';
|
|
||||||
|
|
||||||
function parseId(raw: string): number | null {
|
function parseId(raw: string): number | null {
|
||||||
const n = Number.parseInt(raw, 10);
|
const n = Number.parseInt(raw, 10);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget: after a scope mutation, reconcile any drift the change may
|
|
||||||
// have introduced (orphan files on servers that no longer match, or missing
|
|
||||||
// files on servers that now do). Runs in the background so the API returns
|
|
||||||
// promptly; the deployment will surface on /deployments if there's real drift.
|
|
||||||
function triggerDrift(): void {
|
|
||||||
void runDriftDeployment({ triggeredBy: 'ui' })
|
|
||||||
.then((result) => { if (result.deploymentId) void kick(); })
|
|
||||||
.catch(() => { /* surfaced on /deployments if it fails */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST body: { scopeType: 'game' | 'map' | null, scopeValue?: string }
|
// POST body: { scopeType: 'game' | 'map' | null, scopeValue?: string }
|
||||||
// Setting/changing scope on a script with a current_version_id returns a
|
// Setting/changing scope on a script with a current_version_id returns a
|
||||||
// 409 with kind='scope-requires-beta'; unsetting cleans the file off matching
|
// 409 with kind='scope-requires-beta'. Un-scoping detaches inventory so the
|
||||||
// servers.
|
// orphaned files show up as drift on the deployments page for the operator
|
||||||
|
// to reconcile manually — the same flow as any other drift.
|
||||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||||
const scriptId = parseId(params.id);
|
const scriptId = parseId(params.id);
|
||||||
if (scriptId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
if (scriptId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||||
@@ -39,12 +28,10 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (scopeType === null) {
|
if (scopeType === null) {
|
||||||
const cleaned = await unsetScopeAndCleanup(scriptId);
|
const detached = unsetScopeAndDetach(scriptId);
|
||||||
triggerDrift();
|
return json({ ok: true, detached });
|
||||||
return json({ ok: true, cleaned });
|
|
||||||
}
|
}
|
||||||
const updated = setScope({ scriptId, scopeType, scopeValue });
|
const updated = setScope({ scriptId, scopeType, scopeValue });
|
||||||
triggerDrift();
|
|
||||||
return json({ ok: true, script: updated });
|
return json({ ok: true, script: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ScriptError) {
|
if (err instanceof ScriptError) {
|
||||||
|
|||||||
Reference in New Issue
Block a user