Skip to content

Commit c2af4dc

Browse files
ruvnetReuven
andauthored
fix(agentdb): add delete API to GraphDatabaseAdapter + ReflexionMemory.deleteEpisode (#150) (#151)
Closes the agentdb-side gap from ruvnet/ruflo#1784 and ruvnet/RuVector#427: the GraphDatabaseAdapter wraps @ruvector/graph-node which exposes only create/search primitives, but its query() method accepts arbitrary Cypher — so we can implement delete via DETACH DELETE today without waiting for binding-level methods to be republished. New on GraphDatabaseAdapter: - deleteNode(id, { cascade?: boolean }): { deletedNode, deletedEdges } Counts incident edges before delete so the caller gets an accurate edge-removal count regardless of binding stats. cascade: false refuses when incident edges exist (matches RuVector#427 spec). - deleteEdge(id): { deleted } - deleteHyperedge(id): { deleted } — scopes the match with :HYPEREDGE - deleteEdgesByEndpoints(from, to, label?): { deleted } — saves callers from materialising every edge id before scrubbing a tuple - escapeId / escapeLabel: Cypher-injection guards for ids that may carry user-supplied content. Labels are validated against /^[A-Za-z_][A-Za-z0-9_]*$/ so something like 'evil; DROP' throws instead of slipping into the query. New on ReflexionMemory: - deleteEpisode(id: number | string): boolean Mirrors the dual-write pattern from #128 in reverse — routes through graph adapter / generic graph backend / vector backend (when present) and ALSO purges SQL episodes / episode_embeddings rows so durable storage stays in sync. Best-effort across backends; returns true if any backend acknowledged the delete. Tests (10): tests/unit/controllers/ReflexionMemory-delete.test.ts - 3 ReflexionMemory.deleteEpisode tests against in-memory SQL fake - 7 GraphDatabaseAdapter Cypher-emission tests against a CypherSpy that records every query() call: covers DETACH DELETE wiring, cascade refusal, edge-by-id, hyperedge label scoping, endpoint-tuple delete, label injection guard, id escaping for embedded quotes/backslashes. Verified: cd packages/agentdb && npx vitest run tests/unit/controllers/ReflexionMemory-delete.test.ts Test Files 1 passed (1) Tests 10 passed (10) cd agentic-flow && npx vitest run tests/issue-fixes.test.ts Test Files 1 passed (1) Tests 23 passed (23) (still passing — no regressions) Closes #150 Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
1 parent 50eef3a commit c2af4dc

3 files changed

Lines changed: 480 additions & 0 deletions

File tree

packages/agentdb/src/backends/graph/GraphDatabaseAdapter.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,153 @@ export class GraphDatabaseAdapter {
289289
await this.db.createEdge(edge);
290290
}
291291

292+
// ==========================================================================
293+
// Delete API (issue #150 — closes the gap from ruflo#1784 / RuVector#427)
294+
//
295+
// The native @ruvector/graph-node binding (currently 2.0.4) doesn't expose
296+
// direct deleteNode / deleteEdge / deleteHyperedge methods, but `query()`
297+
// accepts arbitrary Cypher. We implement deletes by routing through Cypher
298+
// so downstream consumers (ruflo's adr-index, agent decommissioning, etc.)
299+
// can keep the graph in sync with external sources of truth without
300+
// rebuilding the whole database.
301+
// ==========================================================================
302+
303+
/**
304+
* Delete a node by id. With `cascade: true` (default) all incident edges
305+
* are removed in the same transaction (`DETACH DELETE`); with
306+
* `cascade: false` the call refuses when incident edges exist (matching
307+
* the spec from RuVector#427).
308+
*
309+
* @returns `deletedNode`: whether the node existed and was removed.
310+
* `deletedEdges`: count of incident edges removed (only meaningful
311+
* when `cascade: true`).
312+
*/
313+
async deleteNode(
314+
id: string,
315+
opts: { cascade?: boolean } = {}
316+
): Promise<{ deletedNode: boolean; deletedEdges: number }> {
317+
const cascade = opts.cascade !== false;
318+
const escaped = this.escapeId(id);
319+
320+
// Count incident edges before delete so we can return an accurate count
321+
// regardless of whether the binding's stats include it.
322+
const before = await this.db.query(
323+
`MATCH ({id: '${escaped}'})-[r]-() RETURN count(r) AS edgeCount`
324+
);
325+
const edgeCount = this.firstNumeric(before, 'edgeCount') ?? 0;
326+
327+
if (!cascade && edgeCount > 0) {
328+
throw new Error(
329+
`deleteNode('${id}', { cascade: false }): node has ${edgeCount} incident edge(s); ` +
330+
`pass cascade: true to remove them in the same transaction.`
331+
);
332+
}
333+
334+
const cypher = cascade
335+
? `MATCH (n {id: '${escaped}'}) DETACH DELETE n RETURN count(n) AS deleted`
336+
: `MATCH (n {id: '${escaped}'}) DELETE n RETURN count(n) AS deleted`;
337+
338+
const result = await this.db.query(cypher);
339+
const deletedNode = (this.firstNumeric(result, 'deleted') ?? 0) > 0;
340+
341+
return { deletedNode, deletedEdges: cascade ? edgeCount : 0 };
342+
}
343+
344+
/**
345+
* Delete a single edge by id. Endpoints stay intact.
346+
*/
347+
async deleteEdge(id: string): Promise<{ deleted: boolean }> {
348+
const escaped = this.escapeId(id);
349+
const result = await this.db.query(
350+
`MATCH ()-[r {id: '${escaped}'}]-() DELETE r RETURN count(r) AS deleted`
351+
);
352+
const deleted = (this.firstNumeric(result, 'deleted') ?? 0) > 0;
353+
return { deleted };
354+
}
355+
356+
/**
357+
* Delete a hyperedge by id. Member nodes stay intact.
358+
*
359+
* Hyperedges are stored as relationship-like entities in RuVector's graph;
360+
* we use the same Cypher pattern as `deleteEdge` but match `:HYPEREDGE`
361+
* to disambiguate when the storage represents both as relationships.
362+
*/
363+
async deleteHyperedge(id: string): Promise<{ deleted: boolean }> {
364+
const escaped = this.escapeId(id);
365+
const result = await this.db.query(
366+
`MATCH ()-[r:HYPEREDGE {id: '${escaped}'}]-() DELETE r RETURN count(r) AS deleted`
367+
);
368+
const deleted = (this.firstNumeric(result, 'deleted') ?? 0) > 0;
369+
return { deleted };
370+
}
371+
372+
/**
373+
* Delete every edge between two endpoints, optionally filtered by label.
374+
* Saves callers the cost of materialising edge ids first when they want to
375+
* scrub a `(source, target [, label])` tuple wholesale.
376+
*/
377+
async deleteEdgesByEndpoints(
378+
from: string,
379+
to: string,
380+
label?: string
381+
): Promise<{ deleted: number }> {
382+
const f = this.escapeId(from);
383+
const t = this.escapeId(to);
384+
const labelClause = label ? `:${this.escapeLabel(label)}` : '';
385+
const result = await this.db.query(
386+
`MATCH ({id: '${f}'})-[r${labelClause}]->({id: '${t}'}) DELETE r RETURN count(r) AS deleted`
387+
);
388+
const deleted = this.firstNumeric(result, 'deleted') ?? 0;
389+
return { deleted };
390+
}
391+
392+
/**
393+
* Cypher escaping for ids/strings. We single-quote the value, so any
394+
* embedded single quote needs doubling, and backslashes are escaped to
395+
* keep the binding's parser happy.
396+
*/
397+
private escapeId(value: string): string {
398+
return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
399+
}
400+
401+
private escapeLabel(label: string): string {
402+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(label)) {
403+
throw new Error(`Invalid graph label: ${label}`);
404+
}
405+
return label;
406+
}
407+
408+
/**
409+
* Pull the first numeric value of column `col` out of a JsQueryResult.
410+
* Different binding versions package row data slightly differently
411+
* (`rows`, `nodes`, `edges`, `data`); this is the lowest-common-denominator
412+
* extractor.
413+
*/
414+
private firstNumeric(result: any, col: string): number | null {
415+
if (!result) return null;
416+
const candidates: any[] = [];
417+
if (Array.isArray(result.rows)) candidates.push(...result.rows);
418+
if (Array.isArray(result.data)) candidates.push(...result.data);
419+
if (Array.isArray(result.nodes)) candidates.push(...result.nodes);
420+
if (Array.isArray(result.edges)) candidates.push(...result.edges);
421+
if (Array.isArray(result)) candidates.push(...result);
422+
423+
for (const row of candidates) {
424+
if (row == null) continue;
425+
if (typeof row === 'object' && col in row) {
426+
const v = row[col];
427+
const n = typeof v === 'string' ? Number(v) : v;
428+
if (typeof n === 'number' && !Number.isNaN(n)) return n;
429+
}
430+
if (typeof row === 'object' && row.properties && col in row.properties) {
431+
const v = row.properties[col];
432+
const n = typeof v === 'string' ? Number(v) : v;
433+
if (typeof n === 'number' && !Number.isNaN(n)) return n;
434+
}
435+
}
436+
return null;
437+
}
438+
292439
/**
293440
* Get graph statistics
294441
*/

packages/agentdb/src/controllers/ReflexionMemory.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,99 @@ export class ReflexionMemory {
11261126
});
11271127
}
11281128

1129+
/**
1130+
* Delete an episode by id. Closes the gap from issue #150 (downstream:
1131+
* ruflo#1784, RuVector#427) — once an episode is written there was no
1132+
* first-class way to remove it through any backend.
1133+
*
1134+
* Strategy mirrors storeEpisode: when a graph backend is present we go
1135+
* through it (`graphAdapter.deleteNode` / `graphBackend.deleteNode` —
1136+
* both Cypher-backed under the hood), and we ALWAYS also purge the SQL
1137+
* `episodes` and `episode_embeddings` rows so durable storage stays in
1138+
* sync. Vector backend entries are removed too when present.
1139+
*
1140+
* @returns True if the episode existed in any backend and was removed.
1141+
*/
1142+
async deleteEpisode(id: number | string): Promise<boolean> {
1143+
const numericId = typeof id === 'number' ? id : parseInt(String(id), 10);
1144+
const stringId = String(id);
1145+
1146+
// Invalidate caches up front — failures below shouldn't leave stale reads.
1147+
this.queryCache.invalidateCategory('episodes');
1148+
this.queryCache.invalidateCategory('task-stats');
1149+
1150+
let removed = false;
1151+
1152+
// 1. Graph adapter (AgentDB v2 fast path)
1153+
if (this.graphBackend && 'storeEpisode' in this.graphBackend) {
1154+
const adapter = this.graphBackend as any;
1155+
if (typeof adapter.deleteNode === 'function') {
1156+
try {
1157+
// Adapter accepts either the canonical "episode-<id>" key or a raw
1158+
// id; try the canonical form first since that's what storeEpisode
1159+
// emits.
1160+
const r = await adapter.deleteNode(`episode-${numericId}`, { cascade: true });
1161+
if (r?.deletedNode) removed = true;
1162+
if (!removed) {
1163+
const r2 = await adapter.deleteNode(stringId, { cascade: true });
1164+
if (r2?.deletedNode) removed = true;
1165+
}
1166+
} catch (err) {
1167+
const msg = err instanceof Error ? err.message : String(err);
1168+
// eslint-disable-next-line no-console
1169+
console.warn(`[ReflexionMemory] deleteEpisode: graph adapter delete failed: ${msg}`);
1170+
}
1171+
}
1172+
}
1173+
// 2. Generic graph backend
1174+
else if (this.graphBackend && typeof (this.graphBackend as any).deleteNode === 'function') {
1175+
try {
1176+
const r = await (this.graphBackend as any).deleteNode(stringId);
1177+
if (r === true) removed = true;
1178+
} catch (err) {
1179+
const msg = err instanceof Error ? err.message : String(err);
1180+
// eslint-disable-next-line no-console
1181+
console.warn(`[ReflexionMemory] deleteEpisode: graph backend delete failed: ${msg}`);
1182+
}
1183+
}
1184+
1185+
// 3. Vector backend (HNSW/RuVector); some implementations don't expose
1186+
// delete — guard with a feature check.
1187+
if (this.vectorBackend && typeof (this.vectorBackend as any).delete === 'function') {
1188+
try {
1189+
const r = await (this.vectorBackend as any).delete(String(numericId));
1190+
if (r === true) removed = true;
1191+
} catch (err) {
1192+
const msg = err instanceof Error ? err.message : String(err);
1193+
// eslint-disable-next-line no-console
1194+
console.warn(`[ReflexionMemory] deleteEpisode: vector backend delete failed: ${msg}`);
1195+
}
1196+
}
1197+
1198+
// 4. SQL durable storage — always attempt so persistence stays clean
1199+
// even when the primary backend was a no-op.
1200+
if (this.db && typeof this.db.prepare === 'function' && Number.isFinite(numericId)) {
1201+
try {
1202+
const embStmt = this.db.prepare(
1203+
`DELETE FROM episode_embeddings WHERE episode_id = ?`
1204+
);
1205+
embStmt.run(numericId);
1206+
const stmt = this.db.prepare(`DELETE FROM episodes WHERE id = ?`);
1207+
const r = stmt.run(numericId);
1208+
const changes = (r as any)?.changes ?? 0;
1209+
if (changes > 0) removed = true;
1210+
} catch (err) {
1211+
const msg = err instanceof Error ? err.message : String(err);
1212+
if (!/no such table/i.test(msg)) {
1213+
// eslint-disable-next-line no-console
1214+
console.warn(`[ReflexionMemory] deleteEpisode: SQL delete failed: ${msg}`);
1215+
}
1216+
}
1217+
}
1218+
1219+
return removed;
1220+
}
1221+
11291222
/**
11301223
* Dual-write helper for issue #128.
11311224
*

0 commit comments

Comments
 (0)