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:
@@ -0,0 +1,42 @@
|
||||
import { deleteGraphNode, graphNodeImpact } from "@/src/server";
|
||||
|
||||
/**
|
||||
* GET /api/graph/nodes/[id] — the deletion confirm dialog's pre-flight
|
||||
* read (node interlude, owner-locked full-death semantics): what deleting
|
||||
* this node would take with it. Edges touching it, its quiz/observation
|
||||
* history, and the sessions that reference it — the facts the user weighs
|
||||
* before pointing a destructive operation at the graph. 404 when the node
|
||||
* does not exist.
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const impact = graphNodeImpact(id);
|
||||
if (impact === null) return Response.json({ error: "No such node" }, { status: 404 });
|
||||
return Response.json(impact);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/graph/nodes/[id] — single-node deletion, FULL DEATH (owner
|
||||
* ruling 2026-09-23: the user pointing at the node is intent — references
|
||||
* from other sessions do not preserve it). The node's every line, every
|
||||
* edge touching it, every quiz/observation event naming it, its mapping
|
||||
* lines, and its position die in one atomic ledger rewrite
|
||||
* (core/graph-deletion.ts deleteNodeTraces); derived data (birth
|
||||
* partition, clusters) self-heals on read.
|
||||
*
|
||||
* Guards (server/graph.ts deleteGraphNode): 409 while ANY extraction 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), the UI retries once the dock drains. No turn/jobs
|
||||
* gates: they never write the graph ledgers, and this orchestration is
|
||||
* synchronous end to end — nothing interleaves. The response carries what
|
||||
* was removed plus the new graphRevision (the refetch signal).
|
||||
*/
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const outcome = deleteGraphNode(id);
|
||||
if (!outcome.ok) {
|
||||
return Response.json({ error: outcome.error, code: outcome.code }, { status: outcome.status });
|
||||
}
|
||||
return Response.json({ stats: outcome.stats, graphRevision: outcome.graphRevision });
|
||||
}
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -621,6 +621,12 @@ import { deleteChatTraces, type ChatDeletionStats } from "../src/core/graph-dele
|
||||
import { deleteChatSession } from "../src/server/chats.ts";
|
||||
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import {
|
||||
deleteNodeTraces,
|
||||
nodeDeletionImpact,
|
||||
type NodeDeletionStats,
|
||||
} from "../src/core/graph-deletion.ts";
|
||||
import { deleteGraphNode, graphRevision } from "../src/server/graph.ts";
|
||||
|
||||
describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () => {
|
||||
/** Two-session scenario: dA creates the shared node X + the orphan Y and
|
||||
@@ -809,3 +815,177 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
assert.deepEqual(await deleteChatSession(dA.id, "delete-nodes"), { ok: false, status: 404, code: "not_found", error: "No such session" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── single-node deletion (node interlude, full death) ───────────────────────
|
||||
|
||||
describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlude)", () => {
|
||||
/** The full-death scenario: node X is born in a notes file (the
|
||||
* production orphan shape — EMPTY provenance, the mapping line is the
|
||||
* anchor), re-encountered by a chat (link line: alias + provenance),
|
||||
* asserted in an edge, and evaluated by two sessions. */
|
||||
function buildNodeScenario() {
|
||||
// Clean slate per test: each test in this describe builds its own
|
||||
// scenario from empty ledgers (the suite's isolation covers the
|
||||
// whole file; this covers describe-internal stacking).
|
||||
graphStore.rewriteLedgers({ nodes: [], edges: [], events: [], mappings: [] });
|
||||
graphStore.writePositions({});
|
||||
graphStore.appendNode({
|
||||
id: "n_dead",
|
||||
name: "Doomed",
|
||||
statement: "Doomed statement",
|
||||
aliases: [],
|
||||
createdAt: "2026-09-23T00:00:00.000Z",
|
||||
provenance: [],
|
||||
});
|
||||
// The chat's re-encounter: a link line (alias + its own provenance).
|
||||
graphStore.appendNode({
|
||||
id: "n_dead",
|
||||
name: "Doomed",
|
||||
statement: "Doomed statement",
|
||||
aliases: ["doomed, rephrased"],
|
||||
createdAt: "2026-09-23T00:00:00.000Z",
|
||||
provenance: [{ chatId: "chat_node_a", messageId: "m1" }],
|
||||
});
|
||||
// A neighbor + two edges: only the one touching X dies.
|
||||
graphStore.appendNode({
|
||||
id: "n_keep",
|
||||
name: "Keeper",
|
||||
statement: "Keeper statement",
|
||||
aliases: [],
|
||||
createdAt: "2026-09-23T00:00:00.000Z",
|
||||
provenance: [{ chatId: "chat_node_a", messageId: "m2" }],
|
||||
});
|
||||
graphStore.appendEdge({ from: "n_dead", to: "n_keep", type: "follows", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] });
|
||||
graphStore.appendEdge({ from: "n_keep", to: "n_dead", type: "supports", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
||||
graphStore.appendEdge({ from: "n_keep", to: "n_other", type: "follows", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
|
||||
// Events: two sessions' evidence on X, one on the neighbor.
|
||||
graphStore.appendEvent({ nodeId: "n_dead", kind: "correct", chatId: "chat_node_a", at: "2026-09-23T00:01:00.000Z", ref: "q1" });
|
||||
graphStore.appendEvent({ nodeId: "n_dead", kind: "observation", chatId: "chat_node_b", at: "2026-09-23T00:02:00.000Z", note: "shaky", ref: "obs:p1" });
|
||||
graphStore.appendEvent({ nodeId: "n_keep", kind: "miss", chatId: "chat_node_a", at: "2026-09-23T00:03:00.000Z", ref: "q2" });
|
||||
// Mappings: the notes anchor + the chat's audit trail.
|
||||
graphStore.appendMapping({ chatId: "src_nodefile", sourceId: "p1", graphNodeId: "n_dead" });
|
||||
graphStore.appendMapping({ chatId: "chat_node_a", sourceId: "p1", graphNodeId: "n_dead" });
|
||||
graphStore.appendMapping({ chatId: "chat_node_a", sourceId: "p2", graphNodeId: "n_keep" });
|
||||
// Resting positions for both.
|
||||
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } });
|
||||
}
|
||||
|
||||
it("nodeDeletionImpact: the confirm dialog's pre-flight facts", () => {
|
||||
buildNodeScenario();
|
||||
assert.deepEqual(nodeDeletionImpact(graphStore, "n_dead"), {
|
||||
edges: 2,
|
||||
quizEvents: 1,
|
||||
observations: 1,
|
||||
// The file anchor, the re-encounter, and the second session's
|
||||
// observation — every referencing session, deduped and sorted.
|
||||
sessions: ["chat_node_a", "chat_node_b", "src_nodefile"],
|
||||
});
|
||||
// Unknown node: null (the route's 404).
|
||||
assert.equal(nodeDeletionImpact(graphStore, "n_missing"), null);
|
||||
});
|
||||
|
||||
it("deleteNodeTraces: FULL DEATH — the node, its edges, its evidence, its anchors, its position", () => {
|
||||
buildNodeScenario();
|
||||
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
|
||||
const stats = deleteNodeTraces(graphStore, "n_dead");
|
||||
assert.deepEqual(stats, { edgesRemoved: 2, eventsRemoved: 2, mappingsRemoved: 2 } satisfies NodeDeletionStats);
|
||||
|
||||
// The node's EVERY line is gone; the neighbor is byte-identical
|
||||
// (full death is node-scoped — other nodes' sessions keep their
|
||||
// aliases and provenance).
|
||||
assert.equal(graphStore.readNodes().some((n) => n.id === "n_dead"), false);
|
||||
assert.equal(
|
||||
JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep")),
|
||||
keepLineBefore,
|
||||
"the untouched node is byte-identical",
|
||||
);
|
||||
// Only the edge to nowhere rode along; the foreign edge X→Z stays.
|
||||
assert.deepEqual(
|
||||
graphStore.readEdges().map((e) => [e.from, e.to]),
|
||||
[["n_keep", "n_other"]],
|
||||
);
|
||||
// The evidence died with the thing it was about; the neighbor's stays.
|
||||
assert.deepEqual(
|
||||
graphStore.readEvents().map((e) => [e.nodeId, e.kind]),
|
||||
[["n_keep", "miss"]],
|
||||
);
|
||||
// The audit trail: X's mappings gone, the neighbor's stay.
|
||||
assert.deepEqual(graphStore.readMappings().map((m) => m.graphNodeId), ["n_keep"]);
|
||||
// The resting position died with the node.
|
||||
const positions = graphStore.readPositions();
|
||||
assert.equal(positions["n_dead"], undefined);
|
||||
assert.deepEqual(positions["n_keep"], { x: 2, y: 2 });
|
||||
// Atomic rewrites left no temp files.
|
||||
assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0);
|
||||
// Idempotent: a second run finds no node — null.
|
||||
assert.equal(deleteNodeTraces(graphStore, "n_dead"), null);
|
||||
});
|
||||
|
||||
it("deleteNodeTraces: a node nothing references still dies, and only its file moves", () => {
|
||||
graphStore.appendNode({
|
||||
id: "n_solo",
|
||||
name: "Solo",
|
||||
statement: "Solo statement",
|
||||
aliases: [],
|
||||
createdAt: "2026-09-23T00:00:00.000Z",
|
||||
provenance: [{ chatId: "chat_node_solo", messageId: "m1" }],
|
||||
});
|
||||
const stats = deleteNodeTraces(graphStore, "n_solo");
|
||||
assert.deepEqual(stats, { edgesRemoved: 0, eventsRemoved: 0, mappingsRemoved: 0 } satisfies NodeDeletionStats);
|
||||
assert.equal(graphStore.readNodes().some((n) => n.id === "n_solo"), false);
|
||||
});
|
||||
|
||||
it("deleteGraphNode: the orchestration — extraction gate, 404, success", () => {
|
||||
// 404 — unknown node.
|
||||
assert.deepEqual(deleteGraphNode("n_missing_node"), {
|
||||
ok: false,
|
||||
status: 404,
|
||||
code: "not_found",
|
||||
error: "No such node",
|
||||
});
|
||||
|
||||
// 409 — ANY registered extraction run: a mid-flight run could
|
||||
// re-reference the dying node; the run is never aborted (its spend
|
||||
// belongs to another session), the caller retries. The registry is
|
||||
// the pipeline's globalThis store — a fake entry is the honest way
|
||||
// to pin the gate without staging a real run.
|
||||
const g = globalThis as { __lhExtractionRuns?: unknown };
|
||||
const realRuns = g.__lhExtractionRuns;
|
||||
g.__lhExtractionRuns = {
|
||||
running: new Map([
|
||||
["chat_busy", { chatId: "chat_busy", kind: "chat", phase: "extracting", startedAt: 1, usage: {}, controller: new AbortController() }],
|
||||
]),
|
||||
inFlight: new Set(),
|
||||
lastStartedAt: 0,
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(deleteGraphNode("n_gate_probe"), {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: "extraction_running",
|
||||
error: "An extraction is running — try again when it finishes",
|
||||
});
|
||||
} finally {
|
||||
g.__lhExtractionRuns = realRuns;
|
||||
}
|
||||
|
||||
// Success — the node dies, the version stamp moves.
|
||||
graphStore.appendNode({
|
||||
id: "n_orch",
|
||||
name: "Orchestrated",
|
||||
statement: "Orchestrated statement",
|
||||
aliases: [],
|
||||
createdAt: "2026-09-23T00:00:00.000Z",
|
||||
provenance: [{ chatId: "chat_node_c", messageId: "m1" }],
|
||||
});
|
||||
const revisionBefore = graphRevision();
|
||||
const outcome = deleteGraphNode("n_orch");
|
||||
assert.ok(outcome.ok);
|
||||
assert.deepEqual(outcome.stats, { edgesRemoved: 0, eventsRemoved: 0, mappingsRemoved: 0 });
|
||||
assert.ok(outcome.graphRevision > 0);
|
||||
assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved");
|
||||
assert.equal(graphStore.readNodes().some((n) => n.id === "n_orch"), false);
|
||||
// And a second call is an honest 404.
|
||||
assert.deepEqual(deleteGraphNode("n_orch"), { ok: false, status: 404, code: "not_found", error: "No such node" });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user