Single-node deletion: full death by node id (node interlude, owner-locked)

The remedy for undeletable nodes (preserve-graph orphans from session
deletion; notes-import nodes with no chat behind them): DELETE
/api/graph/nodes/[id], full-death semantics — the user pointing at the
node IS the intent; references from other sessions do NOT preserve it.

- core/graph-deletion.ts deleteNodeTraces: the node's every line, every
  edge touching it, every quiz/observation event naming it, its mapping
  lines, and its resting position — one synchronous atomic rewriteLedgers
  call; only the ledgers that changed rewrite (mtimes stay honest). Blast
  radius is exactly the rows naming the node id; notes source records stay
  (deleting a proposition is not deleting the file). Derived data
  self-heals on read: the birth cluster shrinks below MIN_CLUSTER_SIZE and
  stops rendering.
- nodeDeletionImpact (GET /api/graph/nodes/[id]): the confirm dialog's
  pre-flight facts — edge count, quiz/observation history, referencing
  sessions. The snapshot's edges + nodeSessions cover part of this, but
  the raw event history was nowhere on the client — hence the read.
- Guards (server/graph.ts deleteGraphNode), decided + documented: NO
  turn/jobs gates (they never write graph ledgers; the orchestration is
  synchronous end to end, nothing interleaves). THE EXTRACTION GATE: 409
  while ANY pipeline run is registered — a mid-flight run holds
  pre-deletion findings and could re-reference the dying node. The run is
  never aborted (its spend belongs to another session — outside this
  deletion's blast radius; the UI retries once the dock drains. A run
  started after the deletion is legitimate re-learning: fresh ledgers,
  fresh node id, and mergeFindings' existingIds guard ignores linkTo ids
  that no longer exist — no dangling edges, no resurrection.

Live-verified on dev data: impact read, full-death stats, honest 404 on
repeat, and the file cluster fell 3→2 and unrendered itself.

859/859 tests, typecheck/lint/build green.
EOF
)
This commit is contained in:
amosblomqvist
2026-09-23 16:28:24 +03:00
parent 7479dce8ef
commit 2d133f1133
4 changed files with 406 additions and 1 deletions
+63
View File
@@ -4,6 +4,13 @@ import type { GraphSnapshot } from "../core/contracts.ts";
import type { GraphCostSummary } from "../core/cost-summary.ts";
import { graphCostSummary } from "../core/cost-summary.ts";
import { graphSnapshot } from "../core/graph.ts";
import {
deleteNodeTraces,
nodeDeletionImpact,
type NodeDeletionImpact,
type NodeDeletionStats,
} from "../core/graph-deletion.ts";
import { extractionStatuses } from "../agent/subagents/extraction.ts";
import { stores } from "./stores.ts";
/**
@@ -65,3 +72,59 @@ export function graphRevision(): number {
}
return revision;
}
// ─── single-node deletion (node interlude, owner-locked full-death) ──────────
/** What deleteGraphNode did — the route maps it straight to HTTP. */
export type NodeDeleteOutcome =
| { ok: false; status: 404; code: "not_found"; error: string }
| { ok: false; status: 409; code: "extraction_running"; error: string }
| { ok: true; stats: NodeDeletionStats; graphRevision: number };
/**
* Delete one node by id — FULL DEATH (core/graph-deletion.ts for the
* ledger semantics). The guard family is deliberately NOT the chat
* one, and the reasoning is worth writing down:
*
* - No turn/jobs gates. Turns and background jobs never write the
* graph ledgers; a turn's follow-up extraction registers as a run,
* and every code path here is synchronous — nothing can register
* or merge between the gate and the ledger rewrite (the event loop
* never yields inside this function).
* - THE EXTRACTION GATE — 409 while ANY pipeline run is registered,
* anywhere. A mid-flight run holds findings extracted from
* transcripts that predate the deletion; its merges could re-add
* aliases, provenance, or events to the dying node (the
* re-reference risk the dispatch flags). Unlike chat deletion we
* do NOT abort the run: it belongs to some session, and killing
* that session's spend is outside this deletion's blast radius —
* the UI retries once the dock drains. A run that starts AFTER
* the deletion is legitimate re-learning: it reads post-deletion
* ledgers, mints a fresh node for a re-taught topic, and
* mergeFindings ignores linkTo ids that no longer exist (the
* existingIds guard) — no dangling edges, no resurrection.
*/
export function deleteGraphNode(nodeId: string): NodeDeleteOutcome {
if (extractionStatuses().length > 0) {
return {
ok: false,
status: 409,
code: "extraction_running",
error: "An extraction is running — try again when it finishes",
};
}
// THE SYNC BLOCK — no awaits below (the doc above): the gate and the
// rewrite are one atomic stretch of the event loop.
const stats = deleteNodeTraces(stores.graphStore, nodeId);
if (stats === null) {
return { ok: false, status: 404, code: "not_found", error: "No such node" };
}
return { ok: true, stats, graphRevision: graphRevision() };
}
/** The confirm dialog's pre-flight read (GET /api/graph/nodes/[id]):
* what full death would take with it. 404-shaped null when the node
* does not exist — the UI shows the dialog only for nodes it can see. */
export function graphNodeImpact(nodeId: string): NodeDeletionImpact | null {
return nodeDeletionImpact(stores.graphStore, nodeId);
}