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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user