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:
+121
-1
@@ -250,4 +250,124 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
// ─── single-node deletion (node interlude, owner-locked full-death) ───────────
|
||||
|
||||
/**
|
||||
* FULL DEATH for one node, by id (owner ruling 2026-09-23): the user
|
||||
* pointing at the node IS the intent — references from other sessions do
|
||||
* NOT preserve it. The remedy for undeletable nodes (preserve-graph
|
||||
* orphans from session deletion; notes-import nodes with no chat behind
|
||||
* them), and the deliberate inverse of the chat cascade above: that one
|
||||
* deletes a SESSION and lets nodes survive on foreign references; this
|
||||
* one deletes a NODE and takes its every trace —
|
||||
*
|
||||
* - every line of the node (create + link lines)
|
||||
* - every EDGE touching it (from or to), whatever session asserted it
|
||||
* - every EVENT naming it (quiz outcomes + observations), from every
|
||||
* session — the evidence history dies with the thing it was about
|
||||
* - every mapping line that anchored it (the audit trail; for a
|
||||
* notes-born node the file IS the anchor — its mapping line is the
|
||||
* only one)
|
||||
* - its resting position
|
||||
*
|
||||
* The blast radius is exactly the rows naming the node id: other nodes'
|
||||
* provenance, aliases, and sessions are untouched (full death is
|
||||
* NODE-scoped, not session-scoped), and the source records of notes
|
||||
* imports stay (the import's bookkeeping; deleting one proposition is not
|
||||
* deleting the file). Derived data needs no pruning: the birth partition
|
||||
* derives on read (the cluster shrinks below MIN_CLUSTER_SIZE and stops
|
||||
* rendering — fine), and no other consumer indexes nodes by id across
|
||||
* ledgers.
|
||||
*
|
||||
* Synchronous, one rewriteLedgers call, no awaits (the same atomicity
|
||||
* argument as the chat cascade); idempotent in the wave-6 sense — a
|
||||
* second run finds no lines and changes nothing.
|
||||
*/
|
||||
export interface NodeDeletionStats {
|
||||
/** Edge assertion LINES removed (every edge touching the node). */
|
||||
edgesRemoved: number;
|
||||
/** Events removed (quiz outcomes + observations naming the node). */
|
||||
eventsRemoved: number;
|
||||
/** Mapping lines removed (the node's audit trail). */
|
||||
mappingsRemoved: number;
|
||||
}
|
||||
|
||||
/** The confirm dialog's pre-flight facts (the GET on the same node id):
|
||||
* what full death would take with it. sessions is the deduped set of
|
||||
* chats/notes sources that reference the node — provenance points,
|
||||
* mapping lines, and events all count. */
|
||||
export interface NodeDeletionImpact {
|
||||
edges: number;
|
||||
quizEvents: number;
|
||||
observations: number;
|
||||
sessions: string[];
|
||||
}
|
||||
|
||||
/** A node exists iff a ledger line carries its id. */
|
||||
function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] {
|
||||
return graphStore.readNodes().filter((line) => line.id === nodeId);
|
||||
}
|
||||
|
||||
/** The impact facts for one node, or null when no such node exists. */
|
||||
export function nodeDeletionImpact(graphStore: GraphStore, nodeId: string): NodeDeletionImpact | null {
|
||||
if (nodeLinesOf(graphStore, nodeId).length === 0) return null;
|
||||
const edges = graphStore.readEdges().filter((line) => line.from === nodeId || line.to === nodeId);
|
||||
const events = graphStore.readEvents().filter((event) => event.nodeId === nodeId);
|
||||
const sessions = new Set<string>();
|
||||
for (const line of nodeLinesOf(graphStore, nodeId)) {
|
||||
for (const pt of line.provenance) sessions.add(pt.chatId);
|
||||
}
|
||||
for (const mapping of graphStore.readMappings()) {
|
||||
if (mapping.graphNodeId === nodeId) sessions.add(mapping.chatId);
|
||||
}
|
||||
for (const event of events) sessions.add(event.chatId);
|
||||
return {
|
||||
edges: edges.length,
|
||||
quizEvents: events.filter((event) => event.kind !== "observation").length,
|
||||
observations: events.filter((event) => event.kind === "observation").length,
|
||||
sessions: [...sessions].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-death one node. Returns null when no such node exists (the
|
||||
* orchestration maps that to 404); rewrites only the ledgers that
|
||||
* changed (unchanged files keep their mtimes — the version stamp moves
|
||||
* only when something really did).
|
||||
*/
|
||||
export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDeletionStats | null {
|
||||
const nodeLines = nodeLinesOf(graphStore, nodeId);
|
||||
if (nodeLines.length === 0) return null;
|
||||
|
||||
const edgeLines = graphStore.readEdges();
|
||||
const events = graphStore.readEvents();
|
||||
const mappings = graphStore.readMappings();
|
||||
|
||||
const outEdges = edgeLines.filter((line) => line.from !== nodeId && line.to !== nodeId);
|
||||
const outEvents = events.filter((event) => event.nodeId !== nodeId);
|
||||
const outMappings = mappings.filter((m) => m.graphNodeId !== nodeId);
|
||||
|
||||
// Only the touched ledgers: the node's own lines always die (its file
|
||||
// must move), everything else rewrites only when rows actually drop.
|
||||
const outNodes = graphStore.readNodes().filter((line) => line.id !== nodeId);
|
||||
graphStore.rewriteLedgers({
|
||||
nodes: outNodes,
|
||||
...(outEdges.length !== edgeLines.length ? { edges: outEdges } : {}),
|
||||
...(outEvents.length !== events.length ? { events: outEvents } : {}),
|
||||
...(outMappings.length !== mappings.length ? { mappings: outMappings } : {}),
|
||||
});
|
||||
|
||||
// The resting position dies with the node.
|
||||
const positions = graphStore.readPositions();
|
||||
if (positions[nodeId] !== undefined) {
|
||||
const { [nodeId]: _dead, ...rest } = positions;
|
||||
graphStore.writePositions(rest);
|
||||
}
|
||||
|
||||
return {
|
||||
edgesRemoved: edgeLines.length - outEdges.length,
|
||||
eventsRemoved: events.length - outEvents.length,
|
||||
mappingsRemoved: mappings.length - outMappings.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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