pilotswarm-horizon-store 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -0
- package/dist/src/agent-tools.d.ts +32 -0
- package/dist/src/agent-tools.d.ts.map +1 -0
- package/dist/src/agent-tools.js +263 -0
- package/dist/src/agent-tools.js.map +1 -0
- package/dist/src/config.d.ts +93 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +120 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/db-retry.d.ts +21 -0
- package/dist/src/db-retry.d.ts.map +1 -0
- package/dist/src/db-retry.js +94 -0
- package/dist/src/db-retry.js.map +1 -0
- package/dist/src/embedding-client.d.ts +13 -0
- package/dist/src/embedding-client.d.ts.map +1 -0
- package/dist/src/embedding-client.js +79 -0
- package/dist/src/embedding-client.js.map +1 -0
- package/dist/src/graph-model.d.ts +49 -0
- package/dist/src/graph-model.d.ts.map +1 -0
- package/dist/src/graph-model.js +112 -0
- package/dist/src/graph-model.js.map +1 -0
- package/dist/src/graph-queries.d.ts +83 -0
- package/dist/src/graph-queries.d.ts.map +1 -0
- package/dist/src/graph-queries.js +727 -0
- package/dist/src/graph-queries.js.map +1 -0
- package/dist/src/graph-store.d.ts +57 -0
- package/dist/src/graph-store.d.ts.map +1 -0
- package/dist/src/graph-store.js +327 -0
- package/dist/src/graph-store.js.map +1 -0
- package/dist/src/horizon-migrator.d.ts +49 -0
- package/dist/src/horizon-migrator.d.ts.map +1 -0
- package/dist/src/horizon-migrator.js +231 -0
- package/dist/src/horizon-migrator.js.map +1 -0
- package/dist/src/horizon-store.d.ts +116 -0
- package/dist/src/horizon-store.d.ts.map +1 -0
- package/dist/src/horizon-store.js +563 -0
- package/dist/src/horizon-store.js.map +1 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +21 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/preconditions.d.ts +16 -0
- package/dist/src/preconditions.d.ts.map +1 -0
- package/dist/src/preconditions.js +99 -0
- package/dist/src/preconditions.js.map +1 -0
- package/dist/src/query-builder.d.ts +37 -0
- package/dist/src/query-builder.d.ts.map +1 -0
- package/dist/src/query-builder.js +82 -0
- package/dist/src/query-builder.js.map +1 -0
- package/dist/src/sql-util.d.ts +20 -0
- package/dist/src/sql-util.d.ts.map +1 -0
- package/dist/src/sql-util.js +40 -0
- package/dist/src/sql-util.js.map +1 -0
- package/dist/src/types.d.ts +16 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +35 -0
- package/dist/src/types.js.map +1 -0
- package/migrations/0001_facts_table.sql +77 -0
- package/migrations/0002_indexes.sql +27 -0
- package/migrations/0003_age_bootstrap.sql +13 -0
- package/migrations/0004_facts_procs.sql +253 -0
- package/migrations/0005_embedder_workflow.sql +119 -0
- package/migrations/0006_facts_read_uncrawled_embedded_gate.sql +33 -0
- package/migrations/0007_embed_key_value_text.sql +122 -0
- package/migrations/0008_embedder_failure_recovery.sql +409 -0
- package/migrations/0009_search_text_full_value.sql +52 -0
- package/migrations/0010_minimal_two_loop_embedder.sql +392 -0
- package/migrations/0011_unified_api_embedder_workflow.sql +273 -0
- package/migrations/0012_facts_soft_delete.sql +587 -0
- package/migrations/0013_graph_namespaces.sql +60 -0
- package/package.json +69 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
// pilotswarm-horizon-store — typed Cypher layer (AGE).
|
|
2
|
+
//
|
|
3
|
+
// ALL graph access goes through this module (03-design §1: graph access is a
|
|
4
|
+
// typed Cypher layer in TypeScript, not plpgsql-wrapped Cypher). The data
|
|
5
|
+
// model (03-design §2.2):
|
|
6
|
+
//
|
|
7
|
+
// (:GraphNode {node_key, kind, name, aliases, namespace, created_by})
|
|
8
|
+
// -[:REL {predicate, predicate_key, confidence, observations,
|
|
9
|
+
// evidence[], asserted_by[], namespace, model}]-> (:GraphNode)
|
|
10
|
+
// -[:EVIDENCED_BY]-> (:Fact {scope_key})
|
|
11
|
+
//
|
|
12
|
+
// Evidence has two physical forms: a NODE's evidence is a real EVIDENCED_BY
|
|
13
|
+
// edge to a content-free :Fact anchor (traversable — the seed pivot matches
|
|
14
|
+
// it); a REL edge's evidence is a property array (property graphs have no
|
|
15
|
+
// edge-to-edge links). Both surface as `evidence: string[]`, ACL-filtered to
|
|
16
|
+
// the caller at result assembly (01 §6.1a). Inaccessible fact SEEDS are
|
|
17
|
+
// ignored (treated as unknown).
|
|
18
|
+
//
|
|
19
|
+
// AGE quirks encapsulated here: no parameterized cypher (escaped literals via
|
|
20
|
+
// sql-util), no `any(x IN ...)` predicate (list comprehension instead), no
|
|
21
|
+
// startNode/endNode/UNWIND on var-length paths (edges re-matched over the
|
|
22
|
+
// reachable key set).
|
|
23
|
+
import { scopeKeyAccessible } from "./types.js";
|
|
24
|
+
import { cypherStr, cypherNum, cypherStrList } from "./sql-util.js";
|
|
25
|
+
import { withDbRetry, isLabelCreationRaceError } from "./db-retry.js";
|
|
26
|
+
import { nodeKeyOf, predicateKey as makePredicateKey, mergeAliases, decideEdgeUpsert, reinforceConfidence, clamp01 } from "./graph-model.js";
|
|
27
|
+
const FACT_SEED = /^(shared|session):/;
|
|
28
|
+
/**
|
|
29
|
+
* Reserved namespace token for the unscoped partition (graph-fact-search
|
|
30
|
+
* enhancements). `default` is NOT a literal namespace: it maps to "no namespace"
|
|
31
|
+
* (NULL) on writes and to `namespace IS NULL` on reads. Matched case-insensitively
|
|
32
|
+
* as a whole token, so a real corpus like `corpus/default-config` is unaffected.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_GRAPH_NAMESPACE = "default";
|
|
35
|
+
function isDefaultNamespaceToken(namespace) {
|
|
36
|
+
return namespace?.trim().replace(/\/+$/g, "").toLowerCase() === DEFAULT_GRAPH_NAMESPACE;
|
|
37
|
+
}
|
|
38
|
+
function normalizeNamespace(namespace) {
|
|
39
|
+
const clean = namespace?.trim().replace(/\/+$/g, "") ?? "";
|
|
40
|
+
if (clean.length === 0)
|
|
41
|
+
return null;
|
|
42
|
+
// `default` is the reserved unscoped/NULL partition, never a stored namespace.
|
|
43
|
+
if (clean.toLowerCase() === DEFAULT_GRAPH_NAMESPACE)
|
|
44
|
+
return null;
|
|
45
|
+
return clean;
|
|
46
|
+
}
|
|
47
|
+
function namespacePredicate(alias, namespace) {
|
|
48
|
+
if (isDefaultNamespaceToken(namespace))
|
|
49
|
+
return `${alias}.namespace IS NULL`;
|
|
50
|
+
const ns = normalizeNamespace(namespace);
|
|
51
|
+
if (!ns)
|
|
52
|
+
return null;
|
|
53
|
+
return `(${alias}.namespace = ${cypherStr(ns)} OR ${alias}.namespace STARTS WITH ${cypherStr(`${ns}/`)})`;
|
|
54
|
+
}
|
|
55
|
+
function namespaceMatches(value, namespace) {
|
|
56
|
+
if (isDefaultNamespaceToken(namespace)) {
|
|
57
|
+
// default == unscoped: the record matches only when it has no namespace.
|
|
58
|
+
return value == null || (typeof value === "string" && normalizeNamespace(value) === null);
|
|
59
|
+
}
|
|
60
|
+
const ns = normalizeNamespace(namespace);
|
|
61
|
+
if (!ns)
|
|
62
|
+
return true;
|
|
63
|
+
const current = typeof value === "string" ? normalizeNamespace(value) : null;
|
|
64
|
+
return current === ns || current?.startsWith(`${ns}/`) === true;
|
|
65
|
+
}
|
|
66
|
+
function namespaceProp(namespace) {
|
|
67
|
+
const ns = normalizeNamespace(namespace);
|
|
68
|
+
return ns ? `, namespace: ${cypherStr(ns)}` : "";
|
|
69
|
+
}
|
|
70
|
+
function namespaceSet(alias, namespace) {
|
|
71
|
+
// A PROVIDED-but-unscoped namespace (`default` / empty / whitespace) clears
|
|
72
|
+
// the property, re-homing the record into the NULL/default partition. An
|
|
73
|
+
// OMITTED namespace (undefined) leaves the existing property untouched.
|
|
74
|
+
if (namespace !== undefined && normalizeNamespace(namespace) === null) {
|
|
75
|
+
return `, ${alias}.namespace = NULL`;
|
|
76
|
+
}
|
|
77
|
+
const ns = normalizeNamespace(namespace);
|
|
78
|
+
return ns ? `, ${alias}.namespace = ${cypherStr(ns)}` : "";
|
|
79
|
+
}
|
|
80
|
+
function edgeNamespacePredicate(namespace) {
|
|
81
|
+
if (isDefaultNamespaceToken(namespace)) {
|
|
82
|
+
// Mirror the concrete edge semantics (edge OR either endpoint) for the
|
|
83
|
+
// unscoped partition: match when the edge or either endpoint is NULL.
|
|
84
|
+
return `(r.namespace IS NULL OR a.namespace IS NULL OR b.namespace IS NULL)`;
|
|
85
|
+
}
|
|
86
|
+
const ns = normalizeNamespace(namespace);
|
|
87
|
+
if (!ns)
|
|
88
|
+
return null;
|
|
89
|
+
const exact = cypherStr(ns);
|
|
90
|
+
const prefix = cypherStr(`${ns}/`);
|
|
91
|
+
return `(` +
|
|
92
|
+
`r.namespace = ${exact} OR r.namespace STARTS WITH ${prefix} OR ` +
|
|
93
|
+
`a.namespace = ${exact} OR a.namespace STARTS WITH ${prefix} OR ` +
|
|
94
|
+
`b.namespace = ${exact} OR b.namespace STARTS WITH ${prefix}` +
|
|
95
|
+
`)`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Load the AGE shared library for this session. On managed Postgres where age
|
|
99
|
+
* is preloaded via shared_preload_libraries, an explicit LOAD is rejected with
|
|
100
|
+
* `access to library "age" is not allowed` — tolerated, since preloaded means
|
|
101
|
+
* there is nothing to do.
|
|
102
|
+
*/
|
|
103
|
+
export async function prepareAgeSession(client) {
|
|
104
|
+
try {
|
|
105
|
+
await client.query(`LOAD 'age'`);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const msg = String(err?.message ?? "");
|
|
109
|
+
if (!/access to library "age" is not allowed/i.test(msg))
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
await client.query(`SET search_path = ag_catalog, "$user", public`);
|
|
113
|
+
}
|
|
114
|
+
/** Parse a scalar agtype column value into a JS value. */
|
|
115
|
+
export function ag(v) {
|
|
116
|
+
if (v == null)
|
|
117
|
+
return v;
|
|
118
|
+
if (typeof v !== "string")
|
|
119
|
+
return v;
|
|
120
|
+
const stripped = v.replace(/::[a-z]+$/i, "");
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(stripped);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return stripped;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Parse an agtype array column into string[]. */
|
|
129
|
+
export function agArr(v) {
|
|
130
|
+
const parsed = ag(v);
|
|
131
|
+
return Array.isArray(parsed) ? parsed.map(String) : [];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Typed Cypher layer (AGE). Provides the GraphStore READ/WRITE method bodies;
|
|
135
|
+
* `HorizonDBGraphStore` wraps this with the lifecycle (initialize/close) to form
|
|
136
|
+
* the full `GraphStore` provider (07 D2). This inner class deliberately does NOT
|
|
137
|
+
* implement the lifecycle, so it is not itself a `GraphStore`.
|
|
138
|
+
*/
|
|
139
|
+
export class GraphQueries {
|
|
140
|
+
pool;
|
|
141
|
+
graphName;
|
|
142
|
+
// Physical connections whose AGE session is already prepared (LOAD age +
|
|
143
|
+
// search_path). Keyed on the pg client object, which the pool reuses per
|
|
144
|
+
// physical connection — so the (otherwise per-checkout) setup runs ONCE per
|
|
145
|
+
// connection instead of on every graph op. On a remote cluster that removes
|
|
146
|
+
// ~2 round trips (a wasted LOAD attempt + SET) from every upsert. A reset
|
|
147
|
+
// connection yields a NEW client object → re-prepared automatically, and a
|
|
148
|
+
// stale-but-cached connection self-heals: its query fails transiently,
|
|
149
|
+
// withDbRetry re-acquires a fresh (unprepared) client, and we prepare that.
|
|
150
|
+
prepared = new WeakSet();
|
|
151
|
+
constructor(pool, graphName) {
|
|
152
|
+
this.pool = pool;
|
|
153
|
+
this.graphName = graphName;
|
|
154
|
+
}
|
|
155
|
+
async withAge(fn) {
|
|
156
|
+
// Retry the whole acquire+run on transient HorizonDB CONNECTION drops
|
|
157
|
+
// (needs a fresh client). The AGE label-creation race is handled one
|
|
158
|
+
// level down in cypher() — that one can retry on the same client.
|
|
159
|
+
return withDbRetry("graph_cypher", async () => {
|
|
160
|
+
const client = await this.pool.connect();
|
|
161
|
+
try {
|
|
162
|
+
if (!this.prepared.has(client)) {
|
|
163
|
+
await prepareAgeSession(client);
|
|
164
|
+
this.prepared.add(client);
|
|
165
|
+
}
|
|
166
|
+
return await fn(client);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
client.release();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async cypher(client, query, columns) {
|
|
174
|
+
// AGE requires a non-empty column list even for write-only queries
|
|
175
|
+
// (DELETE/DETACH DELETE return no rows) — use a dummy column then.
|
|
176
|
+
const colDefs = (columns.length > 0 ? columns : ["_ok"]).map((c) => `${c} agtype`).join(", ");
|
|
177
|
+
// Wrap the generated Cypher in a dollar-quoted SQL literal whose tag is
|
|
178
|
+
// GUARANTEED absent from the query. cypherStr() escapes quotes/backslashes
|
|
179
|
+
// INSIDE Cypher string literals, but it does not (and cannot) escape the
|
|
180
|
+
// SQL `$$` delimiter — a value containing `$$` would otherwise terminate
|
|
181
|
+
// a fixed `$$ ... $$` wrapper early and inject SQL. A computed tag closes
|
|
182
|
+
// that hole.
|
|
183
|
+
let tag = "$cy$";
|
|
184
|
+
if (query.includes(tag)) {
|
|
185
|
+
let i = 0;
|
|
186
|
+
do {
|
|
187
|
+
tag = `$cy${i++}$`;
|
|
188
|
+
} while (query.includes(tag));
|
|
189
|
+
}
|
|
190
|
+
const sql = `SELECT * FROM cypher(${cypherStr(this.graphName)}, ${tag} ${query} ${tag}) AS (${colDefs})`;
|
|
191
|
+
// AGE creates a label's backing table LAZILY on the first CREATE/MERGE
|
|
192
|
+
// that references it. Concurrent first-references to the same label race
|
|
193
|
+
// that internal CREATE TABLE; the loser aborts the whole statement
|
|
194
|
+
// (nothing persisted) with EITHER 42P07 `relation "<label>" already
|
|
195
|
+
// exists` OR 23505 on a pg_catalog index (e.g. pg_class_relname_nsp_index).
|
|
196
|
+
// The label now EXISTS, so re-running the SAME statement on the same
|
|
197
|
+
// client succeeds (each cypher call is its own autocommit statement, so a
|
|
198
|
+
// prior failure does not poison the connection). We only retry this
|
|
199
|
+
// label-race class — every other error surfaces. Label-agnostic on
|
|
200
|
+
// purpose: labels are owned by the layer above us.
|
|
201
|
+
const { rows } = await withDbRetry("graph_label_race", () => client.query(sql), { isRetryable: isLabelCreationRaceError });
|
|
202
|
+
return rows;
|
|
203
|
+
}
|
|
204
|
+
// ─── reads ────────────────────────────────────────────────────────────────
|
|
205
|
+
async searchGraphNodes(q, access) {
|
|
206
|
+
const limit = Math.max(1, Math.min(Math.trunc(q.limit ?? 50), 500));
|
|
207
|
+
const depth = Math.max(1, Math.min(Math.trunc(q.depth ?? 1), 5));
|
|
208
|
+
return this.withAge(async (c) => {
|
|
209
|
+
const found = new Map();
|
|
210
|
+
const collect = (rows) => {
|
|
211
|
+
for (const r of rows) {
|
|
212
|
+
const nodeKey = ag(r.node_key);
|
|
213
|
+
if (!found.has(nodeKey)) {
|
|
214
|
+
const namespace = ag(r.namespace);
|
|
215
|
+
found.set(nodeKey, {
|
|
216
|
+
nodeKey,
|
|
217
|
+
kind: ag(r.kind),
|
|
218
|
+
name: ag(r.name),
|
|
219
|
+
...(typeof namespace === "string" ? { namespace } : {}),
|
|
220
|
+
aliases: agArr(r.aliases),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const RET = `RETURN DISTINCT n.node_key, n.kind, n.name, n.namespace, n.aliases`;
|
|
226
|
+
const COLS = ["node_key", "kind", "name", "namespace", "aliases"];
|
|
227
|
+
const seeds = q.seeds ?? [];
|
|
228
|
+
if (seeds.length > 0) {
|
|
229
|
+
// Inaccessible fact seeds are IGNORED — byte-identical to unknown
|
|
230
|
+
// seeds, so seeding cannot probe private facts (01 §6.5).
|
|
231
|
+
const factSeeds = seeds.filter((s) => FACT_SEED.test(s) && scopeKeyAccessible(s, access));
|
|
232
|
+
const nodeSeeds = seeds.filter((s) => !FACT_SEED.test(s));
|
|
233
|
+
const baseKeys = new Set();
|
|
234
|
+
if (factSeeds.length > 0) {
|
|
235
|
+
const rows = await this.cypher(c, `MATCH (f:Fact)<-[:EVIDENCED_BY]-(n:GraphNode)
|
|
236
|
+
WHERE f.scope_key IN ${cypherStrList(factSeeds)} ${RET}`, COLS);
|
|
237
|
+
collect(rows);
|
|
238
|
+
for (const r of rows)
|
|
239
|
+
baseKeys.add(ag(r.node_key));
|
|
240
|
+
}
|
|
241
|
+
if (nodeSeeds.length > 0) {
|
|
242
|
+
const rows = await this.cypher(c, `MATCH (n:GraphNode) WHERE n.node_key IN ${cypherStrList(nodeSeeds)} ${RET}`, COLS);
|
|
243
|
+
collect(rows);
|
|
244
|
+
for (const r of rows)
|
|
245
|
+
baseKeys.add(ag(r.node_key));
|
|
246
|
+
}
|
|
247
|
+
if (baseKeys.size > 0 && depth >= 1) {
|
|
248
|
+
const rows = await this.cypher(c, `MATCH (s:GraphNode)-[:REL*1..${depth}]-(n:GraphNode)
|
|
249
|
+
WHERE s.node_key IN ${cypherStrList([...baseKeys])} ${RET}`, COLS);
|
|
250
|
+
collect(rows);
|
|
251
|
+
}
|
|
252
|
+
// kind/nameLike/namespace combine as post-filters when seeds anchor the query.
|
|
253
|
+
if (q.kind || q.nameLike || q.namespace) {
|
|
254
|
+
const kindNorm = q.kind;
|
|
255
|
+
const pat = q.nameLike?.toLowerCase();
|
|
256
|
+
for (const [k, n] of [...found]) {
|
|
257
|
+
if (kindNorm && n.kind !== kindNorm) {
|
|
258
|
+
found.delete(k);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (!namespaceMatches(n.namespace, q.namespace)) {
|
|
262
|
+
found.delete(k);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (pat) {
|
|
266
|
+
const hay = [n.name, ...n.aliases].map((x) => x.toLowerCase());
|
|
267
|
+
if (!hay.some((x) => x.includes(pat)))
|
|
268
|
+
found.delete(k);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const filters = [];
|
|
275
|
+
if (q.kind)
|
|
276
|
+
filters.push(`n.kind = ${cypherStr(q.kind)}`);
|
|
277
|
+
const ns = namespacePredicate("n", q.namespace);
|
|
278
|
+
if (ns)
|
|
279
|
+
filters.push(ns);
|
|
280
|
+
if (q.nameLike) {
|
|
281
|
+
const pat = cypherStr(q.nameLike.toLowerCase());
|
|
282
|
+
// AGE lacks `any(x IN list WHERE ...)`; use list comprehension.
|
|
283
|
+
filters.push(`(toLower(n.name) CONTAINS ${pat} OR size([a IN n.aliases WHERE toLower(a) CONTAINS ${pat}]) > 0)`);
|
|
284
|
+
}
|
|
285
|
+
const where = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
|
|
286
|
+
collect(await this.cypher(c, `MATCH (n:GraphNode) ${where} ${RET} LIMIT ${cypherNum(limit)}`, COLS));
|
|
287
|
+
}
|
|
288
|
+
const hits = [...found.values()].slice(0, limit);
|
|
289
|
+
const evidence = await this.evidenceFor(c, hits.map((h) => h.nodeKey), access);
|
|
290
|
+
return hits.map((h) => ({ ...h, evidence: evidence.get(h.nodeKey) ?? [] }));
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Idempotent, duplicate-anchor-proof evidence linking. A naive
|
|
295
|
+
* `MERGE (f:Fact {scope_key}) MERGE (e)-[:EVIDENCED_BY]->(f)` is NOT safe
|
|
296
|
+
* here: AGE has no unique constraints, so concurrent upserts can race the
|
|
297
|
+
* anchor MERGE into duplicate :Fact nodes — after which a later MERGE
|
|
298
|
+
* binds EVERY duplicate and mints extra edges. Instead: skip when any
|
|
299
|
+
* edge already exists; bind exactly ONE anchor (WITH … LIMIT 1) or create
|
|
300
|
+
* it when none exists.
|
|
301
|
+
*/
|
|
302
|
+
async linkEvidenceAnchor(client, nodeKey, scopeKey) {
|
|
303
|
+
const linked = await this.cypher(client, `MATCH (e:GraphNode { node_key: ${cypherStr(nodeKey)} })-[:EVIDENCED_BY]->(f:Fact { scope_key: ${cypherStr(scopeKey)} })
|
|
304
|
+
RETURN f.scope_key LIMIT 1`, ["scope_key"]);
|
|
305
|
+
if (linked.length > 0)
|
|
306
|
+
return;
|
|
307
|
+
const anchored = await this.cypher(client, `MATCH (e:GraphNode { node_key: ${cypherStr(nodeKey)} })
|
|
308
|
+
MATCH (f:Fact { scope_key: ${cypherStr(scopeKey)} })
|
|
309
|
+
WITH e, f LIMIT 1
|
|
310
|
+
CREATE (e)-[:EVIDENCED_BY]->(f) RETURN f.scope_key`, ["scope_key"]);
|
|
311
|
+
if (anchored.length === 0) {
|
|
312
|
+
await this.cypher(client, `MATCH (e:GraphNode { node_key: ${cypherStr(nodeKey)} })
|
|
313
|
+
CREATE (f:Fact { scope_key: ${cypherStr(scopeKey)} })
|
|
314
|
+
CREATE (e)-[:EVIDENCED_BY]->(f) RETURN f.scope_key`, ["scope_key"]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** EVIDENCED_BY scopeKeys per node, ACL-filtered (01 §6.1a), deduped
|
|
318
|
+
* (duplicate anchors must never surface as duplicate evidence keys). */
|
|
319
|
+
async evidenceFor(client, nodeKeys, access) {
|
|
320
|
+
const out = new Map();
|
|
321
|
+
if (nodeKeys.length === 0)
|
|
322
|
+
return out;
|
|
323
|
+
const rows = await this.cypher(client, `MATCH (n:GraphNode)-[:EVIDENCED_BY]->(f:Fact)
|
|
324
|
+
WHERE n.node_key IN ${cypherStrList(nodeKeys)}
|
|
325
|
+
RETURN n.node_key, f.scope_key`, ["node_key", "scope_key"]);
|
|
326
|
+
for (const r of rows) {
|
|
327
|
+
const nk = ag(r.node_key);
|
|
328
|
+
const sk = ag(r.scope_key);
|
|
329
|
+
if (!scopeKeyAccessible(sk, access))
|
|
330
|
+
continue; // traversal saw it; the caller doesn't
|
|
331
|
+
const arr = out.get(nk) ?? [];
|
|
332
|
+
if (!arr.includes(sk))
|
|
333
|
+
arr.push(sk);
|
|
334
|
+
out.set(nk, arr);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
async searchGraphEdges(q, access) {
|
|
339
|
+
const filters = [];
|
|
340
|
+
const pk = q.predicateKey ?? (q.predicate ? makePredicateKey(q.predicate) : undefined);
|
|
341
|
+
if (pk)
|
|
342
|
+
filters.push(`r.predicate_key = ${cypherStr(pk)}`);
|
|
343
|
+
if (q.fromKey)
|
|
344
|
+
filters.push(`a.node_key = ${cypherStr(q.fromKey)}`);
|
|
345
|
+
if (q.toKey)
|
|
346
|
+
filters.push(`b.node_key = ${cypherStr(q.toKey)}`);
|
|
347
|
+
const ns = edgeNamespacePredicate(q.namespace);
|
|
348
|
+
if (ns)
|
|
349
|
+
filters.push(ns);
|
|
350
|
+
if (q.minConfidence != null)
|
|
351
|
+
filters.push(`r.confidence >= ${cypherNum(q.minConfidence)}`);
|
|
352
|
+
const where = filters.length ? `WHERE ${filters.join(" AND ")}` : "";
|
|
353
|
+
const limit = cypherNum(Math.max(1, Math.min(Math.trunc(q.limit ?? 50), 500)));
|
|
354
|
+
return this.withAge(async (c) => {
|
|
355
|
+
const rows = await this.cypher(c, `MATCH (a:GraphNode)-[r:REL]->(b:GraphNode) ${where}
|
|
356
|
+
RETURN a.node_key, b.node_key, r.predicate, r.predicate_key, r.namespace, r.confidence, r.observations, r.evidence
|
|
357
|
+
LIMIT ${limit}`, ["from_key", "to_key", "predicate", "predicate_key", "namespace", "confidence", "observations", "evidence"]);
|
|
358
|
+
return rows.map((r) => ({
|
|
359
|
+
fromKey: ag(r.from_key), toKey: ag(r.to_key), predicate: ag(r.predicate),
|
|
360
|
+
predicateKey: ag(r.predicate_key), namespace: ag(r.namespace) ?? undefined,
|
|
361
|
+
confidence: Number(ag(r.confidence)),
|
|
362
|
+
observations: Number(ag(r.observations)),
|
|
363
|
+
evidence: agArr(r.evidence).filter((sk) => scopeKeyAccessible(sk, access)),
|
|
364
|
+
}));
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
async graphNeighbourhood(nodeKey, depth, _access, opts = {}) {
|
|
368
|
+
const d = Math.max(1, Math.min(Math.trunc(depth), 5));
|
|
369
|
+
return this.withAge(async (c) => {
|
|
370
|
+
const ns = namespacePredicate("n", opts.namespace);
|
|
371
|
+
const nodeRows = await this.cypher(c, `MATCH (e:GraphNode { node_key: ${cypherStr(nodeKey)} })-[:REL*1..${d}]-(n:GraphNode)
|
|
372
|
+
${ns ? `WHERE ${ns}` : ""}
|
|
373
|
+
RETURN DISTINCT n.node_key, n.kind, n.name, n.namespace`, ["node_key", "kind", "name", "namespace"]);
|
|
374
|
+
const nodes = nodeRows.map((r) => {
|
|
375
|
+
const namespace = ag(r.namespace);
|
|
376
|
+
return {
|
|
377
|
+
nodeKey: ag(r.node_key),
|
|
378
|
+
kind: ag(r.kind),
|
|
379
|
+
name: ag(r.name),
|
|
380
|
+
...(typeof namespace === "string" ? { namespace } : {}),
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
if (nodes.length === 0)
|
|
384
|
+
return { nodes: [], edges: [] };
|
|
385
|
+
// Edges among the reachable set (+ anchor); avoids AGE-unsupported
|
|
386
|
+
// startNode/endNode/UNWIND on variable-length paths.
|
|
387
|
+
const keys = [...new Set([nodeKey, ...nodes.map((n) => n.nodeKey)])];
|
|
388
|
+
const list = cypherStrList(keys);
|
|
389
|
+
const edgeRows = await this.cypher(c, `MATCH (a:GraphNode)-[r:REL]->(b:GraphNode)
|
|
390
|
+
WHERE a.node_key IN ${list} AND b.node_key IN ${list}
|
|
391
|
+
RETURN a.node_key, b.node_key, r.predicate, r.namespace, r.confidence`, ["from_key", "to_key", "predicate", "namespace", "confidence"]);
|
|
392
|
+
return {
|
|
393
|
+
nodes,
|
|
394
|
+
edges: edgeRows.map((r) => {
|
|
395
|
+
const namespace = ag(r.namespace);
|
|
396
|
+
return {
|
|
397
|
+
fromKey: ag(r.from_key), toKey: ag(r.to_key),
|
|
398
|
+
predicate: ag(r.predicate),
|
|
399
|
+
...(typeof namespace === "string" ? { namespace } : {}),
|
|
400
|
+
confidence: Number(ag(r.confidence)),
|
|
401
|
+
};
|
|
402
|
+
}),
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Cheap whole-graph counts for `graph_stats` (enhancedfactstore 07 P5):
|
|
408
|
+
* a single `count()` Cypher per axis instead of a client-side fan-out.
|
|
409
|
+
* AGE has no edge-only catalog, so edges are counted via a directed match.
|
|
410
|
+
*/
|
|
411
|
+
async graphStats(opts = {}) {
|
|
412
|
+
return this.withAge(async (c) => {
|
|
413
|
+
const nodeFilters = [namespacePredicate("n", opts.namespace)].filter(Boolean);
|
|
414
|
+
const nodeWhere = nodeFilters.length ? `WHERE ${nodeFilters.join(" AND ")}` : "";
|
|
415
|
+
const edgeFilters = [edgeNamespacePredicate(opts.namespace)].filter(Boolean);
|
|
416
|
+
const edgeWhere = edgeFilters.length ? `WHERE ${edgeFilters.join(" AND ")}` : "";
|
|
417
|
+
const nodeRows = await this.cypher(c, `MATCH (n:GraphNode) ${nodeWhere} RETURN count(n) AS c`, ["c"]);
|
|
418
|
+
const edgeRows = await this.cypher(c, `MATCH (a:GraphNode)-[r]->(b:GraphNode) ${edgeWhere} RETURN count(r) AS c`, ["c"]);
|
|
419
|
+
return {
|
|
420
|
+
nodeCount: Number(ag(nodeRows[0]?.c) ?? 0),
|
|
421
|
+
edgeCount: Number(ag(edgeRows[0]?.c) ?? 0),
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// ─── writes ───────────────────────────────────────────────────────────────
|
|
426
|
+
async upsertGraphNode(n) {
|
|
427
|
+
if (!n.kind?.trim() || !n.name?.trim()) {
|
|
428
|
+
throw new Error("upsertGraphNode requires non-empty kind and name");
|
|
429
|
+
}
|
|
430
|
+
if (!n.agentId)
|
|
431
|
+
throw new Error("upsertGraphNode requires agentId");
|
|
432
|
+
const key = nodeKeyOf(n.kind, n.name);
|
|
433
|
+
return this.withAge(async (c) => {
|
|
434
|
+
const existing = await this.cypher(c, `MATCH (e:GraphNode { node_key: ${cypherStr(key)} }) RETURN e.aliases, e.namespace`, ["aliases", "namespace"]);
|
|
435
|
+
const incomingAliases = mergeAliases(n.aliases ?? [], [n.name]);
|
|
436
|
+
let ref;
|
|
437
|
+
if (existing.length > 0) {
|
|
438
|
+
const merged = mergeAliases(agArr(existing[0].aliases), incomingAliases);
|
|
439
|
+
// An explicitly provided namespace wins (including `default`/empty,
|
|
440
|
+
// which normalizes to null = unscoped); only when OMITTED do we
|
|
441
|
+
// preserve the node's existing namespace.
|
|
442
|
+
const namespace = n.namespace !== undefined
|
|
443
|
+
? normalizeNamespace(n.namespace)
|
|
444
|
+
: normalizeNamespace(ag(existing[0].namespace));
|
|
445
|
+
await this.cypher(c, `MATCH (e:GraphNode { node_key: ${cypherStr(key)} })
|
|
446
|
+
SET e.aliases = ${cypherStrList(merged)}, e.updated_at = timestamp()${namespaceSet("e", n.namespace)}
|
|
447
|
+
RETURN e.node_key`, ["node_key"]);
|
|
448
|
+
ref = { nodeKey: key, kind: n.kind, name: n.name, ...(namespace ? { namespace } : {}), aliases: merged, created: false };
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
const namespace = normalizeNamespace(n.namespace);
|
|
452
|
+
await this.cypher(c, `CREATE (e:GraphNode { node_key: ${cypherStr(key)}, kind: ${cypherStr(n.kind)},
|
|
453
|
+
name: ${cypherStr(n.name)}, aliases: ${cypherStrList(incomingAliases)}${namespaceProp(n.namespace)},
|
|
454
|
+
created_by: ${cypherStr(n.agentId)} }) RETURN e.node_key`, ["node_key"]);
|
|
455
|
+
ref = { nodeKey: key, kind: n.kind, name: n.name, ...(namespace ? { namespace } : {}), aliases: incomingAliases, created: true };
|
|
456
|
+
}
|
|
457
|
+
// Node evidence = real EVIDENCED_BY edges to lazy :Fact anchors
|
|
458
|
+
// (03-design §2.2). Unioned on every upsert.
|
|
459
|
+
for (const sk of new Set(n.evidence ?? [])) {
|
|
460
|
+
await this.linkEvidenceAnchor(c, key, sk);
|
|
461
|
+
}
|
|
462
|
+
return ref;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
async upsertGraphEdge(e) {
|
|
466
|
+
if (!e.fromKey || !e.toKey)
|
|
467
|
+
throw new Error("upsertGraphEdge requires fromKey and toKey");
|
|
468
|
+
if (e.fromKey === e.toKey)
|
|
469
|
+
throw new Error("self-referential edge rejected");
|
|
470
|
+
if (!e.predicate?.trim())
|
|
471
|
+
throw new Error("upsertGraphEdge requires a predicate");
|
|
472
|
+
if (!e.agentId)
|
|
473
|
+
throw new Error("upsertGraphEdge requires agentId");
|
|
474
|
+
const conf = e.confidence ?? 1.0;
|
|
475
|
+
if (typeof conf !== "number" || conf < 0 || conf > 1)
|
|
476
|
+
throw new Error("confidence must be in [0,1]");
|
|
477
|
+
const pk = makePredicateKey(e.predicate);
|
|
478
|
+
return this.withAge(async (c) => {
|
|
479
|
+
// Both endpoints must exist (02 §6).
|
|
480
|
+
const ends = await this.cypher(c, `MATCH (n:GraphNode) WHERE n.node_key IN ${cypherStrList([e.fromKey, e.toKey])}
|
|
481
|
+
RETURN n.node_key`, ["node_key"]);
|
|
482
|
+
const present = new Set(ends.map((r) => ag(r.node_key)));
|
|
483
|
+
for (const k of [e.fromKey, e.toKey]) {
|
|
484
|
+
if (!present.has(k))
|
|
485
|
+
throw new Error(`upsertGraphEdge: endpoint node not found: ${k}`);
|
|
486
|
+
}
|
|
487
|
+
const MATCH_EDGE = `MATCH (a:GraphNode { node_key: ${cypherStr(e.fromKey)} })` +
|
|
488
|
+
`-[rel:REL { predicate_key: ${cypherStr(pk)} }]->` +
|
|
489
|
+
`(b:GraphNode { node_key: ${cypherStr(e.toKey)} })`;
|
|
490
|
+
const existingRows = await this.cypher(c, `${MATCH_EDGE} RETURN rel.confidence, rel.observations, rel.evidence, rel.namespace`, ["confidence", "observations", "evidence", "namespace"]);
|
|
491
|
+
const existing = existingRows.length > 0
|
|
492
|
+
? {
|
|
493
|
+
confidence: Number(ag(existingRows[0].confidence)),
|
|
494
|
+
observations: Number(ag(existingRows[0].observations)),
|
|
495
|
+
evidence: agArr(existingRows[0].evidence),
|
|
496
|
+
namespace: normalizeNamespace(ag(existingRows[0].namespace)),
|
|
497
|
+
}
|
|
498
|
+
: null;
|
|
499
|
+
const d = decideEdgeUpsert({ confidence: conf, evidence: e.evidence }, existing);
|
|
500
|
+
if (d.action === "create") {
|
|
501
|
+
await this.cypher(c, `MATCH (a:GraphNode { node_key: ${cypherStr(e.fromKey)} }), (b:GraphNode { node_key: ${cypherStr(e.toKey)} })
|
|
502
|
+
CREATE (a)-[rel:REL { predicate: ${cypherStr(e.predicate)}, predicate_key: ${cypherStr(pk)},
|
|
503
|
+
confidence: ${cypherNum(d.confidence)}, observations: ${cypherNum(d.observations)},
|
|
504
|
+
asserted_by: ${cypherStrList([e.agentId])}, evidence: ${cypherStrList(d.evidence)}${namespaceProp(e.namespace)},
|
|
505
|
+
model: ${cypherStr(e.model ?? "")}, first_seen: timestamp(), last_seen: timestamp() }]->(b)
|
|
506
|
+
RETURN rel.predicate_key`, ["predicate_key"]);
|
|
507
|
+
}
|
|
508
|
+
else if (d.action === "reinforce") {
|
|
509
|
+
await this.cypher(c, `${MATCH_EDGE}
|
|
510
|
+
SET rel.confidence = ${cypherNum(d.confidence)}, rel.observations = ${cypherNum(d.observations)},
|
|
511
|
+
rel.evidence = ${cypherStrList(d.evidence)}, rel.last_seen = timestamp()${namespaceSet("rel", e.namespace)}
|
|
512
|
+
RETURN rel.predicate_key`, ["predicate_key"]);
|
|
513
|
+
}
|
|
514
|
+
// action === "noop": already-known evidence only — leave untouched (GR7).
|
|
515
|
+
const namespace = normalizeNamespace(e.namespace) ?? existing?.namespace ?? undefined;
|
|
516
|
+
return {
|
|
517
|
+
fromKey: e.fromKey, toKey: e.toKey, predicate: e.predicate, predicateKey: pk,
|
|
518
|
+
...(namespace ? { namespace } : {}),
|
|
519
|
+
confidence: d.confidence, observations: d.observations,
|
|
520
|
+
reinforced: d.action === "reinforce",
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
async mergeGraphNodes(fromKey, intoKey, reason, opts = {}) {
|
|
525
|
+
await this.withAge(async (c) => {
|
|
526
|
+
const ns = namespacePredicate("e", opts.namespace);
|
|
527
|
+
const dup = await this.cypher(c, `MATCH (e:GraphNode { node_key: ${cypherStr(fromKey)} }) ${ns ? `WHERE ${ns}` : ""} RETURN e.aliases`, ["aliases"]);
|
|
528
|
+
if (dup.length === 0)
|
|
529
|
+
return; // nothing to merge
|
|
530
|
+
const survivorNs = namespacePredicate("e", opts.namespace);
|
|
531
|
+
const survivor = await this.cypher(c, `MATCH (e:GraphNode { node_key: ${cypherStr(intoKey)} }) ${survivorNs ? `WHERE ${survivorNs}` : ""} RETURN e.aliases`, ["aliases"]);
|
|
532
|
+
if (survivor.length === 0)
|
|
533
|
+
throw new Error(`mergeGraphNodes: merge target not found: ${intoKey}`);
|
|
534
|
+
// 1. Union aliases onto the survivor (+ audit note).
|
|
535
|
+
const merged = mergeAliases(agArr(survivor[0].aliases), agArr(dup[0].aliases));
|
|
536
|
+
await this.cypher(c, `MATCH (s:GraphNode { node_key: ${cypherStr(intoKey)} })
|
|
537
|
+
SET s.aliases = ${cypherStrList(merged)}, s.merged_note = ${cypherStr(reason)}
|
|
538
|
+
RETURN s.node_key`, ["node_key"]);
|
|
539
|
+
// 2. Repoint REL edges, HARDENED against duplicate triples (03 §7):
|
|
540
|
+
// when the survivor already has the same (other, predicate_key)
|
|
541
|
+
// edge, COMBINE (evidence union, observations sum, noisy-OR)
|
|
542
|
+
// instead of creating a second edge. AGE has no APOC refactor,
|
|
543
|
+
// so this is read-decide-write per edge.
|
|
544
|
+
const repoint = async (direction) => {
|
|
545
|
+
const match = direction === "out"
|
|
546
|
+
? `MATCH (d:GraphNode { node_key: ${cypherStr(fromKey)} })-[r:REL]->(t:GraphNode)`
|
|
547
|
+
: `MATCH (t:GraphNode)-[r:REL]->(d:GraphNode { node_key: ${cypherStr(fromKey)} })`;
|
|
548
|
+
const rows = await this.cypher(c, `${match} RETURN t.node_key, r.predicate, r.predicate_key, r.confidence, r.observations, r.evidence`, ["other_key", "predicate", "predicate_key", "confidence", "observations", "evidence"]);
|
|
549
|
+
for (const r of rows) {
|
|
550
|
+
const other = ag(r.other_key);
|
|
551
|
+
if (other === intoKey)
|
|
552
|
+
continue; // dup↔survivor edge: drop, never self-loop
|
|
553
|
+
const pk = ag(r.predicate_key);
|
|
554
|
+
const fromK = direction === "out" ? intoKey : other;
|
|
555
|
+
const toK = direction === "out" ? other : intoKey;
|
|
556
|
+
const ex = await this.cypher(c, `MATCH (a:GraphNode { node_key: ${cypherStr(fromK)} })-[rel:REL { predicate_key: ${cypherStr(pk)} }]->(b:GraphNode { node_key: ${cypherStr(toK)} })
|
|
557
|
+
RETURN rel.confidence, rel.observations, rel.evidence`, ["confidence", "observations", "evidence"]);
|
|
558
|
+
if (ex.length > 0) {
|
|
559
|
+
// Evidence-aware combine (same principle as GR7): when the
|
|
560
|
+
// duplicate edge brings NO novel evidence, the survivor
|
|
561
|
+
// already absorbed it — drop the dup edge without summing
|
|
562
|
+
// observations / noisy-OR-ing again, so a replayed merge
|
|
563
|
+
// cannot double-count. Evidence-less duplicates can't be
|
|
564
|
+
// deduped and still combine.
|
|
565
|
+
const exEv = agArr(ex[0].evidence);
|
|
566
|
+
const dupEv = agArr(r.evidence);
|
|
567
|
+
const known = new Set(exEv);
|
|
568
|
+
const novel = dupEv.filter((e) => !known.has(e));
|
|
569
|
+
if (dupEv.length > 0 && novel.length === 0)
|
|
570
|
+
continue;
|
|
571
|
+
const conf = reinforceConfidence(Number(ag(ex[0].confidence)), Number(ag(r.confidence)));
|
|
572
|
+
const obs = Number(ag(ex[0].observations)) + Number(ag(r.observations));
|
|
573
|
+
const ev = [...exEv, ...novel];
|
|
574
|
+
await this.cypher(c, `MATCH (a:GraphNode { node_key: ${cypherStr(fromK)} })-[rel:REL { predicate_key: ${cypherStr(pk)} }]->(b:GraphNode { node_key: ${cypherStr(toK)} })
|
|
575
|
+
SET rel.confidence = ${cypherNum(clamp01(conf))}, rel.observations = ${cypherNum(obs)},
|
|
576
|
+
rel.evidence = ${cypherStrList(ev)}
|
|
577
|
+
RETURN rel.predicate_key`, ["predicate_key"]);
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
await this.cypher(c, `MATCH (a:GraphNode { node_key: ${cypherStr(fromK)} }), (b:GraphNode { node_key: ${cypherStr(toK)} })
|
|
581
|
+
CREATE (a)-[rel:REL { predicate: ${cypherStr(ag(r.predicate))}, predicate_key: ${cypherStr(pk)},
|
|
582
|
+
confidence: ${cypherNum(Number(ag(r.confidence)))}, observations: ${cypherNum(Number(ag(r.observations)))},
|
|
583
|
+
evidence: ${cypherStrList(agArr(r.evidence))} }]->(b)
|
|
584
|
+
RETURN rel.predicate_key`, ["predicate_key"]);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
await repoint("out");
|
|
589
|
+
await repoint("in");
|
|
590
|
+
// 3. Repoint node evidence (EVIDENCED_BY anchors) onto the survivor.
|
|
591
|
+
const anchors = await this.cypher(c, `MATCH (d:GraphNode { node_key: ${cypherStr(fromKey)} })-[:EVIDENCED_BY]->(f:Fact)
|
|
592
|
+
RETURN f.scope_key`, ["scope_key"]);
|
|
593
|
+
for (const r of anchors) {
|
|
594
|
+
await this.linkEvidenceAnchor(c, intoKey, ag(r.scope_key));
|
|
595
|
+
}
|
|
596
|
+
// 4. Remove the duplicate (and all its remaining edges).
|
|
597
|
+
await this.cypher(c, `MATCH (d:GraphNode { node_key: ${cypherStr(fromKey)} }) DETACH DELETE d`, []);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
async deleteGraphNode(nodeKey, opts = {}) {
|
|
601
|
+
return this.withAge(async (c) => {
|
|
602
|
+
const ns = namespacePredicate("e", opts.namespace);
|
|
603
|
+
const exists = await this.cypher(c, `MATCH (e:GraphNode { node_key: ${cypherStr(nodeKey)} }) ${ns ? `WHERE ${ns}` : ""} RETURN e.node_key`, ["node_key"]);
|
|
604
|
+
if (exists.length === 0)
|
|
605
|
+
return false;
|
|
606
|
+
await this.cypher(c, `MATCH (e:GraphNode { node_key: ${cypherStr(nodeKey)} }) ${ns ? `WHERE ${ns}` : ""} DETACH DELETE e`, []);
|
|
607
|
+
return true;
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
async deleteGraphEdge(fromKey, toKey, predicateKey, opts = {}) {
|
|
611
|
+
return this.withAge(async (c) => {
|
|
612
|
+
const MATCH = `MATCH (a:GraphNode { node_key: ${cypherStr(fromKey)} })` +
|
|
613
|
+
`-[r:REL { predicate_key: ${cypherStr(predicateKey)} }]->` +
|
|
614
|
+
`(b:GraphNode { node_key: ${cypherStr(toKey)} })`;
|
|
615
|
+
const ns = edgeNamespacePredicate(opts.namespace);
|
|
616
|
+
const where = ns ? ` WHERE ${ns}` : "";
|
|
617
|
+
const exists = await this.cypher(c, `${MATCH}${where} RETURN r.predicate_key`, ["predicate_key"]);
|
|
618
|
+
if (exists.length === 0)
|
|
619
|
+
return false;
|
|
620
|
+
await this.cypher(c, `${MATCH}${where} DELETE r`, []);
|
|
621
|
+
return true;
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
async removeGraphEvidence(scopeKey, opts = {}) {
|
|
625
|
+
if (!scopeKey?.trim())
|
|
626
|
+
throw new Error("removeGraphEvidence requires scopeKey");
|
|
627
|
+
return this.withAge(async (c) => {
|
|
628
|
+
let nodeEvidenceRemoved = 0;
|
|
629
|
+
let edgeEvidenceRemoved = 0;
|
|
630
|
+
let nodesDeleted = 0;
|
|
631
|
+
let edgesDeleted = 0;
|
|
632
|
+
const nodeNs = namespacePredicate("n", opts.namespace);
|
|
633
|
+
const nodeRows = await this.cypher(c, `MATCH (n:GraphNode)-[:EVIDENCED_BY]->(f:Fact { scope_key: ${cypherStr(scopeKey)} })
|
|
634
|
+
${nodeNs ? `WHERE ${nodeNs}` : ""}
|
|
635
|
+
RETURN DISTINCT n.node_key`, ["node_key"]);
|
|
636
|
+
const nodeKeys = [...new Set(nodeRows.map((r) => ag(r.node_key)).filter(Boolean))];
|
|
637
|
+
if (nodeKeys.length > 0) {
|
|
638
|
+
const removed = await this.cypher(c, `MATCH (n:GraphNode)-[ev:EVIDENCED_BY]->(f:Fact { scope_key: ${cypherStr(scopeKey)} })
|
|
639
|
+
WHERE n.node_key IN ${cypherStrList(nodeKeys)}
|
|
640
|
+
DELETE ev
|
|
641
|
+
RETURN count(ev) AS c`, ["c"]);
|
|
642
|
+
nodeEvidenceRemoved = Number(ag(removed[0]?.c) ?? 0);
|
|
643
|
+
}
|
|
644
|
+
const edgeNs = edgeNamespacePredicate(opts.namespace);
|
|
645
|
+
const edgeRows = await this.cypher(c, `MATCH (a:GraphNode)-[r:REL]->(b:GraphNode)
|
|
646
|
+
WHERE ${edgeNs ? `${edgeNs} AND ` : ""}${cypherStr(scopeKey)} IN r.evidence
|
|
647
|
+
RETURN a.node_key, b.node_key, r.predicate_key, r.evidence`, ["from_key", "to_key", "predicate_key", "evidence"]);
|
|
648
|
+
for (const row of edgeRows) {
|
|
649
|
+
const fromKey = ag(row.from_key);
|
|
650
|
+
const toKey = ag(row.to_key);
|
|
651
|
+
const predicateKey = ag(row.predicate_key);
|
|
652
|
+
const evidence = agArr(row.evidence);
|
|
653
|
+
const nextEvidence = evidence.filter((sk) => sk !== scopeKey);
|
|
654
|
+
edgeEvidenceRemoved += evidence.length - nextEvidence.length;
|
|
655
|
+
if (nextEvidence.length === 0) {
|
|
656
|
+
const ns = edgeNamespacePredicate(opts.namespace);
|
|
657
|
+
await this.cypher(c, `MATCH (a:GraphNode { node_key: ${cypherStr(fromKey)} })-[r:REL { predicate_key: ${cypherStr(predicateKey)} }]->(b:GraphNode { node_key: ${cypherStr(toKey)} })
|
|
658
|
+
${ns ? `WHERE ${ns}` : ""}
|
|
659
|
+
DELETE r`, []);
|
|
660
|
+
edgesDeleted += 1;
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
await this.cypher(c, `MATCH (a:GraphNode { node_key: ${cypherStr(fromKey)} })-[r:REL { predicate_key: ${cypherStr(predicateKey)} }]->(b:GraphNode { node_key: ${cypherStr(toKey)} })
|
|
664
|
+
SET r.evidence = ${cypherStrList(nextEvidence)}
|
|
665
|
+
RETURN r.predicate_key`, ["predicate_key"]);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
for (const nodeKey of nodeKeys) {
|
|
669
|
+
// Only delete a node that is now FULLY orphaned. A node can lose its
|
|
670
|
+
// own EVIDENCED_BY anchors while still being an endpoint of edges that
|
|
671
|
+
// other live facts evidence — a bare DETACH DELETE would silently
|
|
672
|
+
// destroy those still-evidenced edges (H2). The edge pass above has
|
|
673
|
+
// already removed edges that became evidence-less, so any REL edge
|
|
674
|
+
// that survives here is still validly evidenced: keep the node.
|
|
675
|
+
const remaining = await this.cypher(c, `MATCH (n:GraphNode { node_key: ${cypherStr(nodeKey)} })-[:EVIDENCED_BY]->(f:Fact)
|
|
676
|
+
RETURN f.scope_key LIMIT 1`, ["scope_key"]);
|
|
677
|
+
if (remaining.length > 0)
|
|
678
|
+
continue;
|
|
679
|
+
const stillLinked = await this.cypher(c, `MATCH (n:GraphNode { node_key: ${cypherStr(nodeKey)} })-[r:REL]-()
|
|
680
|
+
RETURN r.predicate_key LIMIT 1`, ["predicate_key"]);
|
|
681
|
+
if (stillLinked.length > 0)
|
|
682
|
+
continue;
|
|
683
|
+
await this.cypher(c, `MATCH (n:GraphNode { node_key: ${cypherStr(nodeKey)} }) DETACH DELETE n`, []);
|
|
684
|
+
nodesDeleted += 1;
|
|
685
|
+
}
|
|
686
|
+
return { scopeKey, nodeEvidenceRemoved, edgeEvidenceRemoved, nodesDeleted, edgesDeleted };
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Drop graph data for an EXACT namespace (graph-fact-search enhancements,
|
|
691
|
+
* `deleteGraphNamespace`). NOT subtree: only records whose own namespace
|
|
692
|
+
* equals `namespace` are removed — child namespaces are deleted explicitly.
|
|
693
|
+
* Edges with this namespace are removed first; then nodes with this
|
|
694
|
+
* namespace are DETACH DELETEd (which also clears their remaining edges and
|
|
695
|
+
* EVIDENCED_BY anchors). Idempotent / re-runnable. Refuses the reserved
|
|
696
|
+
* `default` token / empty (the NULL partition is never bulk-deleted).
|
|
697
|
+
*/
|
|
698
|
+
async deleteNamespaceData(namespace) {
|
|
699
|
+
const ns = normalizeNamespace(namespace);
|
|
700
|
+
if (!ns) {
|
|
701
|
+
throw new Error("deleteNamespaceData requires a concrete namespace (not 'default' or empty)");
|
|
702
|
+
}
|
|
703
|
+
const exact = cypherStr(ns);
|
|
704
|
+
return this.withAge(async (c) => {
|
|
705
|
+
// Edges removed = edges whose OWN namespace is exactly this namespace
|
|
706
|
+
// OR edges incident to a node in this namespace (those vanish when the
|
|
707
|
+
// node is DETACH DELETEd). Counting both keeps edgesDeleted honest:
|
|
708
|
+
// deleting a namespace's nodes also drops edges that reach into other
|
|
709
|
+
// namespaces — an unavoidable consequence of removing an endpoint.
|
|
710
|
+
const edgeMatch = `MATCH (a:GraphNode)-[r:REL]->(b:GraphNode) ` +
|
|
711
|
+
`WHERE r.namespace = ${exact} OR a.namespace = ${exact} OR b.namespace = ${exact}`;
|
|
712
|
+
const edgeCountRows = await this.cypher(c, `${edgeMatch} RETURN count(r) AS c`, ["c"]);
|
|
713
|
+
const edgesDeleted = Number(ag(edgeCountRows[0]?.c) ?? 0);
|
|
714
|
+
if (edgesDeleted > 0) {
|
|
715
|
+
await this.cypher(c, `${edgeMatch} DELETE r`, []);
|
|
716
|
+
}
|
|
717
|
+
// Nodes whose namespace is EXACTLY this namespace (not subtree).
|
|
718
|
+
const nodeCountRows = await this.cypher(c, `MATCH (n:GraphNode) WHERE n.namespace = ${exact} RETURN count(n) AS c`, ["c"]);
|
|
719
|
+
const nodesDeleted = Number(ag(nodeCountRows[0]?.c) ?? 0);
|
|
720
|
+
if (nodesDeleted > 0) {
|
|
721
|
+
await this.cypher(c, `MATCH (n:GraphNode) WHERE n.namespace = ${exact} DETACH DELETE n`, []);
|
|
722
|
+
}
|
|
723
|
+
return { nodesDeleted, edgesDeleted };
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
//# sourceMappingURL=graph-queries.js.map
|