@utaba/deep-memory-storage-neo4j 0.19.0

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/dist/index.js ADDED
@@ -0,0 +1,3299 @@
1
+ // src/Neo4jStorageProvider.ts
2
+ import {
3
+ ProviderError as ProviderError7,
4
+ RepositoryNotFoundError,
5
+ createSafeSink,
6
+ matchesPropertyFilters as matchesPropertyFilters2,
7
+ projectEntity
8
+ } from "@utaba/deep-memory";
9
+
10
+ // src/Neo4jTraversalExecutor.ts
11
+ import { CypherCompiler, ProviderError as ProviderError2 } from "@utaba/deep-memory";
12
+
13
+ // src/mapping.ts
14
+ import { ProviderError } from "@utaba/deep-memory";
15
+ var MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
16
+ var MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
17
+ function bigintToSafeNumber(value) {
18
+ if (typeof value === "number") return value;
19
+ if (typeof value === "bigint") {
20
+ if (value > MAX_SAFE_BIGINT || value < MIN_SAFE_BIGINT) {
21
+ throw new ProviderError(
22
+ `Neo4j integer value ${value.toString()} exceeds Number.MAX_SAFE_INTEGER \u2014 refusing to truncate.`
23
+ );
24
+ }
25
+ return Number(value);
26
+ }
27
+ throw new ProviderError(
28
+ `bigintToSafeNumber expected bigint | number, received ${typeof value}.`
29
+ );
30
+ }
31
+ function entityFromRecord(record, alias = "n") {
32
+ return entityFromProperties(extractPropertyBag(record, alias));
33
+ }
34
+ function relationshipFromRecord(record, alias = "r") {
35
+ return relationshipFromProperties(extractPropertyBag(record, alias));
36
+ }
37
+ function entityFromProperties(props) {
38
+ const summary = optionalString(props, "summary");
39
+ const data = optionalString(props, "data");
40
+ const dataFormat = optionalString(props, "dataFormat");
41
+ const embedding = parseEmbedding(props);
42
+ const entity = {
43
+ id: requireString(props, "id"),
44
+ slug: requireString(props, "slug"),
45
+ entityType: requireString(props, "entityType"),
46
+ label: requireString(props, "label"),
47
+ properties: parsePropertiesBlob(props),
48
+ provenance: provenanceFromProperties(props)
49
+ };
50
+ if (summary !== void 0) entity.summary = summary;
51
+ if (data !== void 0) entity.data = data;
52
+ if (dataFormat !== void 0) entity.dataFormat = dataFormat;
53
+ if (embedding !== void 0) entity.embedding = embedding;
54
+ return entity;
55
+ }
56
+ function relationshipFromProperties(props) {
57
+ return {
58
+ id: requireString(props, "id"),
59
+ relationshipType: requireString(props, "relationshipType"),
60
+ sourceEntityId: requireString(props, "sourceEntityId"),
61
+ targetEntityId: requireString(props, "targetEntityId"),
62
+ properties: parsePropertiesBlob(props),
63
+ bidirectional: requireBoolean(props, "bidirectional"),
64
+ provenance: provenanceFromProperties(props)
65
+ };
66
+ }
67
+ function repositoryFromRecord(record, alias = "r") {
68
+ return repositoryFromProperties(extractPropertyBag(record, alias));
69
+ }
70
+ function repositorySummaryFromRecord(record, alias = "r") {
71
+ return repositorySummaryFromProperties(extractPropertyBag(record, alias));
72
+ }
73
+ function repositoryFromProperties(props) {
74
+ const type = optionalString(props, "type");
75
+ const description = optionalString(props, "description");
76
+ const legal = optionalString(props, "legal");
77
+ const owner = optionalString(props, "owner");
78
+ const metadata = parseOptionalJsonObject(props, "metadata");
79
+ const repo = {
80
+ repositoryId: requireString(props, "repositoryId"),
81
+ label: requireString(props, "label"),
82
+ governanceConfig: parseRequiredJsonObject(props, "governanceConfig"),
83
+ createdAt: requireString(props, "createdAt"),
84
+ createdBy: requireString(props, "createdBy")
85
+ };
86
+ if (type !== void 0) repo.type = type;
87
+ if (description !== void 0) repo.description = description;
88
+ if (legal !== void 0) repo.legal = legal;
89
+ if (owner !== void 0) repo.owner = owner;
90
+ if (metadata !== void 0) repo.metadata = metadata;
91
+ return repo;
92
+ }
93
+ function repositorySummaryFromProperties(props) {
94
+ const type = optionalString(props, "type");
95
+ const description = optionalString(props, "description");
96
+ const summary = {
97
+ repositoryId: requireString(props, "repositoryId"),
98
+ label: requireString(props, "label"),
99
+ governanceConfig: parseRequiredJsonObject(props, "governanceConfig")
100
+ };
101
+ if (type !== void 0) summary.type = type;
102
+ if (description !== void 0) summary.description = description;
103
+ return summary;
104
+ }
105
+ function changeRecordFromRecord(record, alias = "e") {
106
+ return changeRecordFromProperties(extractPropertyBag(record, alias));
107
+ }
108
+ function changeRecordFromProperties(props) {
109
+ const previousVersion = optionalString(props, "previousVersion");
110
+ const approvedBy = optionalString(props, "approvedBy");
111
+ const approvedAt = optionalString(props, "approvedAt");
112
+ const record = {
113
+ changeId: requireString(props, "changeId"),
114
+ changeType: requireChangeType(props, "changeType"),
115
+ typeName: requireString(props, "typeName"),
116
+ newVersion: requireString(props, "newVersion"),
117
+ proposedBy: requireString(props, "proposedBy"),
118
+ proposedAt: requireString(props, "proposedAt"),
119
+ reason: requireString(props, "reason")
120
+ };
121
+ if (previousVersion !== void 0) record.previousVersion = previousVersion;
122
+ if (approvedBy !== void 0) record.approvedBy = approvedBy;
123
+ if (approvedAt !== void 0) record.approvedAt = approvedAt;
124
+ return record;
125
+ }
126
+ var STORED_ENTITY_FIELDS = [
127
+ "id",
128
+ "entityType",
129
+ "label",
130
+ "slug",
131
+ "summary",
132
+ "properties",
133
+ "data",
134
+ "dataFormat",
135
+ "createdBy",
136
+ "createdByType",
137
+ "createdAt",
138
+ "createdInConversation",
139
+ "createdFromMessage",
140
+ "modifiedBy",
141
+ "modifiedByType",
142
+ "modifiedAt",
143
+ "modifiedInConversation",
144
+ "modifiedFromMessage"
145
+ ];
146
+ function buildEntityProjection(options) {
147
+ const alias = options?.alias ?? "n";
148
+ const parts = STORED_ENTITY_FIELDS.map((field) => `${alias}.${field} AS ${field}`);
149
+ if (options?.loadEmbeddings === true) {
150
+ parts.push(`${alias}.embedding AS embedding`);
151
+ }
152
+ return parts.join(", ");
153
+ }
154
+ var RESERVED_ENTITY_PROPERTY_KEYS = /* @__PURE__ */ new Set([
155
+ "id",
156
+ "repositoryId",
157
+ "entityType",
158
+ "label",
159
+ "slug",
160
+ "summary",
161
+ "properties",
162
+ "data",
163
+ "dataFormat",
164
+ "embedding",
165
+ "createdBy",
166
+ "createdByType",
167
+ "createdAt",
168
+ "createdInConversation",
169
+ "createdFromMessage",
170
+ "modifiedBy",
171
+ "modifiedByType",
172
+ "modifiedAt",
173
+ "modifiedInConversation",
174
+ "modifiedFromMessage"
175
+ ]);
176
+ var USER_PROPERTY_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
177
+ function assertSafeUserPropertyKey(key) {
178
+ if (!USER_PROPERTY_KEY_PATTERN.test(key)) {
179
+ throw new ProviderError(
180
+ `Entity property key "${key}" is not a valid Cypher identifier \u2014 must match ${USER_PROPERTY_KEY_PATTERN.source}. User-property keys are interpolated into Cypher REMOVE / predicate slots (the key slot cannot be parameterised), so an unsafe value would widen the injection surface.`
181
+ );
182
+ }
183
+ if (RESERVED_ENTITY_PROPERTY_KEYS.has(key)) {
184
+ throw new ProviderError(
185
+ `Entity property key "${key}" collides with a schema-managed field. Reserved names: ${Array.from(RESERVED_ENTITY_PROPERTY_KEYS).join(", ")}.`
186
+ );
187
+ }
188
+ return key;
189
+ }
190
+ function isNativeStorableValue(value) {
191
+ if (typeof value === "string") return true;
192
+ if (typeof value === "boolean") return true;
193
+ if (typeof value === "number") return Number.isFinite(value);
194
+ if (Array.isArray(value)) {
195
+ if (value.length === 0) return true;
196
+ const first = value[0];
197
+ const t = typeof first;
198
+ if (t !== "string" && t !== "boolean" && (t !== "number" || !Number.isFinite(first))) {
199
+ return false;
200
+ }
201
+ for (const v of value) {
202
+ if (typeof v !== t) return false;
203
+ if (t === "number" && !Number.isFinite(v)) return false;
204
+ }
205
+ return true;
206
+ }
207
+ return false;
208
+ }
209
+ function entityToParams(entity) {
210
+ const p = entity.provenance;
211
+ return {
212
+ id: entity.id,
213
+ entityType: entity.entityType,
214
+ label: entity.label,
215
+ slug: entity.slug,
216
+ summary: entity.summary ?? null,
217
+ properties: JSON.stringify(entity.properties),
218
+ data: entity.data ?? null,
219
+ dataFormat: entity.dataFormat ?? null,
220
+ embedding: entity.embedding ?? null,
221
+ createdBy: p.createdBy,
222
+ createdByType: p.createdByType,
223
+ createdAt: p.createdAt,
224
+ createdInConversation: p.createdInConversation ?? null,
225
+ createdFromMessage: p.createdFromMessage ?? null,
226
+ modifiedBy: p.modifiedBy,
227
+ modifiedByType: p.modifiedByType,
228
+ modifiedAt: p.modifiedAt,
229
+ modifiedInConversation: p.modifiedInConversation ?? null,
230
+ modifiedFromMessage: p.modifiedFromMessage ?? null
231
+ };
232
+ }
233
+ function entityUserPropertyParams(properties) {
234
+ const out = {};
235
+ for (const [key, value] of Object.entries(properties)) {
236
+ assertSafeUserPropertyKey(key);
237
+ if (isNativeStorableValue(value)) {
238
+ out[key] = value;
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+ var STORED_RELATIONSHIP_FIELDS = [
244
+ "id",
245
+ "relationshipType",
246
+ "sourceEntityId",
247
+ "targetEntityId",
248
+ "properties",
249
+ "bidirectional",
250
+ "createdBy",
251
+ "createdByType",
252
+ "createdAt",
253
+ "createdInConversation",
254
+ "createdFromMessage",
255
+ "modifiedBy",
256
+ "modifiedByType",
257
+ "modifiedAt",
258
+ "modifiedInConversation",
259
+ "modifiedFromMessage"
260
+ ];
261
+ function buildRelationshipProjection(options) {
262
+ const alias = options?.alias ?? "r";
263
+ return STORED_RELATIONSHIP_FIELDS.map((field) => `${alias}.${field} AS ${field}`).join(", ");
264
+ }
265
+ function relationshipToParams(rel) {
266
+ const p = rel.provenance;
267
+ return {
268
+ id: rel.id,
269
+ relationshipType: rel.relationshipType,
270
+ sourceEntityId: rel.sourceEntityId,
271
+ targetEntityId: rel.targetEntityId,
272
+ properties: JSON.stringify(rel.properties ?? {}),
273
+ bidirectional: rel.bidirectional,
274
+ createdBy: p.createdBy,
275
+ createdByType: p.createdByType,
276
+ createdAt: p.createdAt,
277
+ createdInConversation: p.createdInConversation ?? null,
278
+ createdFromMessage: p.createdFromMessage ?? null,
279
+ modifiedBy: p.modifiedBy,
280
+ modifiedByType: p.modifiedByType,
281
+ modifiedAt: p.modifiedAt,
282
+ modifiedInConversation: p.modifiedInConversation ?? null,
283
+ modifiedFromMessage: p.modifiedFromMessage ?? null
284
+ };
285
+ }
286
+ var RELATIONSHIP_TYPE_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
287
+ function assertSafeRelationshipType(value) {
288
+ if (!RELATIONSHIP_TYPE_PATTERN.test(value)) {
289
+ throw new ProviderError(
290
+ `Relationship type "${value}" is not a valid Cypher identifier \u2014 must match ${RELATIONSHIP_TYPE_PATTERN.source}. Cypher 25 does not allow parameterising the relationship-type slot, so the value is interpolated directly into the query string and an unsafe value would otherwise widen the injection surface.`
291
+ );
292
+ }
293
+ return value;
294
+ }
295
+ function repositoryCreateParams(config) {
296
+ return {
297
+ type: config.type ?? null,
298
+ label: config.label,
299
+ description: config.description ?? null,
300
+ legal: config.legal ?? null,
301
+ owner: config.owner ?? null,
302
+ governanceConfig: JSON.stringify(config.governanceConfig),
303
+ metadata: config.metadata !== void 0 ? JSON.stringify(config.metadata) : null,
304
+ createdAt: config.createdAt,
305
+ createdBy: config.createdBy
306
+ };
307
+ }
308
+ function extractPropertyBag(record, alias) {
309
+ if (record.keys.includes(alias)) {
310
+ const value = record.get(alias);
311
+ if (isNodeLike(value)) return value.properties;
312
+ if (isRelationshipLike(value)) return value.properties;
313
+ }
314
+ const bag = {};
315
+ for (const key of record.keys) {
316
+ if (typeof key !== "string") continue;
317
+ bag[key] = record.get(key);
318
+ }
319
+ return bag;
320
+ }
321
+ function isNodeLike(value) {
322
+ if (typeof value !== "object" || value === null) return false;
323
+ const v = value;
324
+ return typeof v.properties === "object" && v.properties !== null && !Array.isArray(v.properties) && Array.isArray(v.labels);
325
+ }
326
+ function isRelationshipLike(value) {
327
+ if (typeof value !== "object" || value === null) return false;
328
+ const v = value;
329
+ return typeof v.properties === "object" && v.properties !== null && !Array.isArray(v.properties) && typeof v.type === "string";
330
+ }
331
+ function provenanceFromProperties(props) {
332
+ const provenance = {
333
+ createdBy: requireString(props, "createdBy"),
334
+ createdByType: requireActorType(props, "createdByType"),
335
+ createdAt: requireString(props, "createdAt"),
336
+ modifiedBy: requireString(props, "modifiedBy"),
337
+ modifiedByType: requireActorType(props, "modifiedByType"),
338
+ modifiedAt: requireString(props, "modifiedAt")
339
+ };
340
+ const createdInConversation = optionalString(props, "createdInConversation");
341
+ const createdFromMessage = optionalString(props, "createdFromMessage");
342
+ const modifiedInConversation = optionalString(props, "modifiedInConversation");
343
+ const modifiedFromMessage = optionalString(props, "modifiedFromMessage");
344
+ if (createdInConversation !== void 0) provenance.createdInConversation = createdInConversation;
345
+ if (createdFromMessage !== void 0) provenance.createdFromMessage = createdFromMessage;
346
+ if (modifiedInConversation !== void 0) provenance.modifiedInConversation = modifiedInConversation;
347
+ if (modifiedFromMessage !== void 0) provenance.modifiedFromMessage = modifiedFromMessage;
348
+ return provenance;
349
+ }
350
+ function requireString(props, key) {
351
+ const value = props[key];
352
+ if (typeof value !== "string") {
353
+ throw new ProviderError(
354
+ `Neo4j record is missing required string field "${key}" (got ${describeType(value)}).`
355
+ );
356
+ }
357
+ return value;
358
+ }
359
+ function requireActorType(props, key) {
360
+ const value = requireString(props, key);
361
+ if (value !== "user" && value !== "agent") {
362
+ throw new ProviderError(
363
+ `Neo4j record field "${key}" must be "user" or "agent" (got ${JSON.stringify(value)}).`
364
+ );
365
+ }
366
+ return value;
367
+ }
368
+ var CHANGE_TYPES = [
369
+ "entity_type_added",
370
+ "relationship_type_added",
371
+ "entity_type_modified",
372
+ "relationship_type_modified",
373
+ "entity_type_removed",
374
+ "relationship_type_removed"
375
+ ];
376
+ function requireChangeType(props, key) {
377
+ const value = requireString(props, key);
378
+ if (!CHANGE_TYPES.includes(value)) {
379
+ throw new ProviderError(
380
+ `Neo4j record field "${key}" must be one of the VocabularyChangeRecord change types (got ${JSON.stringify(value)}).`
381
+ );
382
+ }
383
+ return value;
384
+ }
385
+ function requireBoolean(props, key) {
386
+ const value = props[key];
387
+ if (typeof value !== "boolean") {
388
+ throw new ProviderError(
389
+ `Neo4j record is missing required boolean field "${key}" (got ${describeType(value)}).`
390
+ );
391
+ }
392
+ return value;
393
+ }
394
+ function optionalString(props, key) {
395
+ const value = props[key];
396
+ if (value === void 0 || value === null || value === "") return void 0;
397
+ if (typeof value !== "string") {
398
+ throw new ProviderError(
399
+ `Neo4j record field "${key}" must be a string or empty (got ${describeType(value)}).`
400
+ );
401
+ }
402
+ return value;
403
+ }
404
+ function parsePropertiesBlob(props) {
405
+ const raw = props["properties"];
406
+ if (raw === void 0 || raw === null || raw === "") return {};
407
+ if (typeof raw !== "string") {
408
+ throw new ProviderError(
409
+ `Neo4j record field "properties" must be a JSON string (got ${describeType(raw)}).`
410
+ );
411
+ }
412
+ let parsed;
413
+ try {
414
+ parsed = JSON.parse(raw);
415
+ } catch (err) {
416
+ const detail = err instanceof Error ? err.message : String(err);
417
+ throw new ProviderError(`Neo4j record field "properties" is not valid JSON: ${detail}.`);
418
+ }
419
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
420
+ throw new ProviderError(
421
+ `Neo4j record field "properties" must decode to a JSON object (got ${describeType(parsed)}).`
422
+ );
423
+ }
424
+ return parsed;
425
+ }
426
+ function parseRequiredJsonObject(props, key) {
427
+ const raw = props[key];
428
+ if (typeof raw !== "string" || raw === "") {
429
+ throw new ProviderError(
430
+ `Neo4j record field "${key}" must be a non-empty JSON string (got ${describeType(raw)}).`
431
+ );
432
+ }
433
+ let parsed;
434
+ try {
435
+ parsed = JSON.parse(raw);
436
+ } catch (err) {
437
+ const detail = err instanceof Error ? err.message : String(err);
438
+ throw new ProviderError(`Neo4j record field "${key}" is not valid JSON: ${detail}.`);
439
+ }
440
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
441
+ throw new ProviderError(
442
+ `Neo4j record field "${key}" must decode to a JSON object (got ${describeType(parsed)}).`
443
+ );
444
+ }
445
+ return parsed;
446
+ }
447
+ function parseOptionalJsonObject(props, key) {
448
+ const raw = props[key];
449
+ if (raw === void 0 || raw === null || raw === "") return void 0;
450
+ if (typeof raw !== "string") {
451
+ throw new ProviderError(
452
+ `Neo4j record field "${key}" must be a JSON string (got ${describeType(raw)}).`
453
+ );
454
+ }
455
+ let parsed;
456
+ try {
457
+ parsed = JSON.parse(raw);
458
+ } catch (err) {
459
+ const detail = err instanceof Error ? err.message : String(err);
460
+ throw new ProviderError(`Neo4j record field "${key}" is not valid JSON: ${detail}.`);
461
+ }
462
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
463
+ throw new ProviderError(
464
+ `Neo4j record field "${key}" must decode to a JSON object (got ${describeType(parsed)}).`
465
+ );
466
+ }
467
+ return parsed;
468
+ }
469
+ function parseEmbedding(props) {
470
+ const raw = props["embedding"];
471
+ if (raw === void 0 || raw === null) return void 0;
472
+ if (!Array.isArray(raw)) {
473
+ throw new ProviderError(
474
+ `Neo4j record field "embedding" must be an array of numbers (got ${describeType(raw)}).`
475
+ );
476
+ }
477
+ for (const v of raw) {
478
+ if (typeof v !== "number") {
479
+ throw new ProviderError(
480
+ `Neo4j record field "embedding" must contain only numbers (got ${describeType(v)}).`
481
+ );
482
+ }
483
+ }
484
+ return raw;
485
+ }
486
+ function describeType(value) {
487
+ if (value === null) return "null";
488
+ if (Array.isArray(value)) return "array";
489
+ return typeof value;
490
+ }
491
+
492
+ // src/Neo4jTraversalExecutor.ts
493
+ var Neo4jTraversalExecutor = class {
494
+ constructor(connection, config) {
495
+ this.connection = connection;
496
+ this.config = config;
497
+ }
498
+ connection;
499
+ config;
500
+ compiler = new CypherCompiler();
501
+ /**
502
+ * Compile, scope, submit, and parse a traversal spec. The provider's vocab
503
+ * cache is the source of `vocabulary` — `traverse` / `exploreNeighborhood`
504
+ * / `findPaths` look it up once and pass it down.
505
+ */
506
+ async execute(repositoryId, spec, vocabulary) {
507
+ const startTime = Date.now();
508
+ const compiled = this.compiler.compile(spec, vocabulary);
509
+ const scopedQuery = this.scopeQuery(compiled.query);
510
+ const finalQuery = this.config.profileTraversals ? `PROFILE ${scopedQuery}` : scopedQuery;
511
+ const params = coerceSkipLimitToBigInt(finalQuery, compiled.params);
512
+ let result;
513
+ try {
514
+ result = await this.connection.executeQuery(
515
+ finalQuery,
516
+ params,
517
+ { repositoryId, routing: "READ" }
518
+ );
519
+ } catch (err) {
520
+ throw new ProviderError2(
521
+ `Neo4j traversal failed: ${err instanceof Error ? err.message : String(err)}`,
522
+ "Inspect the compiled Cypher in queryMetadata.compiledQuery and confirm the spec passed validation."
523
+ );
524
+ }
525
+ const executionTimeMs = Date.now() - startTime;
526
+ const summary = result.summary;
527
+ const serverMs = bigintLike(summary.resultConsumedAfter);
528
+ const raw = {
529
+ terminalEntities: [],
530
+ allEntities: [],
531
+ allRelationships: [],
532
+ pathRows: [],
533
+ entityMap: /* @__PURE__ */ new Map(),
534
+ relationshipMap: /* @__PURE__ */ new Map(),
535
+ pathRelFirstDirection: /* @__PURE__ */ new Map(),
536
+ executionTimeMs,
537
+ serverMs,
538
+ compiledQuery: finalQuery
539
+ };
540
+ if (this.config.profileTraversals && summary.profile !== void 0) {
541
+ raw.profile = summariseProfile(summary.profile);
542
+ }
543
+ const emitsProjection = spec.projection !== void 0 && spec.returnMode !== "path" && spec.returnMode !== "all";
544
+ if (emitsProjection) {
545
+ this.parseProjectionRows(result.records, raw, spec.projection.properties, spec.projection.mode ?? "values");
546
+ } else if (spec.returnMode === "terminal") {
547
+ this.parseTerminalRows(result.records, raw);
548
+ } else if (spec.returnMode === "all") {
549
+ this.parseAllRows(result.records, raw);
550
+ } else {
551
+ this.parsePathRows(result.records, raw);
552
+ }
553
+ return raw;
554
+ }
555
+ /**
556
+ * Parse the server-aggregated projection rows. Each row has one column per
557
+ * projected property and, when mode is 'count', an additional `count` column.
558
+ * Values arrive as native scalars / strings / BigInts; counts arrive as
559
+ * BigInt (per the driver's `useBigInt: true` configuration) and coerce to
560
+ * Number for the JSON wire shape.
561
+ */
562
+ parseProjectionRows(records, raw, propertyNames, mode) {
563
+ const aggregations = [];
564
+ for (const record of records) {
565
+ const values = {};
566
+ for (const prop of propertyNames) {
567
+ values[prop] = normaliseScalar(record.get(prop));
568
+ }
569
+ if (mode === "count") {
570
+ const rawCount = record.get("count");
571
+ const count = typeof rawCount === "bigint" ? Number(rawCount) : typeof rawCount === "number" ? rawCount : 0;
572
+ aggregations.push({ values, count });
573
+ } else {
574
+ aggregations.push({ values });
575
+ }
576
+ }
577
+ raw.aggregations = aggregations;
578
+ }
579
+ /**
580
+ * Inject the repositoryId scope onto the start node so the chokepoint's
581
+ * `$rid` assertion is satisfied structurally and the planner uses the
582
+ * `(repositoryId, id)` unique constraint's backing index for the seek.
583
+ *
584
+ * The compiler always emits `(n0)` as the first match part (it never adds a
585
+ * label or property map there) — we rewrite that single occurrence. The
586
+ * provider's defence-in-depth (D3b layer 3) prevents cross-repository edges
587
+ * from existing in the first place, so scoping just the start node is
588
+ * sufficient — once the planner anchors on a node in this repo, every
589
+ * reachable node is also in this repo.
590
+ */
591
+ scopeQuery(query) {
592
+ const scope = "(n0:_Entity {repositoryId: $rid})";
593
+ const replaced = query.replace("(n0)", scope);
594
+ if (replaced === query) {
595
+ throw new ProviderError2(
596
+ "Neo4jTraversalExecutor: compiled query did not contain the expected `(n0)` start-node form; cannot inject repositoryId scope.",
597
+ "This indicates a CypherCompiler emission change that the provider has not been updated for."
598
+ );
599
+ }
600
+ return replaced;
601
+ }
602
+ parseTerminalRows(records, raw) {
603
+ for (const record of records) {
604
+ for (const key of record.keys) {
605
+ if (typeof key !== "string") continue;
606
+ const value = record.get(key);
607
+ if (isNode(value)) {
608
+ const stored = entityFromNode(value);
609
+ raw.terminalEntities.push(stored);
610
+ if (!raw.entityMap.has(stored.id)) raw.entityMap.set(stored.id, stored);
611
+ }
612
+ }
613
+ }
614
+ }
615
+ parseAllRows(records, raw) {
616
+ for (const record of records) {
617
+ for (const key of record.keys) {
618
+ if (typeof key !== "string") continue;
619
+ const value = record.get(key);
620
+ if (isNode(value)) {
621
+ const stored = entityFromNode(value);
622
+ if (!raw.entityMap.has(stored.id)) {
623
+ raw.entityMap.set(stored.id, stored);
624
+ raw.allEntities.push(stored);
625
+ }
626
+ } else if (isRelationship(value)) {
627
+ const stored = relationshipFromRelationship(value);
628
+ if (!raw.relationshipMap.has(stored.id)) {
629
+ raw.relationshipMap.set(stored.id, stored);
630
+ raw.allRelationships.push(stored);
631
+ }
632
+ }
633
+ }
634
+ }
635
+ }
636
+ parsePathRows(records, raw) {
637
+ for (const record of records) {
638
+ const pathNodes = record.get("pathNodes");
639
+ const pathRels = record.get("pathRels");
640
+ if (!Array.isArray(pathNodes) || !Array.isArray(pathRels)) continue;
641
+ const pathEntityIds = [];
642
+ const pathRelIds = [];
643
+ const pathRelDirections = [];
644
+ let previousElementId;
645
+ for (const node of pathNodes) {
646
+ if (!isNode(node)) continue;
647
+ const stored = entityFromNode(node);
648
+ if (!raw.entityMap.has(stored.id)) raw.entityMap.set(stored.id, stored);
649
+ pathEntityIds.push(stored.id);
650
+ }
651
+ for (let i = 0; i < pathRels.length; i++) {
652
+ const rel = pathRels[i];
653
+ if (!isRelationship(rel)) continue;
654
+ const stored = relationshipFromRelationship(rel);
655
+ if (!raw.relationshipMap.has(stored.id)) raw.relationshipMap.set(stored.id, stored);
656
+ pathRelIds.push(stored.id);
657
+ const segmentStartNode = pathNodes[i];
658
+ const segmentStartElementId = isNode(segmentStartNode) ? segmentStartNode.elementId : previousElementId;
659
+ const direction = segmentStartElementId !== void 0 && rel.startNodeElementId === segmentStartElementId ? "out" : "in";
660
+ pathRelDirections.push(direction);
661
+ if (!raw.pathRelFirstDirection.has(stored.id)) {
662
+ raw.pathRelFirstDirection.set(stored.id, direction);
663
+ }
664
+ const segmentEndNode = pathNodes[i + 1];
665
+ if (isNode(segmentEndNode)) previousElementId = segmentEndNode.elementId;
666
+ }
667
+ raw.pathRows.push({
668
+ entityIds: pathEntityIds,
669
+ relationshipIds: pathRelIds,
670
+ relationshipDirections: pathRelDirections
671
+ });
672
+ }
673
+ }
674
+ };
675
+ function isNode(value) {
676
+ if (typeof value !== "object" || value === null) return false;
677
+ const v = value;
678
+ return typeof v.elementId === "string" && Array.isArray(v.labels) && typeof v.properties === "object" && v.properties !== null;
679
+ }
680
+ function isRelationship(value) {
681
+ if (typeof value !== "object" || value === null) return false;
682
+ const v = value;
683
+ return typeof v.elementId === "string" && typeof v.type === "string" && typeof v.startNodeElementId === "string" && typeof v.endNodeElementId === "string" && typeof v.properties === "object" && v.properties !== null;
684
+ }
685
+ function entityFromNode(node) {
686
+ return entityFromProperties(node.properties);
687
+ }
688
+ function relationshipFromRelationship(rel) {
689
+ return relationshipFromProperties(rel.properties);
690
+ }
691
+ function bigintLike(value) {
692
+ if (typeof value === "bigint") return Number(value);
693
+ if (typeof value === "number") return value;
694
+ return 0;
695
+ }
696
+ function normaliseScalar(value) {
697
+ if (typeof value === "bigint") return Number(value);
698
+ return value;
699
+ }
700
+ var SKIP_LIMIT_PARAM_PATTERN = /\b(?:SKIP|LIMIT)\s+\$(\w+)/gi;
701
+ function coerceSkipLimitToBigInt(cypher, params) {
702
+ const targetKeys = /* @__PURE__ */ new Set();
703
+ for (const match of cypher.matchAll(SKIP_LIMIT_PARAM_PATTERN)) {
704
+ if (match[1] !== void 0) targetKeys.add(match[1]);
705
+ }
706
+ if (targetKeys.size === 0) return params;
707
+ const out = { ...params };
708
+ for (const key of targetKeys) {
709
+ const value = out[key];
710
+ if (typeof value === "number" && Number.isFinite(value)) {
711
+ out[key] = BigInt(Math.trunc(value));
712
+ }
713
+ }
714
+ return out;
715
+ }
716
+ function summariseProfile(plan) {
717
+ let total = 0;
718
+ const visit = (node) => {
719
+ if (node === void 0) return;
720
+ const hits = node.dbHits;
721
+ if (typeof hits === "bigint") total += Number(hits);
722
+ else if (typeof hits === "number") total += hits;
723
+ for (const child of node.children ?? []) visit(child);
724
+ };
725
+ visit(plan);
726
+ return { totalDbHits: total, rootOperator: plan.operatorType ?? "unknown" };
727
+ }
728
+
729
+ // src/Neo4jConnection.ts
730
+ import neo4j from "neo4j-driver";
731
+ import { ProviderError as ProviderError3 } from "@utaba/deep-memory";
732
+
733
+ // src/usageScope.ts
734
+ import { AsyncLocalStorage } from "async_hooks";
735
+ var COUNTER_KEYS = [
736
+ "nodesCreated",
737
+ "nodesDeleted",
738
+ "relationshipsCreated",
739
+ "relationshipsDeleted",
740
+ "propertiesSet",
741
+ "labelsAdded",
742
+ "labelsRemoved",
743
+ "indexesAdded",
744
+ "indexesRemoved",
745
+ "constraintsAdded",
746
+ "constraintsRemoved"
747
+ ];
748
+ var store = new AsyncLocalStorage();
749
+ function createUsageScope() {
750
+ return {
751
+ calls: 0,
752
+ recordCount: 0,
753
+ serverMs: 0,
754
+ availableAfterMs: 0,
755
+ counters: {}
756
+ };
757
+ }
758
+ function runInUsageScope(scope, fn) {
759
+ return store.run(scope, fn);
760
+ }
761
+ function recordRoundTrip(summary, recordCount) {
762
+ const scope = store.getStore();
763
+ if (scope === void 0) return;
764
+ scope.calls += 1;
765
+ scope.recordCount += recordCount;
766
+ if (summary.resultConsumedAfter !== void 0) {
767
+ scope.serverMs += bigintToSafeNumber(summary.resultConsumedAfter);
768
+ }
769
+ if (summary.resultAvailableAfter !== void 0) {
770
+ scope.availableAfterMs += bigintToSafeNumber(summary.resultAvailableAfter);
771
+ }
772
+ const stats = summary.counters !== void 0 ? summary.counters.updates() : void 0;
773
+ if (stats !== void 0) {
774
+ for (const key of COUNTER_KEYS) {
775
+ const value = stats[key];
776
+ if (typeof value === "number" && value !== 0) {
777
+ scope.counters[key] = (scope.counters[key] ?? 0) + value;
778
+ }
779
+ }
780
+ }
781
+ }
782
+ function buildUsageDetails(scope) {
783
+ const details = {
784
+ calls: scope.calls,
785
+ recordCount: scope.recordCount,
786
+ availableAfterMs: scope.availableAfterMs
787
+ };
788
+ for (const [key, value] of Object.entries(scope.counters)) {
789
+ if (value !== 0) details[key] = value;
790
+ }
791
+ return details;
792
+ }
793
+
794
+ // src/Neo4jConnection.ts
795
+ var DEFAULT_DATABASE = "neo4j";
796
+ var DEFAULT_USER_AGENT = "@utaba/deep-memory-storage-neo4j";
797
+ var RID_TOKEN_PATTERN = /\$rid\b/;
798
+ var NOTIFICATION_QUERY_TRUNCATION = 200;
799
+ var Neo4jConnection = class {
800
+ driver;
801
+ database;
802
+ constructor(config) {
803
+ this.database = config.database ?? DEFAULT_DATABASE;
804
+ this.driver = neo4j.driver(
805
+ config.uri,
806
+ neo4j.auth.basic(config.username, config.password),
807
+ {
808
+ // BigInt for INTEGER avoids silent precision loss on counts that may
809
+ // exceed Number.MAX_SAFE_INTEGER — see D6b. The mapping layer
810
+ // narrows BigInt → Number at the public-API boundary.
811
+ useBigInt: true,
812
+ userAgent: config.userAgent ?? DEFAULT_USER_AGENT,
813
+ ...config.maxTransactionRetryTime !== void 0 ? { maxTransactionRetryTime: config.maxTransactionRetryTime } : {}
814
+ }
815
+ );
816
+ }
817
+ /** Verify the driver can reach the server. Throws on failure. */
818
+ async verifyConnectivity() {
819
+ await this.driver.verifyConnectivity({ database: this.database });
820
+ }
821
+ /** Returns server version / edition metadata for diagnostics. */
822
+ async getServerInfo() {
823
+ return this.driver.getServerInfo({ database: this.database });
824
+ }
825
+ /** Close the driver and release all connections. */
826
+ async close() {
827
+ await this.driver.close();
828
+ }
829
+ /**
830
+ * Run a single-statement query scoped to a repository.
831
+ *
832
+ * Required `repositoryId` binds `$rid` for the caller. The Cypher string
833
+ * MUST reference `$rid` somewhere in a predicate or property map; absence
834
+ * throws `ProviderError` as a programming error (D3b layer 2).
835
+ */
836
+ async executeQuery(cypher, params, options) {
837
+ this.assertRepositoryId(options.repositoryId);
838
+ this.assertScoped(cypher);
839
+ const result = await this.driver.executeQuery(
840
+ cypher,
841
+ { ...params, rid: options.repositoryId },
842
+ {
843
+ database: this.database,
844
+ ...options.routing !== void 0 ? { routing: options.routing } : {}
845
+ }
846
+ );
847
+ recordRoundTrip(result.summary, result.records.length);
848
+ this.surfaceNotifications(cypher, result.summary);
849
+ return result;
850
+ }
851
+ /**
852
+ * Run a managed write transaction scoped to a repository. Use only when
853
+ * more than one Cypher statement must commit atomically — single-statement
854
+ * writes go through `executeQuery` so the driver handles transient-error
855
+ * retry uniformly (D2b).
856
+ *
857
+ * Every `tx.run` call inside `txFn` is wrapped to inject `$rid` and assert
858
+ * scope, so the same isolation enforcement applies as on the default path.
859
+ * The transaction function MUST be idempotent (the driver retries on
860
+ * transient errors) and MUST NOT return the raw `Result` — process records
861
+ * inside the function and return mapped data.
862
+ */
863
+ async executeWrite(repositoryId, txFn) {
864
+ return this.runManaged("write", repositoryId, txFn);
865
+ }
866
+ /**
867
+ * Run a managed read transaction scoped to a repository. Same isolation
868
+ * contract as `executeWrite`.
869
+ */
870
+ async executeRead(repositoryId, txFn) {
871
+ return this.runManaged("read", repositoryId, txFn);
872
+ }
873
+ /**
874
+ * Cross-repository escape hatch. Reserved for the small allowlist of
875
+ * legitimate cross-repository operations: `ensureSchema` (DDL),
876
+ * `listRepositories`, `_Meta` schema-version reads, server-info probes.
877
+ * Does NOT inject `$rid`. Every call site MUST add a one-line comment
878
+ * justifying why cross-repository access is correct (D3b).
879
+ */
880
+ async executeSystemQuery(cypher, params, options) {
881
+ if (options.crossRepository !== true) {
882
+ throw new ProviderError3(
883
+ "executeSystemQuery requires crossRepository: true \u2014 use executeQuery for repository-scoped Cypher."
884
+ );
885
+ }
886
+ const result = await this.driver.executeQuery(cypher, params, {
887
+ database: this.database,
888
+ ...options.routing !== void 0 ? { routing: options.routing } : {}
889
+ });
890
+ recordRoundTrip(result.summary, result.records.length);
891
+ this.surfaceNotifications(cypher, result.summary);
892
+ return result;
893
+ }
894
+ /**
895
+ * Run a single DDL statement on a session (auto-commit). Used for schema
896
+ * setup — `CREATE CONSTRAINT` / `CREATE INDEX` statements cannot run inside
897
+ * a managed transaction in Cypher 25 and must be issued one per call.
898
+ * Cross-repository by definition.
899
+ */
900
+ async executeSystemDdl(cypher) {
901
+ const session = this.driver.session({ database: this.database });
902
+ try {
903
+ const result = await session.run(cypher);
904
+ recordRoundTrip(result.summary, result.records.length);
905
+ this.surfaceNotifications(cypher, result.summary);
906
+ return result.summary;
907
+ } finally {
908
+ await session.close();
909
+ }
910
+ }
911
+ /**
912
+ * Run a repository-scoped Cypher statement that uses `CALL ( ) { ... } IN
913
+ * TRANSACTIONS OF $batchSize ROWS` semantics.
914
+ *
915
+ * `IN TRANSACTIONS` is only valid in implicit (auto-commit) transactions —
916
+ * the managed `executeWrite` / `executeRead` helpers open an explicit
917
+ * transaction internally and the server rejects the statement with
918
+ * `Neo.DatabaseError.Transaction.TransactionStartFailed`. Probe P13
919
+ * (local-tests/baseline/neo4j-call-in-transactions-results.md) captured the
920
+ * exact failure mode and the empty-match / partial-batch behaviour the
921
+ * chunked-wipe path relies on.
922
+ *
923
+ * Same `$rid` injection + scope assertion as `executeQuery`, so the chunked
924
+ * delete still flows through the D3b layer 2 lifeline. Returns only the
925
+ * summary because the chunked-wipe subquery yields no rows; counters live on
926
+ * `summary.counters.updates()`.
927
+ */
928
+ async executeImplicitInTransactions(cypher, params, options) {
929
+ this.assertRepositoryId(options.repositoryId);
930
+ this.assertScoped(cypher);
931
+ const session = this.driver.session({ database: this.database });
932
+ try {
933
+ const result = await session.run(cypher, { ...params, rid: options.repositoryId });
934
+ recordRoundTrip(result.summary, result.records.length);
935
+ this.surfaceNotifications(cypher, result.summary);
936
+ return result.summary;
937
+ } finally {
938
+ await session.close();
939
+ }
940
+ }
941
+ async runManaged(mode, repositoryId, txFn) {
942
+ this.assertRepositoryId(repositoryId);
943
+ const session = this.driver.session({ database: this.database });
944
+ try {
945
+ const wrapped = (managed) => txFn(new ScopedTransaction(managed, repositoryId, this.surfaceNotifications.bind(this)));
946
+ return mode === "write" ? await session.executeWrite(wrapped) : await session.executeRead(wrapped);
947
+ } finally {
948
+ await session.close();
949
+ }
950
+ }
951
+ assertRepositoryId(repositoryId) {
952
+ if (typeof repositoryId !== "string" || repositoryId.length === 0) {
953
+ throw new ProviderError3(
954
+ "Neo4jConnection: repositoryId is required for scoped queries (D3b isolation guarantee)."
955
+ );
956
+ }
957
+ }
958
+ assertScoped(cypher) {
959
+ if (!RID_TOKEN_PATTERN.test(cypher)) {
960
+ throw new ProviderError3(
961
+ "Neo4jConnection: Cypher omits required $rid binding. Repository-scoped queries MUST reference $rid in a predicate or property map. Use executeSystemQuery for cross-repository calls (D3b allowlist)."
962
+ );
963
+ }
964
+ }
965
+ surfaceNotifications(cypher, summary) {
966
+ const items = collectNonInformationNotifications(summary);
967
+ if (items.length === 0) return;
968
+ const truncated = cypher.length > NOTIFICATION_QUERY_TRUNCATION ? `${cypher.slice(0, NOTIFICATION_QUERY_TRUNCATION)}\u2026` : cypher;
969
+ console.warn("[neo4j] notifications", { cypher: truncated, notifications: items });
970
+ }
971
+ };
972
+ var ScopedTransaction = class {
973
+ constructor(tx, repositoryId, notify) {
974
+ this.tx = tx;
975
+ this.repositoryId = repositoryId;
976
+ this.notify = notify;
977
+ }
978
+ tx;
979
+ repositoryId;
980
+ notify;
981
+ /**
982
+ * Run a single statement inside the managed transaction. Injects `$rid`
983
+ * and asserts the Cypher string references it.
984
+ */
985
+ async run(cypher, params) {
986
+ if (!RID_TOKEN_PATTERN.test(cypher)) {
987
+ throw new ProviderError3(
988
+ "ScopedTransaction.run: Cypher omits required $rid binding. Multi-statement writes still flow through the isolation chokepoint."
989
+ );
990
+ }
991
+ const queryResult = await this.tx.run(cypher, { ...params, rid: this.repositoryId });
992
+ const records = queryResult.records;
993
+ const summary = queryResult.summary;
994
+ recordRoundTrip(summary, records.length);
995
+ this.notify(cypher, summary);
996
+ return { records, summary };
997
+ }
998
+ };
999
+ function collectNonInformationNotifications(summary) {
1000
+ const summaryLike = summary;
1001
+ const out = [];
1002
+ const seen = /* @__PURE__ */ new Set();
1003
+ for (const item of summaryLike.notifications ?? []) {
1004
+ const entry = {
1005
+ severity: item.severity ?? "",
1006
+ category: item.category ?? "",
1007
+ code: item.code ?? "",
1008
+ title: item.title ?? "",
1009
+ description: item.description ?? ""
1010
+ };
1011
+ const key = entry.code || entry.title;
1012
+ if (key && seen.has(key)) continue;
1013
+ if (key) seen.add(key);
1014
+ out.push(entry);
1015
+ }
1016
+ for (const item of summaryLike.gqlStatusObjects ?? []) {
1017
+ const severity = item.severity ?? "";
1018
+ if (severity !== "WARNING" && severity !== "ERROR") continue;
1019
+ const entry = {
1020
+ severity,
1021
+ category: item.classification ?? "",
1022
+ code: item.gqlStatus ?? "",
1023
+ title: item.title ?? "",
1024
+ description: item.description ?? item.statusDescription ?? ""
1025
+ };
1026
+ const key = entry.code || entry.title || entry.description;
1027
+ if (key && seen.has(key)) continue;
1028
+ if (key) seen.add(key);
1029
+ out.push(entry);
1030
+ }
1031
+ return out;
1032
+ }
1033
+
1034
+ // src/errors.ts
1035
+ import {
1036
+ DuplicateEntityError,
1037
+ DuplicateRelationshipError,
1038
+ DuplicateRepositoryError,
1039
+ ProviderError as ProviderError4
1040
+ } from "@utaba/deep-memory";
1041
+ var CONSTRAINT_VIOLATION_CODE = "Neo.ClientError.Schema.ConstraintValidationFailed";
1042
+ var SYNTAX_ERROR_CODE = "Neo.ClientError.Statement.SyntaxError";
1043
+ function mapDriverError(error, context = {}) {
1044
+ if (error instanceof DuplicateEntityError || error instanceof DuplicateRelationshipError || error instanceof DuplicateRepositoryError || error instanceof ProviderError4) {
1045
+ throw error;
1046
+ }
1047
+ const driverError = error ?? {};
1048
+ const code = typeof driverError.code === "string" ? driverError.code : "";
1049
+ const message = typeof driverError.message === "string" ? driverError.message : "";
1050
+ if (code === CONSTRAINT_VIOLATION_CODE) {
1051
+ const kind = inferConstraintKind(context, message);
1052
+ if (kind === "repository" && context.repositoryId !== void 0) {
1053
+ throw new DuplicateRepositoryError(context.repositoryId);
1054
+ }
1055
+ if (kind === "entity" && context.entityId !== void 0) {
1056
+ throw new DuplicateEntityError(context.entityId);
1057
+ }
1058
+ if (kind === "relationship" && context.relationshipId !== void 0) {
1059
+ throw new DuplicateRelationshipError(context.relationshipId);
1060
+ }
1061
+ throw new ProviderError4(
1062
+ `Neo4j constraint violation${formatOperation(context)}: ${message || code}`,
1063
+ "Inspect the failing Cypher and the schema constraints \u2014 the affected unique key already exists."
1064
+ );
1065
+ }
1066
+ if (code === SYNTAX_ERROR_CODE) {
1067
+ throw new ProviderError4(
1068
+ `Neo4j syntax error${formatOperation(context)}: ${message || code}`,
1069
+ "This is a programming error inside @utaba/deep-memory-storage-neo4j \u2014 please file an issue."
1070
+ );
1071
+ }
1072
+ const prefix = `Neo4j driver error${formatOperation(context)}`;
1073
+ const codeSuffix = code ? ` [${code}]` : "";
1074
+ const messageSuffix = message ? `: ${message}` : "";
1075
+ throw new ProviderError4(`${prefix}${codeSuffix}${messageSuffix}`);
1076
+ }
1077
+ function inferConstraintKind(context, message) {
1078
+ if (context.kind !== void 0) return context.kind;
1079
+ if (message.startsWith("Relationship")) return "relationship";
1080
+ if (message.includes("`_Repository`")) return "repository";
1081
+ return "entity";
1082
+ }
1083
+ function formatOperation(context) {
1084
+ return context.operation ? ` in ${context.operation}` : "";
1085
+ }
1086
+
1087
+ // src/queries/bulk.ts
1088
+ import { ProviderError as ProviderError5 } from "@utaba/deep-memory";
1089
+ var EXPORT_BATCH_SIZE = 100;
1090
+ var DEFAULT_IMPORT_CHUNK_SIZE = 500;
1091
+ var DEFAULT_IMPORT_CONCURRENCY = 8;
1092
+ var EXPORT_ENTITY_PROJECTION = buildEntityProjection({ loadEmbeddings: true });
1093
+ var EXPORT_RELATIONSHIP_PROJECTION = buildRelationshipProjection();
1094
+ var EXPORT_ENTITIES_FIRST_PAGE = `
1095
+ MATCH (n:_Entity {repositoryId: $rid})
1096
+ RETURN ${EXPORT_ENTITY_PROJECTION}
1097
+ ORDER BY n.id
1098
+ LIMIT $batchSize
1099
+ `;
1100
+ var EXPORT_ENTITIES_NEXT_PAGE = `
1101
+ MATCH (n:_Entity {repositoryId: $rid})
1102
+ WHERE n.id > $cursor
1103
+ RETURN ${EXPORT_ENTITY_PROJECTION}
1104
+ ORDER BY n.id
1105
+ LIMIT $batchSize
1106
+ `;
1107
+ var EXPORT_RELATIONSHIPS_FIRST_PAGE = `
1108
+ MATCH (:_Entity {repositoryId: $rid})-[r {repositoryId: $rid}]->(:_Entity {repositoryId: $rid})
1109
+ RETURN ${EXPORT_RELATIONSHIP_PROJECTION}
1110
+ ORDER BY r.id
1111
+ LIMIT $batchSize
1112
+ `;
1113
+ var EXPORT_RELATIONSHIPS_NEXT_PAGE = `
1114
+ MATCH (:_Entity {repositoryId: $rid})-[r {repositoryId: $rid}]->(:_Entity {repositoryId: $rid})
1115
+ WHERE r.id > $cursor
1116
+ RETURN ${EXPORT_RELATIONSHIP_PROJECTION}
1117
+ ORDER BY r.id
1118
+ LIMIT $batchSize
1119
+ `;
1120
+ async function* exportAll(conn, repositoryId) {
1121
+ let sequence = 0;
1122
+ let cursor;
1123
+ while (true) {
1124
+ const query = cursor === void 0 ? EXPORT_ENTITIES_FIRST_PAGE : EXPORT_ENTITIES_NEXT_PAGE;
1125
+ const params = cursor === void 0 ? { batchSize: BigInt(EXPORT_BATCH_SIZE) } : { cursor, batchSize: BigInt(EXPORT_BATCH_SIZE) };
1126
+ const result = await conn.executeQuery(query, params, { repositoryId, routing: "READ" });
1127
+ const entities = result.records.map((record) => entityFromRecord(record));
1128
+ const isLast = entities.length < EXPORT_BATCH_SIZE;
1129
+ if (entities.length > 0) {
1130
+ cursor = entities[entities.length - 1].id;
1131
+ yield {
1132
+ type: "entities",
1133
+ data: entities,
1134
+ sequence: sequence++,
1135
+ isLast
1136
+ };
1137
+ }
1138
+ if (isLast) break;
1139
+ }
1140
+ let relCursor;
1141
+ while (true) {
1142
+ const query = relCursor === void 0 ? EXPORT_RELATIONSHIPS_FIRST_PAGE : EXPORT_RELATIONSHIPS_NEXT_PAGE;
1143
+ const params = relCursor === void 0 ? { batchSize: BigInt(EXPORT_BATCH_SIZE) } : { cursor: relCursor, batchSize: BigInt(EXPORT_BATCH_SIZE) };
1144
+ const result = await conn.executeQuery(query, params, { repositoryId, routing: "READ" });
1145
+ const relationships = result.records.map((record) => relationshipFromRecord(record));
1146
+ const isLast = relationships.length < EXPORT_BATCH_SIZE;
1147
+ if (relationships.length > 0) {
1148
+ relCursor = relationships[relationships.length - 1].id;
1149
+ yield {
1150
+ type: "relationships",
1151
+ data: relationships,
1152
+ sequence: sequence++,
1153
+ isLast
1154
+ };
1155
+ }
1156
+ if (isLast) break;
1157
+ }
1158
+ if (sequence === 0) {
1159
+ yield {
1160
+ type: "entities",
1161
+ data: [],
1162
+ sequence: 0,
1163
+ isLast: true
1164
+ };
1165
+ }
1166
+ }
1167
+ var INSERT_ENTITIES_QUERY = `
1168
+ UNWIND $rows AS row
1169
+ CREATE (n:_Entity)
1170
+ SET
1171
+ n.id = row.id,
1172
+ n.repositoryId = $rid,
1173
+ n.entityType = row.entityType,
1174
+ n.label = row.label,
1175
+ n.slug = row.slug,
1176
+ n.summary = row.summary,
1177
+ n.properties = row.properties,
1178
+ n.data = row.data,
1179
+ n.dataFormat = row.dataFormat,
1180
+ n.embedding = row.embedding,
1181
+ n.createdBy = row.createdBy,
1182
+ n.createdByType = row.createdByType,
1183
+ n.createdAt = row.createdAt,
1184
+ n.createdInConversation = row.createdInConversation,
1185
+ n.createdFromMessage = row.createdFromMessage,
1186
+ n.modifiedBy = row.modifiedBy,
1187
+ n.modifiedByType = row.modifiedByType,
1188
+ n.modifiedAt = row.modifiedAt,
1189
+ n.modifiedInConversation = row.modifiedInConversation,
1190
+ n.modifiedFromMessage = row.modifiedFromMessage
1191
+ SET n += row.userProperties
1192
+ `;
1193
+ var UPSERT_ENTITIES_QUERY = `
1194
+ UNWIND $rows AS row
1195
+ MERGE (n:_Entity {repositoryId: $rid, id: row.id})
1196
+ ON CREATE SET
1197
+ n.entityType = row.entityType,
1198
+ n.label = row.label,
1199
+ n.slug = row.slug,
1200
+ n.summary = row.summary,
1201
+ n.properties = row.properties,
1202
+ n.data = row.data,
1203
+ n.dataFormat = row.dataFormat,
1204
+ n.embedding = row.embedding,
1205
+ n.createdBy = row.createdBy,
1206
+ n.createdByType = row.createdByType,
1207
+ n.createdAt = row.createdAt,
1208
+ n.createdInConversation = row.createdInConversation,
1209
+ n.createdFromMessage = row.createdFromMessage,
1210
+ n.modifiedBy = row.modifiedBy,
1211
+ n.modifiedByType = row.modifiedByType,
1212
+ n.modifiedAt = row.modifiedAt,
1213
+ n.modifiedInConversation = row.modifiedInConversation,
1214
+ n.modifiedFromMessage = row.modifiedFromMessage
1215
+ ON MATCH SET
1216
+ n.entityType = row.entityType,
1217
+ n.label = row.label,
1218
+ n.slug = row.slug,
1219
+ n.summary = row.summary,
1220
+ n.properties = row.properties,
1221
+ n.data = row.data,
1222
+ n.dataFormat = row.dataFormat,
1223
+ n.embedding = row.embedding,
1224
+ n.createdBy = row.createdBy,
1225
+ n.createdByType = row.createdByType,
1226
+ n.createdAt = row.createdAt,
1227
+ n.createdInConversation = row.createdInConversation,
1228
+ n.createdFromMessage = row.createdFromMessage,
1229
+ n.modifiedBy = row.modifiedBy,
1230
+ n.modifiedByType = row.modifiedByType,
1231
+ n.modifiedAt = row.modifiedAt,
1232
+ n.modifiedInConversation = row.modifiedInConversation,
1233
+ n.modifiedFromMessage = row.modifiedFromMessage
1234
+ SET n += row.userProperties
1235
+ `;
1236
+ function buildInsertRelationshipsQuery(relationshipType) {
1237
+ const safe = assertSafeRelationshipType(relationshipType);
1238
+ return `
1239
+ UNWIND $rows AS row
1240
+ MATCH (s:_Entity {repositoryId: $rid, id: row.sourceEntityId})
1241
+ MATCH (t:_Entity {repositoryId: $rid, id: row.targetEntityId})
1242
+ CREATE (s)-[r:${safe} {
1243
+ repositoryId: $rid,
1244
+ id: row.id,
1245
+ relationshipType: row.relationshipType,
1246
+ sourceEntityId: row.sourceEntityId,
1247
+ targetEntityId: row.targetEntityId,
1248
+ properties: row.properties,
1249
+ bidirectional: row.bidirectional,
1250
+ createdBy: row.createdBy,
1251
+ createdByType: row.createdByType,
1252
+ createdAt: row.createdAt,
1253
+ createdInConversation: row.createdInConversation,
1254
+ createdFromMessage: row.createdFromMessage,
1255
+ modifiedBy: row.modifiedBy,
1256
+ modifiedByType: row.modifiedByType,
1257
+ modifiedAt: row.modifiedAt,
1258
+ modifiedInConversation: row.modifiedInConversation,
1259
+ modifiedFromMessage: row.modifiedFromMessage
1260
+ }]->(t)
1261
+ RETURN row.id AS id
1262
+ `;
1263
+ }
1264
+ function buildUpsertRelationshipsQuery(relationshipType) {
1265
+ const safe = assertSafeRelationshipType(relationshipType);
1266
+ return `
1267
+ UNWIND $rows AS row
1268
+ MATCH (s:_Entity {repositoryId: $rid, id: row.sourceEntityId})
1269
+ MATCH (t:_Entity {repositoryId: $rid, id: row.targetEntityId})
1270
+ MERGE (s)-[r:${safe} {repositoryId: $rid, id: row.id}]->(t)
1271
+ ON CREATE SET
1272
+ r.relationshipType = row.relationshipType,
1273
+ r.sourceEntityId = row.sourceEntityId,
1274
+ r.targetEntityId = row.targetEntityId,
1275
+ r.properties = row.properties,
1276
+ r.bidirectional = row.bidirectional,
1277
+ r.createdBy = row.createdBy,
1278
+ r.createdByType = row.createdByType,
1279
+ r.createdAt = row.createdAt,
1280
+ r.createdInConversation = row.createdInConversation,
1281
+ r.createdFromMessage = row.createdFromMessage,
1282
+ r.modifiedBy = row.modifiedBy,
1283
+ r.modifiedByType = row.modifiedByType,
1284
+ r.modifiedAt = row.modifiedAt,
1285
+ r.modifiedInConversation = row.modifiedInConversation,
1286
+ r.modifiedFromMessage = row.modifiedFromMessage
1287
+ ON MATCH SET
1288
+ r.properties = row.properties,
1289
+ r.bidirectional = row.bidirectional,
1290
+ r.modifiedBy = row.modifiedBy,
1291
+ r.modifiedByType = row.modifiedByType,
1292
+ r.modifiedAt = row.modifiedAt,
1293
+ r.modifiedInConversation = row.modifiedInConversation,
1294
+ r.modifiedFromMessage = row.modifiedFromMessage
1295
+ RETURN row.id AS id
1296
+ `;
1297
+ }
1298
+ async function importBulk(conn, repositoryId, data, options) {
1299
+ const skipCheck = options?.skipExistenceCheck === true;
1300
+ const ext = options ?? {};
1301
+ const chunkSize = ext.chunkSize ?? DEFAULT_IMPORT_CHUNK_SIZE;
1302
+ const concurrency = ext.concurrency ?? DEFAULT_IMPORT_CONCURRENCY;
1303
+ const allEntities = [];
1304
+ const allRelationships = [];
1305
+ for (const chunk of data) {
1306
+ if (chunk.entities) allEntities.push(...chunk.entities);
1307
+ if (chunk.relationships) allRelationships.push(...chunk.relationships);
1308
+ }
1309
+ const errors = [];
1310
+ const entityChunks = sliceIntoChunks(allEntities, chunkSize);
1311
+ const entityResults = await runBounded(
1312
+ entityChunks,
1313
+ concurrency,
1314
+ (chunk) => importEntityChunk(conn, repositoryId, chunk, skipCheck)
1315
+ );
1316
+ let entitiesImported = 0;
1317
+ for (const res of entityResults) {
1318
+ entitiesImported += res.imported;
1319
+ errors.push(...res.errors);
1320
+ }
1321
+ const relationshipChunks = groupRelationshipsByTypeIntoChunks(allRelationships, chunkSize);
1322
+ const relationshipResults = await runBounded(
1323
+ relationshipChunks,
1324
+ concurrency,
1325
+ (group) => importRelationshipChunk(conn, repositoryId, group, skipCheck)
1326
+ );
1327
+ let relationshipsImported = 0;
1328
+ for (const res of relationshipResults) {
1329
+ relationshipsImported += res.imported;
1330
+ errors.push(...res.errors);
1331
+ }
1332
+ return { entitiesImported, relationshipsImported, errors };
1333
+ }
1334
+ async function importEntityChunk(conn, repositoryId, entities, skipCheck) {
1335
+ if (entities.length === 0) return { imported: 0, errors: [] };
1336
+ const rows = entities.map((entity) => ({
1337
+ ...entityToParams(entity),
1338
+ userProperties: entityUserPropertyParams(entity.properties)
1339
+ }));
1340
+ const query = skipCheck ? INSERT_ENTITIES_QUERY : UPSERT_ENTITIES_QUERY;
1341
+ try {
1342
+ await conn.executeQuery(query, { rows }, { repositoryId });
1343
+ return { imported: entities.length, errors: [] };
1344
+ } catch (err) {
1345
+ return fallbackPerEntity(conn, repositoryId, entities, skipCheck, err);
1346
+ }
1347
+ }
1348
+ async function fallbackPerEntity(conn, repositoryId, entities, skipCheck, chunkError) {
1349
+ const errors = [];
1350
+ let imported = 0;
1351
+ const query = skipCheck ? INSERT_ENTITIES_QUERY : UPSERT_ENTITIES_QUERY;
1352
+ for (const entity of entities) {
1353
+ try {
1354
+ const rows = [
1355
+ {
1356
+ ...entityToParams(entity),
1357
+ userProperties: entityUserPropertyParams(entity.properties)
1358
+ }
1359
+ ];
1360
+ await conn.executeQuery(query, { rows }, { repositoryId });
1361
+ imported++;
1362
+ } catch (rowErr) {
1363
+ errors.push({
1364
+ item: `entity:${entity.id}`,
1365
+ error: rowErr instanceof Error ? rowErr.message : String(rowErr)
1366
+ });
1367
+ }
1368
+ }
1369
+ if (errors.length === 0 && imported < entities.length) {
1370
+ errors.push({
1371
+ item: `entity-chunk`,
1372
+ error: chunkError instanceof Error ? chunkError.message : String(chunkError)
1373
+ });
1374
+ }
1375
+ return { imported, errors };
1376
+ }
1377
+ async function importRelationshipChunk(conn, repositoryId, group, skipCheck) {
1378
+ if (group.rows.length === 0) return { imported: 0, errors: [] };
1379
+ const rows = group.rows.map((rel) => relationshipToParams(rel));
1380
+ const query = skipCheck ? buildInsertRelationshipsQuery(group.relationshipType) : buildUpsertRelationshipsQuery(group.relationshipType);
1381
+ try {
1382
+ const result = await conn.executeQuery(query, { rows }, { repositoryId });
1383
+ const importedIds = /* @__PURE__ */ new Set();
1384
+ for (const record of result.records) {
1385
+ const id = record.get("id");
1386
+ if (typeof id === "string") importedIds.add(id);
1387
+ }
1388
+ const errors = [];
1389
+ for (const rel of group.rows) {
1390
+ if (!importedIds.has(rel.id)) {
1391
+ errors.push({
1392
+ item: `relationship:${rel.id}`,
1393
+ error: `endpoint not found in repository (source=${rel.sourceEntityId}, target=${rel.targetEntityId})`
1394
+ });
1395
+ }
1396
+ }
1397
+ return { imported: importedIds.size, errors };
1398
+ } catch (err) {
1399
+ return fallbackPerRelationship(conn, repositoryId, group, skipCheck, err);
1400
+ }
1401
+ }
1402
+ async function fallbackPerRelationship(conn, repositoryId, group, skipCheck, chunkError) {
1403
+ const errors = [];
1404
+ let imported = 0;
1405
+ const query = skipCheck ? buildInsertRelationshipsQuery(group.relationshipType) : buildUpsertRelationshipsQuery(group.relationshipType);
1406
+ for (const rel of group.rows) {
1407
+ try {
1408
+ const rows = [relationshipToParams(rel)];
1409
+ const result = await conn.executeQuery(query, { rows }, { repositoryId });
1410
+ if (result.records.length === 1) {
1411
+ imported++;
1412
+ } else {
1413
+ errors.push({
1414
+ item: `relationship:${rel.id}`,
1415
+ error: `endpoint not found in repository (source=${rel.sourceEntityId}, target=${rel.targetEntityId})`
1416
+ });
1417
+ }
1418
+ } catch (rowErr) {
1419
+ errors.push({
1420
+ item: `relationship:${rel.id}`,
1421
+ error: rowErr instanceof Error ? rowErr.message : String(rowErr)
1422
+ });
1423
+ }
1424
+ }
1425
+ if (errors.length === 0 && imported < group.rows.length) {
1426
+ errors.push({
1427
+ item: `relationship-chunk:${group.relationshipType}`,
1428
+ error: chunkError instanceof Error ? chunkError.message : String(chunkError)
1429
+ });
1430
+ }
1431
+ return { imported, errors };
1432
+ }
1433
+ function sliceIntoChunks(items, chunkSize) {
1434
+ if (items.length === 0) return [];
1435
+ const out = [];
1436
+ for (let i = 0; i < items.length; i += chunkSize) {
1437
+ out.push(items.slice(i, i + chunkSize));
1438
+ }
1439
+ return out;
1440
+ }
1441
+ function groupRelationshipsByTypeIntoChunks(relationships, chunkSize) {
1442
+ const grouped = /* @__PURE__ */ new Map();
1443
+ for (const rel of relationships) {
1444
+ const list = grouped.get(rel.relationshipType);
1445
+ if (list) list.push(rel);
1446
+ else grouped.set(rel.relationshipType, [rel]);
1447
+ }
1448
+ const out = [];
1449
+ for (const [relationshipType, rows] of grouped) {
1450
+ for (let i = 0; i < rows.length; i += chunkSize) {
1451
+ out.push({ relationshipType, rows: rows.slice(i, i + chunkSize) });
1452
+ }
1453
+ }
1454
+ return out;
1455
+ }
1456
+ async function runBounded(items, concurrency, fn) {
1457
+ if (items.length === 0) return [];
1458
+ if (concurrency < 1) {
1459
+ throw new ProviderError5(`runBounded: concurrency must be >= 1 (got ${concurrency}).`);
1460
+ }
1461
+ const cap = Math.min(concurrency, items.length);
1462
+ const results = new Array(items.length);
1463
+ let nextIndex = 0;
1464
+ const workers = [];
1465
+ for (let w = 0; w < cap; w++) {
1466
+ workers.push(
1467
+ (async () => {
1468
+ while (true) {
1469
+ const i = nextIndex++;
1470
+ if (i >= items.length) return;
1471
+ results[i] = await fn(items[i]);
1472
+ }
1473
+ })()
1474
+ );
1475
+ }
1476
+ await Promise.all(workers);
1477
+ return results;
1478
+ }
1479
+
1480
+ // src/queries/entity.ts
1481
+ import { EntityNotFoundError, ProviderError as ProviderError6 } from "@utaba/deep-memory";
1482
+ var ENTITY_CREATE_QUERY = `
1483
+ CREATE (n:_Entity {
1484
+ repositoryId: $rid,
1485
+ id: $id,
1486
+ entityType: $entityType,
1487
+ label: $label,
1488
+ slug: $slug,
1489
+ summary: $summary,
1490
+ properties: $properties,
1491
+ data: $data,
1492
+ dataFormat: $dataFormat,
1493
+ embedding: $embedding,
1494
+ createdBy: $createdBy,
1495
+ createdByType: $createdByType,
1496
+ createdAt: $createdAt,
1497
+ createdInConversation: $createdInConversation,
1498
+ createdFromMessage: $createdFromMessage,
1499
+ modifiedBy: $modifiedBy,
1500
+ modifiedByType: $modifiedByType,
1501
+ modifiedAt: $modifiedAt,
1502
+ modifiedInConversation: $modifiedInConversation,
1503
+ modifiedFromMessage: $modifiedFromMessage
1504
+ })
1505
+ SET n += $userProperties
1506
+ RETURN n.id AS id
1507
+ `;
1508
+ var ENTITY_PROJECTION_LIGHT = buildEntityProjection();
1509
+ var ENTITY_PROJECTION_FULL = buildEntityProjection({ loadEmbeddings: true });
1510
+ var ENTITY_GET_QUERY_LIGHT = `MATCH (n:_Entity {repositoryId: $rid, id: $id}) RETURN ${ENTITY_PROJECTION_LIGHT}`;
1511
+ var ENTITY_GET_QUERY_FULL = `MATCH (n:_Entity {repositoryId: $rid, id: $id}) RETURN ${ENTITY_PROJECTION_FULL}`;
1512
+ var ENTITY_GET_BY_SLUG_QUERY_LIGHT = `MATCH (n:_Entity {repositoryId: $rid, slug: $slug}) RETURN ${ENTITY_PROJECTION_LIGHT}`;
1513
+ var ENTITY_GET_BY_SLUG_QUERY_FULL = `MATCH (n:_Entity {repositoryId: $rid, slug: $slug}) RETURN ${ENTITY_PROJECTION_FULL}`;
1514
+ var ENTITY_GET_MANY_QUERY_LIGHT = `MATCH (n:_Entity {repositoryId: $rid}) WHERE n.id IN $ids RETURN ${ENTITY_PROJECTION_LIGHT}`;
1515
+ var ENTITY_GET_MANY_QUERY_FULL = `MATCH (n:_Entity {repositoryId: $rid}) WHERE n.id IN $ids RETURN ${ENTITY_PROJECTION_FULL}`;
1516
+ async function createEntity(conn, repositoryId, entity) {
1517
+ const userProperties = entityUserPropertyParams(entity.properties);
1518
+ try {
1519
+ await conn.executeQuery(
1520
+ ENTITY_CREATE_QUERY,
1521
+ { ...entityToParams(entity), userProperties },
1522
+ { repositoryId }
1523
+ );
1524
+ } catch (err) {
1525
+ mapDriverError(err, {
1526
+ kind: "entity",
1527
+ entityId: entity.id,
1528
+ operation: "createEntity"
1529
+ });
1530
+ }
1531
+ return entity;
1532
+ }
1533
+ async function getEntity(conn, repositoryId, entityId, options) {
1534
+ const query = options?.loadEmbeddings === true ? ENTITY_GET_QUERY_FULL : ENTITY_GET_QUERY_LIGHT;
1535
+ const result = await conn.executeQuery(
1536
+ query,
1537
+ { id: entityId },
1538
+ { repositoryId, routing: "READ" }
1539
+ );
1540
+ const record = result.records[0];
1541
+ if (record === void 0) return null;
1542
+ return entityFromRecord(record);
1543
+ }
1544
+ async function getEntityBySlug(conn, repositoryId, slug, options) {
1545
+ const query = options?.loadEmbeddings === true ? ENTITY_GET_BY_SLUG_QUERY_FULL : ENTITY_GET_BY_SLUG_QUERY_LIGHT;
1546
+ const result = await conn.executeQuery(
1547
+ query,
1548
+ { slug },
1549
+ { repositoryId, routing: "READ" }
1550
+ );
1551
+ const record = result.records[0];
1552
+ if (record === void 0) return null;
1553
+ return entityFromRecord(record);
1554
+ }
1555
+ async function getEntities(conn, repositoryId, entityIds, options) {
1556
+ const map = /* @__PURE__ */ new Map();
1557
+ if (entityIds.length === 0) return map;
1558
+ const query = options?.loadEmbeddings === true ? ENTITY_GET_MANY_QUERY_FULL : ENTITY_GET_MANY_QUERY_LIGHT;
1559
+ const result = await conn.executeQuery(
1560
+ query,
1561
+ { ids: entityIds },
1562
+ { repositoryId, routing: "READ" }
1563
+ );
1564
+ for (const record of result.records) {
1565
+ const entity = entityFromRecord(record);
1566
+ map.set(entity.id, entity);
1567
+ }
1568
+ return map;
1569
+ }
1570
+ async function updateEntity(conn, repositoryId, entityId, updates) {
1571
+ let userProperties = null;
1572
+ if (updates.properties !== void 0) {
1573
+ userProperties = entityUserPropertyParams(updates.properties);
1574
+ }
1575
+ let keysToRemove = [];
1576
+ if (userProperties !== null) {
1577
+ const readResult = await conn.executeQuery(
1578
+ "MATCH (n:_Entity {repositoryId: $rid, id: $id}) RETURN properties(n) AS props",
1579
+ { id: entityId },
1580
+ { repositoryId, routing: "READ" }
1581
+ );
1582
+ const readRecord = readResult.records[0];
1583
+ if (readRecord === void 0) throw new EntityNotFoundError(entityId);
1584
+ const props = readRecord.get("props");
1585
+ const existingProps = typeof props === "object" && props !== null && !Array.isArray(props) ? props : {};
1586
+ const existingUserKeys = Object.keys(existingProps).filter(
1587
+ (k) => !RESERVED_ENTITY_PROPERTY_KEYS.has(k)
1588
+ );
1589
+ keysToRemove = existingUserKeys.filter((k) => !(k in userProperties));
1590
+ }
1591
+ const setParts = [];
1592
+ const params = { id: entityId };
1593
+ const setField = (key, paramName, value) => {
1594
+ setParts.push(`n.${key} = $${paramName}`);
1595
+ params[paramName] = value;
1596
+ };
1597
+ if (updates.entityType !== void 0) setField("entityType", "entityType", updates.entityType);
1598
+ if (updates.label !== void 0) setField("label", "label", updates.label);
1599
+ if (updates.slug !== void 0) setField("slug", "slug", updates.slug);
1600
+ if (updates.summary !== void 0) setField("summary", "summary", updates.summary);
1601
+ if (updates.properties !== void 0) {
1602
+ setField("properties", "properties", JSON.stringify(updates.properties));
1603
+ }
1604
+ if (updates.data !== void 0) setField("data", "data", updates.data);
1605
+ if (updates.dataFormat !== void 0) setField("dataFormat", "dataFormat", updates.dataFormat);
1606
+ if (updates.embedding !== void 0) setField("embedding", "embedding", updates.embedding);
1607
+ const p = updates.provenance;
1608
+ setField("modifiedBy", "modifiedBy", p.modifiedBy);
1609
+ setField("modifiedByType", "modifiedByType", p.modifiedByType);
1610
+ setField("modifiedAt", "modifiedAt", p.modifiedAt);
1611
+ if (p.modifiedInConversation !== void 0) {
1612
+ setField("modifiedInConversation", "modifiedInConversation", p.modifiedInConversation);
1613
+ }
1614
+ if (p.modifiedFromMessage !== void 0) {
1615
+ setField("modifiedFromMessage", "modifiedFromMessage", p.modifiedFromMessage);
1616
+ }
1617
+ let userPropsClause = "";
1618
+ if (userProperties !== null) {
1619
+ params["userProperties"] = userProperties;
1620
+ userPropsClause = " SET n += $userProperties";
1621
+ }
1622
+ let removeClause = "";
1623
+ if (keysToRemove.length > 0) {
1624
+ const safeKeys = keysToRemove.map((k) => `n.${assertSafeUserPropertyKey(k)}`);
1625
+ removeClause = ` REMOVE ${safeKeys.join(", ")}`;
1626
+ }
1627
+ const cypher = `MATCH (n:_Entity {repositoryId: $rid, id: $id}) SET ${setParts.join(", ")}${userPropsClause}${removeClause} RETURN ${ENTITY_PROJECTION_LIGHT}`;
1628
+ const result = await conn.executeQuery(cypher, params, { repositoryId });
1629
+ const record = result.records[0];
1630
+ if (record === void 0) throw new EntityNotFoundError(entityId);
1631
+ return entityFromRecord(record);
1632
+ }
1633
+ async function deleteEntity(conn, repositoryId, entityId) {
1634
+ const result = await conn.executeQuery(
1635
+ "MATCH (n:_Entity {repositoryId: $rid, id: $id}) DETACH DELETE n RETURN count(n) AS deleted",
1636
+ { id: entityId },
1637
+ { repositoryId }
1638
+ );
1639
+ const deleted = bigintToSafeNumber(result.records[0]?.get("deleted") ?? 0);
1640
+ if (deleted !== 1) throw new EntityNotFoundError(entityId);
1641
+ }
1642
+ async function deleteEntities(conn, repositoryId, ids) {
1643
+ if (ids.length === 0) return { deleted: [], notFound: [] };
1644
+ const result = await conn.executeQuery(
1645
+ "MATCH (n:_Entity {repositoryId: $rid}) WHERE n.id IN $ids WITH n, n.id AS id DETACH DELETE n RETURN id AS deleted",
1646
+ { ids },
1647
+ { repositoryId }
1648
+ );
1649
+ const deleted = [];
1650
+ for (const record of result.records) {
1651
+ const id = record.get("deleted");
1652
+ if (typeof id === "string") deleted.push(id);
1653
+ }
1654
+ const deletedSet = new Set(deleted);
1655
+ const notFound = ids.filter((id) => !deletedSet.has(id));
1656
+ return { deleted, notFound };
1657
+ }
1658
+ var FIND_PROJECTION_LIGHT = buildEntityProjection({ alias: "n" });
1659
+ var FIND_PROJECTION_FULL = buildEntityProjection({ alias: "n", loadEmbeddings: true });
1660
+ var FIND_PROJECTION_LIGHT_FT = buildEntityProjection({ alias: "node" });
1661
+ var FIND_PROJECTION_FULL_FT = buildEntityProjection({ alias: "node", loadEmbeddings: true });
1662
+ function buildFindEntitiesWhere(query, options) {
1663
+ const { alias, includeRepositoryPredicate } = options;
1664
+ const params = {};
1665
+ const predicates = [];
1666
+ if (includeRepositoryPredicate) {
1667
+ predicates.push(`${alias}.repositoryId = $rid`);
1668
+ }
1669
+ if (query.entityTypes && query.entityTypes.length > 0) {
1670
+ predicates.push(`${alias}.entityType IN $entityTypes`);
1671
+ params["entityTypes"] = query.entityTypes;
1672
+ }
1673
+ if (query.properties) {
1674
+ let i = 0;
1675
+ for (const [key, value] of Object.entries(query.properties)) {
1676
+ assertSafeUserPropertyKey(key);
1677
+ if (!isNativeStorableValue(value)) {
1678
+ throw new ProviderError6(
1679
+ `findEntities property filter "${key}" has a value that Neo4j cannot store as a native scalar \u2014 filters must be strings, finite numbers, booleans, or homogeneous arrays of those. Nested objects and null values live only inside the JSON properties blob and are not predicate-queryable.`
1680
+ );
1681
+ }
1682
+ const paramName = `prop${i}`;
1683
+ predicates.push(`${alias}.${key} = $${paramName}`);
1684
+ params[paramName] = value;
1685
+ i++;
1686
+ }
1687
+ }
1688
+ if (query.provenance) {
1689
+ if (query.provenance.conversationIds && query.provenance.conversationIds.length > 0) {
1690
+ predicates.push(
1691
+ `(${alias}.createdInConversation IN $convIds OR ${alias}.modifiedInConversation IN $convIds)`
1692
+ );
1693
+ params["convIds"] = query.provenance.conversationIds;
1694
+ }
1695
+ if (query.provenance.actors && query.provenance.actors.length > 0) {
1696
+ predicates.push(
1697
+ `(${alias}.createdBy IN $actors OR ${alias}.modifiedBy IN $actors)`
1698
+ );
1699
+ params["actors"] = query.provenance.actors;
1700
+ }
1701
+ if (query.provenance.dateRange) {
1702
+ predicates.push(
1703
+ `((${alias}.createdAt >= $dateFrom AND ${alias}.createdAt <= $dateTo) OR (${alias}.modifiedAt >= $dateFrom AND ${alias}.modifiedAt <= $dateTo))`
1704
+ );
1705
+ params["dateFrom"] = query.provenance.dateRange.from;
1706
+ params["dateTo"] = query.provenance.dateRange.to;
1707
+ }
1708
+ }
1709
+ const cypherWhere = predicates.length > 0 ? `WHERE ${predicates.join(" AND ")}` : "";
1710
+ return { cypherWhere, params };
1711
+ }
1712
+ async function findEntities(conn, repositoryId, query, options) {
1713
+ const loadEmbeddings = options?.loadEmbeddings === true;
1714
+ const skipLimitParams = {
1715
+ skip: BigInt(query.offset),
1716
+ limit: BigInt(query.limit)
1717
+ };
1718
+ let records;
1719
+ let countResult;
1720
+ if (query.searchTerm !== void 0 && query.searchTerm !== "") {
1721
+ const projection = loadEmbeddings ? FIND_PROJECTION_FULL_FT : FIND_PROJECTION_LIGHT_FT;
1722
+ const where = buildFindEntitiesWhere(query, {
1723
+ alias: "node",
1724
+ includeRepositoryPredicate: false
1725
+ });
1726
+ const repoPredicate = "node.repositoryId = $rid";
1727
+ const combinedWhere = where.cypherWhere.length > 0 ? `WHERE ${repoPredicate} AND ${where.cypherWhere.slice("WHERE ".length)}` : `WHERE ${repoPredicate}`;
1728
+ const dataCypher = `CALL db.index.fulltext.queryNodes('dm_entity_text', $term) YIELD node, score ${combinedWhere} RETURN ${projection} ORDER BY score DESC SKIP $skip LIMIT $limit`;
1729
+ const countCypher = `CALL db.index.fulltext.queryNodes('dm_entity_text', $term) YIELD node ${combinedWhere} RETURN count(node) AS total`;
1730
+ const termParam = { term: query.searchTerm };
1731
+ const [dataResult, count] = await Promise.all([
1732
+ conn.executeQuery(
1733
+ dataCypher,
1734
+ { ...where.params, ...termParam, ...skipLimitParams },
1735
+ { repositoryId, routing: "READ" }
1736
+ ),
1737
+ conn.executeQuery(
1738
+ countCypher,
1739
+ { ...where.params, ...termParam },
1740
+ { repositoryId, routing: "READ" }
1741
+ )
1742
+ ]);
1743
+ records = dataResult.records;
1744
+ countResult = count;
1745
+ } else {
1746
+ const projection = loadEmbeddings ? FIND_PROJECTION_FULL : FIND_PROJECTION_LIGHT;
1747
+ const where = buildFindEntitiesWhere(query, {
1748
+ alias: "n",
1749
+ includeRepositoryPredicate: true
1750
+ });
1751
+ const dataCypher = `MATCH (n:_Entity) ${where.cypherWhere} RETURN ${projection} ORDER BY n.id SKIP $skip LIMIT $limit`;
1752
+ const countCypher = `MATCH (n:_Entity) ${where.cypherWhere} RETURN count(n) AS total`;
1753
+ const [dataResult, count] = await Promise.all([
1754
+ conn.executeQuery(
1755
+ dataCypher,
1756
+ { ...where.params, ...skipLimitParams },
1757
+ { repositoryId, routing: "READ" }
1758
+ ),
1759
+ conn.executeQuery(countCypher, where.params, { repositoryId, routing: "READ" })
1760
+ ]);
1761
+ records = dataResult.records;
1762
+ countResult = count;
1763
+ }
1764
+ const items = records.map((record) => entityFromRecord(record));
1765
+ const totalRaw = countResult.records[0]?.get("total");
1766
+ const total = totalRaw === void 0 ? 0 : bigintToSafeNumber(totalRaw);
1767
+ const hasMore = query.offset + items.length < total;
1768
+ return {
1769
+ items,
1770
+ total,
1771
+ hasMore,
1772
+ limit: query.limit,
1773
+ offset: query.offset
1774
+ };
1775
+ }
1776
+ async function deleteEntitiesByType(conn, repositoryId, entityType) {
1777
+ const result = await conn.executeQuery(
1778
+ `MATCH (n:_Entity {repositoryId: $rid, entityType: $entityType})
1779
+ OPTIONAL MATCH (n)-[r]-()
1780
+ WITH collect(DISTINCT n) AS nodes,
1781
+ count(DISTINCT n) AS entities,
1782
+ count(DISTINCT r) AS rels
1783
+ CALL (nodes) {
1784
+ UNWIND nodes AS node
1785
+ DETACH DELETE node
1786
+ }
1787
+ RETURN entities, rels`,
1788
+ { entityType },
1789
+ { repositoryId }
1790
+ );
1791
+ const record = result.records[0];
1792
+ if (record === void 0) {
1793
+ return { deletedEntities: 0, deletedRelationships: 0 };
1794
+ }
1795
+ const deletedEntities = bigintToSafeNumber(record.get("entities") ?? 0);
1796
+ const deletedRelationships = bigintToSafeNumber(record.get("rels") ?? 0);
1797
+ return { deletedEntities, deletedRelationships };
1798
+ }
1799
+
1800
+ // src/queries/relationship.ts
1801
+ import {
1802
+ EntityNotFoundError as EntityNotFoundError2,
1803
+ matchesPropertyFilters
1804
+ } from "@utaba/deep-memory";
1805
+ var RELATIONSHIP_PROJECTION = buildRelationshipProjection();
1806
+ async function createRelationship(conn, repositoryId, relationship) {
1807
+ const relType = assertSafeRelationshipType(relationship.relationshipType);
1808
+ const cypher = `
1809
+ OPTIONAL MATCH (s:_Entity {repositoryId: $rid, id: $sourceEntityId})
1810
+ OPTIONAL MATCH (t:_Entity {repositoryId: $rid, id: $targetEntityId})
1811
+ WITH s, t
1812
+ FOREACH (_ IN CASE WHEN s IS NOT NULL AND t IS NOT NULL THEN [1] ELSE [] END |
1813
+ CREATE (s)-[r:${relType} {
1814
+ repositoryId: $rid,
1815
+ id: $id,
1816
+ relationshipType: $relationshipType,
1817
+ sourceEntityId: $sourceEntityId,
1818
+ targetEntityId: $targetEntityId,
1819
+ properties: $properties,
1820
+ bidirectional: $bidirectional,
1821
+ createdBy: $createdBy,
1822
+ createdByType: $createdByType,
1823
+ createdAt: $createdAt,
1824
+ createdInConversation: $createdInConversation,
1825
+ createdFromMessage: $createdFromMessage,
1826
+ modifiedBy: $modifiedBy,
1827
+ modifiedByType: $modifiedByType,
1828
+ modifiedAt: $modifiedAt,
1829
+ modifiedInConversation: $modifiedInConversation,
1830
+ modifiedFromMessage: $modifiedFromMessage
1831
+ }]->(t)
1832
+ )
1833
+ RETURN s IS NULL AS sMissing, t IS NULL AS tMissing
1834
+ `;
1835
+ const result = await conn.executeQuery(
1836
+ cypher,
1837
+ relationshipToParams(relationship),
1838
+ { repositoryId }
1839
+ );
1840
+ const record = result.records[0];
1841
+ if (record === void 0) {
1842
+ throw new EntityNotFoundError2(relationship.sourceEntityId);
1843
+ }
1844
+ const sMissing = record.get("sMissing") === true;
1845
+ const tMissing = record.get("tMissing") === true;
1846
+ if (sMissing) throw new EntityNotFoundError2(relationship.sourceEntityId);
1847
+ if (tMissing) throw new EntityNotFoundError2(relationship.targetEntityId);
1848
+ return relationship;
1849
+ }
1850
+ async function getRelationship(conn, repositoryId, relationshipId) {
1851
+ const result = await conn.executeQuery(
1852
+ `MATCH ()-[r {repositoryId: $rid, id: $relId}]->() RETURN ${RELATIONSHIP_PROJECTION}`,
1853
+ { relId: relationshipId },
1854
+ { repositoryId, routing: "READ" }
1855
+ );
1856
+ const record = result.records[0];
1857
+ if (record === void 0) return null;
1858
+ return relationshipFromRecord(record);
1859
+ }
1860
+ async function getEntityRelationships(conn, repositoryId, entityId, options) {
1861
+ const limit = options?.limit ?? 50;
1862
+ const offset = options?.offset ?? 0;
1863
+ const direction = options?.direction ?? "both";
1864
+ const propertyFilters = options?.propertyFilters;
1865
+ const hasPropertyFilters = propertyFilters != null && propertyFilters.length > 0;
1866
+ const params = {
1867
+ eid: entityId,
1868
+ offset: BigInt(offset),
1869
+ limit: BigInt(limit)
1870
+ };
1871
+ const typeFilter = options?.relationshipTypes != null && options.relationshipTypes.length > 0 ? buildTypeFilter(options.relationshipTypes, params) : "";
1872
+ const { dataCypher, countCypher } = buildEntityRelationshipQueries(direction, typeFilter);
1873
+ const [dataResult, countResult] = await Promise.all([
1874
+ conn.executeQuery(dataCypher, params, { repositoryId, routing: "READ" }),
1875
+ hasPropertyFilters ? Promise.resolve(null) : conn.executeQuery(countCypher, params, { repositoryId, routing: "READ" })
1876
+ ]);
1877
+ let items = dataResult.records.map((record) => relationshipFromRecord(record));
1878
+ if (hasPropertyFilters) {
1879
+ items = items.filter((rel) => matchesPropertyFilters(rel.properties, propertyFilters));
1880
+ }
1881
+ let total;
1882
+ if (countResult !== null) {
1883
+ const totalRecord = countResult.records[0];
1884
+ total = totalRecord !== void 0 ? bigintToSafeNumber(totalRecord.get("total") ?? 0) : 0;
1885
+ }
1886
+ const hasMore = total !== void 0 ? offset + dataResult.records.length < total : dataResult.records.length === limit;
1887
+ return { items, total, hasMore, limit, offset };
1888
+ }
1889
+ async function deleteRelationship(conn, repositoryId, relationshipId) {
1890
+ await conn.executeQuery(
1891
+ "MATCH ()-[r {repositoryId: $rid, id: $relId}]->() DELETE r",
1892
+ { relId: relationshipId },
1893
+ { repositoryId }
1894
+ );
1895
+ }
1896
+ async function deleteRelationships(conn, repositoryId, ids) {
1897
+ if (ids.length === 0) return { deleted: [], notFound: [] };
1898
+ const result = await conn.executeQuery(
1899
+ "MATCH ()-[r {repositoryId: $rid}]->() WHERE r.id IN $ids WITH r, r.id AS id DELETE r RETURN id AS deleted",
1900
+ { ids },
1901
+ { repositoryId }
1902
+ );
1903
+ const deleted = [];
1904
+ for (const record of result.records) {
1905
+ const id = record.get("deleted");
1906
+ if (typeof id === "string") deleted.push(id);
1907
+ }
1908
+ const deletedSet = new Set(deleted);
1909
+ const notFound = ids.filter((id) => !deletedSet.has(id));
1910
+ return { deleted, notFound };
1911
+ }
1912
+ async function deleteRelationshipsByType(conn, repositoryId, relationshipType) {
1913
+ const relType = assertSafeRelationshipType(relationshipType);
1914
+ const result = await conn.executeQuery(
1915
+ `MATCH ()-[r:${relType} {repositoryId: $rid}]->() WITH r, r.id AS id DELETE r RETURN id`,
1916
+ {},
1917
+ { repositoryId }
1918
+ );
1919
+ return { deletedRelationships: result.records.length };
1920
+ }
1921
+ function buildTypeFilter(relationshipTypes, params) {
1922
+ params["relTypes"] = relationshipTypes;
1923
+ return " WHERE type(r) IN $relTypes";
1924
+ }
1925
+ function buildEntityRelationshipQueries(direction, typeFilter) {
1926
+ if (direction === "both") {
1927
+ const dataCypher2 = `MATCH (e:_Entity {repositoryId: $rid, id: $eid})-[r {repositoryId: $rid}]-()${typeFilter} RETURN ${RELATIONSHIP_PROJECTION} ORDER BY id SKIP $offset LIMIT $limit`;
1928
+ const countCypher2 = `MATCH (e:_Entity {repositoryId: $rid, id: $eid})-[r {repositoryId: $rid}]-()${typeFilter} RETURN count(r) AS total`;
1929
+ return { dataCypher: dataCypher2, countCypher: countCypher2 };
1930
+ }
1931
+ const naturalPattern = direction === "out" ? "(e:_Entity {repositoryId: $rid, id: $eid})-[r {repositoryId: $rid}]->()" : "()-[r {repositoryId: $rid}]->(e:_Entity {repositoryId: $rid, id: $eid})";
1932
+ const bidirPattern = direction === "out" ? "()-[r {repositoryId: $rid, bidirectional: true}]->(e:_Entity {repositoryId: $rid, id: $eid})" : "(e:_Entity {repositoryId: $rid, id: $eid})-[r {repositoryId: $rid, bidirectional: true}]->()";
1933
+ const dataCypher = `CALL () {
1934
+ MATCH ${naturalPattern}${typeFilter} RETURN ${RELATIONSHIP_PROJECTION}
1935
+ UNION ALL
1936
+ MATCH ${bidirPattern}${typeFilter} RETURN ${RELATIONSHIP_PROJECTION}
1937
+ }
1938
+ RETURN ${reprojectFromCallScope()} ORDER BY id SKIP $offset LIMIT $limit`;
1939
+ const countCypher = `CALL () {
1940
+ MATCH ${naturalPattern}${typeFilter} RETURN r.id AS rid
1941
+ UNION ALL
1942
+ MATCH ${bidirPattern}${typeFilter} RETURN r.id AS rid
1943
+ }
1944
+ RETURN count(rid) AS total`;
1945
+ return { dataCypher, countCypher };
1946
+ }
1947
+ function reprojectFromCallScope() {
1948
+ return RELATIONSHIP_PROJECTION.split(",").map((part) => {
1949
+ const segments = part.trim().split(" AS ");
1950
+ const fieldName = (segments[1] ?? segments[0] ?? "").trim();
1951
+ return `${fieldName} AS ${fieldName}`;
1952
+ }).join(", ");
1953
+ }
1954
+
1955
+ // src/queries/repository.ts
1956
+ var ENTITY_STATS_QUERY = `
1957
+ MATCH (n:_Entity {repositoryId: $rid})
1958
+ RETURN n.entityType AS type, count(n) AS count
1959
+ `;
1960
+ var RELATIONSHIP_STATS_QUERY = `
1961
+ MATCH (:_Entity {repositoryId: $rid})-[r {repositoryId: $rid}]->(:_Entity {repositoryId: $rid})
1962
+ RETURN type(r) AS type, count(r) AS count
1963
+ `;
1964
+ async function getRepositoryStats(conn, repositoryId, vocabulary) {
1965
+ const [entityResult, relationshipResult] = await Promise.all([
1966
+ conn.executeQuery(ENTITY_STATS_QUERY, {}, { repositoryId, routing: "READ" }),
1967
+ conn.executeQuery(RELATIONSHIP_STATS_QUERY, {}, { repositoryId, routing: "READ" })
1968
+ ]);
1969
+ const entityTypeBreakdown = {};
1970
+ let entityCount = 0;
1971
+ for (const record of entityResult.records) {
1972
+ const type = record.get("type");
1973
+ if (typeof type !== "string") continue;
1974
+ const count = bigintToSafeNumber(record.get("count") ?? 0);
1975
+ entityTypeBreakdown[type] = count;
1976
+ entityCount += count;
1977
+ }
1978
+ const relationshipTypeBreakdown = {};
1979
+ let relationshipCount = 0;
1980
+ for (const record of relationshipResult.records) {
1981
+ const type = record.get("type");
1982
+ if (typeof type !== "string") continue;
1983
+ const count = bigintToSafeNumber(record.get("count") ?? 0);
1984
+ relationshipTypeBreakdown[type] = count;
1985
+ relationshipCount += count;
1986
+ }
1987
+ return {
1988
+ entityCount,
1989
+ relationshipCount,
1990
+ vocabularyVersion: vocabulary.version,
1991
+ entityTypeBreakdown,
1992
+ relationshipTypeBreakdown
1993
+ };
1994
+ }
1995
+
1996
+ // src/queries/timeline.ts
1997
+ var ENTITY_CREATED = "entity:created";
1998
+ var ENTITY_UPDATED = "entity:updated";
1999
+ var RELATIONSHIP_CREATED = "relationship:created";
2000
+ var TIMELINE_QUERY = `
2001
+ MATCH (n:_Entity {repositoryId: $rid, id: $id})
2002
+ OPTIONAL MATCH (n)-[r {repositoryId: $rid}]-()
2003
+ WITH n, collect(DISTINCT r) AS rels
2004
+ RETURN
2005
+ n.createdAt AS createdAt,
2006
+ n.modifiedAt AS modifiedAt,
2007
+ [x IN rels WHERE x IS NOT NULL | {id: x.id, createdAt: x.createdAt}] AS rels
2008
+ `;
2009
+ async function getTimeline(conn, repositoryId, entityId, options) {
2010
+ const result = await conn.executeQuery(
2011
+ TIMELINE_QUERY,
2012
+ { id: entityId },
2013
+ { repositoryId, routing: "READ" }
2014
+ );
2015
+ const record = result.records[0];
2016
+ if (record === void 0) {
2017
+ return { events: [], total: 0 };
2018
+ }
2019
+ const createdAt = record.get("createdAt");
2020
+ const modifiedAt = record.get("modifiedAt");
2021
+ const relsRaw = record.get("rels");
2022
+ const events = [];
2023
+ if (typeof createdAt === "string" && createdAt.length > 0) {
2024
+ events.push({ timestamp: createdAt, eventType: ENTITY_CREATED, entityId });
2025
+ }
2026
+ if (typeof modifiedAt === "string" && modifiedAt.length > 0 && modifiedAt !== createdAt) {
2027
+ events.push({ timestamp: modifiedAt, eventType: ENTITY_UPDATED, entityId });
2028
+ }
2029
+ if (Array.isArray(relsRaw)) {
2030
+ for (const item of relsRaw) {
2031
+ if (item === null || typeof item !== "object") continue;
2032
+ const rel = item;
2033
+ const relId = rel.id;
2034
+ const relCreatedAt = rel.createdAt;
2035
+ if (typeof relId !== "string" || typeof relCreatedAt !== "string") continue;
2036
+ events.push({
2037
+ timestamp: relCreatedAt,
2038
+ eventType: RELATIONSHIP_CREATED,
2039
+ entityId,
2040
+ relationshipId: relId
2041
+ });
2042
+ }
2043
+ }
2044
+ let filtered = events;
2045
+ if (options.timeRange) {
2046
+ const from = options.timeRange.from;
2047
+ const to = options.timeRange.to;
2048
+ filtered = filtered.filter((e) => e.timestamp >= from && e.timestamp <= to);
2049
+ }
2050
+ if (options.eventTypes && options.eventTypes.length > 0) {
2051
+ const allow = new Set(options.eventTypes);
2052
+ filtered = filtered.filter((e) => allow.has(e.eventType));
2053
+ }
2054
+ filtered.sort((a, b) => a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0);
2055
+ const total = filtered.length;
2056
+ const paged = filtered.slice(options.offset, options.offset + options.limit);
2057
+ return { events: paged, total };
2058
+ }
2059
+
2060
+ // src/queries/vocabulary.ts
2061
+ function emptyVocabulary() {
2062
+ return {
2063
+ version: "0.0.0",
2064
+ lastModified: (/* @__PURE__ */ new Date()).toISOString(),
2065
+ modifiedBy: "system",
2066
+ entityTypes: [],
2067
+ relationshipTypes: []
2068
+ };
2069
+ }
2070
+ async function getVocabulary(conn, repositoryId) {
2071
+ const result = await conn.executeQuery(
2072
+ "MATCH (v:_Vocabulary {repositoryId: $rid}) RETURN v.vocabulary AS json",
2073
+ {},
2074
+ { repositoryId, routing: "READ" }
2075
+ );
2076
+ const record = result.records[0];
2077
+ if (record === void 0) return emptyVocabulary();
2078
+ const raw = record.get("json");
2079
+ if (typeof raw !== "string" || raw === "") return emptyVocabulary();
2080
+ try {
2081
+ return JSON.parse(raw);
2082
+ } catch {
2083
+ return emptyVocabulary();
2084
+ }
2085
+ }
2086
+ async function saveVocabulary(conn, repositoryId, vocabulary) {
2087
+ await conn.executeQuery(
2088
+ "MERGE (v:_Vocabulary {repositoryId: $rid}) SET v.vocabulary = $json",
2089
+ { json: JSON.stringify(vocabulary) },
2090
+ { repositoryId }
2091
+ );
2092
+ }
2093
+ async function getVocabularyChangeLog(conn, repositoryId, options) {
2094
+ const limit = options?.limit ?? 10;
2095
+ const offset = options?.offset ?? 0;
2096
+ const [dataResult, countResult] = await Promise.all([
2097
+ conn.executeQuery(
2098
+ `MATCH (e:_VocabularyChangeLog {repositoryId: $rid})
2099
+ RETURN e
2100
+ ORDER BY e.proposedAt DESC
2101
+ SKIP $offset LIMIT $limit`,
2102
+ { offset: BigInt(offset), limit: BigInt(limit) },
2103
+ { repositoryId, routing: "READ" }
2104
+ ),
2105
+ conn.executeQuery(
2106
+ "MATCH (e:_VocabularyChangeLog {repositoryId: $rid}) RETURN count(e) AS total",
2107
+ {},
2108
+ { repositoryId, routing: "READ" }
2109
+ )
2110
+ ]);
2111
+ const items = dataResult.records.map((record) => changeRecordFromRecord(record, "e"));
2112
+ const totalRaw = countResult.records[0]?.get("total");
2113
+ const total = totalRaw === void 0 ? 0 : bigintToSafeNumber(totalRaw);
2114
+ return {
2115
+ items,
2116
+ total,
2117
+ hasMore: offset + items.length < total,
2118
+ limit,
2119
+ offset
2120
+ };
2121
+ }
2122
+
2123
+ // src/schema.ts
2124
+ var SCHEMA_VERSION = 1;
2125
+ function getSchemaCypher() {
2126
+ return [
2127
+ // Composite node-property uniqueness on (repositoryId, id) — the load-bearing
2128
+ // primary discriminator for every entity-scoped query.
2129
+ `CREATE CONSTRAINT dm_entity_unique IF NOT EXISTS
2130
+ FOR (n:_Entity) REQUIRE (n.repositoryId, n.id) IS UNIQUE`,
2131
+ // Composite uniqueness on (repositoryId, slug) — backs getEntityBySlug
2132
+ // and guarantees stable URL-style addressing within a repository.
2133
+ `CREATE CONSTRAINT dm_entity_slug_unique IF NOT EXISTS
2134
+ FOR (n:_Entity) REQUIRE (n.repositoryId, n.slug) IS UNIQUE`,
2135
+ // Repository uniqueness — backs getRepository and listRepositories.
2136
+ `CREATE CONSTRAINT dm_repository_unique IF NOT EXISTS
2137
+ FOR (n:_Repository) REQUIRE n.repositoryId IS UNIQUE`,
2138
+ // Range index (default for `CREATE INDEX` without a type keyword) covering
2139
+ // (repositoryId, entityType) — backs findEntities type-filter and
2140
+ // deleteEntitiesByType.
2141
+ `CREATE INDEX dm_entity_type_lookup IF NOT EXISTS
2142
+ FOR (n:_Entity) ON (n.repositoryId, n.entityType)`,
2143
+ // Range index on (repositoryId, modifiedAt) — backs timeline and recency
2144
+ // ordering. Date is stored as ISO-8601 string (D6), lexicographic order
2145
+ // matches chronological order.
2146
+ `CREATE INDEX dm_entity_modified IF NOT EXISTS
2147
+ FOR (n:_Entity) ON (n.repositoryId, n.modifiedAt)`,
2148
+ // Fulltext index on entity label + summary — backs the findEntities
2149
+ // searchTerm branch via CALL db.index.fulltext.queryNodes(...). Unfiltered
2150
+ // by design; query callers must post-filter by repositoryId (see D3b).
2151
+ `CREATE FULLTEXT INDEX dm_entity_text IF NOT EXISTS
2152
+ FOR (n:_Entity) ON EACH [n.label, n.summary]`
2153
+ ];
2154
+ }
2155
+
2156
+ // src/Neo4jStorageProvider.ts
2157
+ var PROVIDER_NAME = "neo4j";
2158
+ var DELETE_BATCH_SIZE = 500;
2159
+ var VOCABULARY_CACHE_TTL_MS = 6e4;
2160
+ var TRACKED_METHODS = {
2161
+ ensureSchema: () => void 0,
2162
+ createRepository: (args) => {
2163
+ const cfg = args[0];
2164
+ return cfg?.repositoryId;
2165
+ },
2166
+ getRepository: (args) => args[0],
2167
+ listRepositories: () => void 0,
2168
+ updateRepository: (args) => args[0],
2169
+ deleteRepository: (args) => args[0],
2170
+ deleteAllContents: (args) => args[0],
2171
+ getVocabulary: (args) => args[0],
2172
+ saveVocabulary: (args) => args[0],
2173
+ getVocabularyChangeLog: (args) => args[0],
2174
+ createEntity: (args) => args[0],
2175
+ getEntity: (args) => args[0],
2176
+ getEntityBySlug: (args) => args[0],
2177
+ getEntities: (args) => args[0],
2178
+ updateEntity: (args) => args[0],
2179
+ deleteEntity: (args) => args[0],
2180
+ deleteEntities: (args) => args[0],
2181
+ deleteEntitiesByType: (args) => args[0],
2182
+ findEntities: (args) => args[0],
2183
+ createRelationship: (args) => args[0],
2184
+ getRelationship: (args) => args[0],
2185
+ getEntityRelationships: (args) => args[0],
2186
+ deleteRelationship: (args) => args[0],
2187
+ deleteRelationships: (args) => args[0],
2188
+ deleteRelationshipsByType: (args) => args[0],
2189
+ traverse: (args) => args[0],
2190
+ exploreNeighborhood: (args) => args[0],
2191
+ findPaths: (args) => args[0],
2192
+ getTimeline: (args) => args[0],
2193
+ getRepositoryStats: (args) => args[0],
2194
+ importBulk: (args) => args[0],
2195
+ // `executeNativeQuery` is cross-repository by design. The first positional
2196
+ // argument is `repositoryId` for interface symmetry but is intentionally
2197
+ // not stamped on the sink record — the call is not scoped to one repository.
2198
+ executeNativeQuery: () => void 0
2199
+ // `exportAll` is intentionally omitted from this map. The Proxy emits one
2200
+ // sink record at promise resolution; an `AsyncIterable` returns
2201
+ // synchronously and is consumed across an arbitrary number of awaits, so
2202
+ // a single emit at method-return would fire BEFORE any chunk had streamed
2203
+ // and the sink would carry zero round-trips. `exportAll` opens its own
2204
+ // usage scope via `trackIterable` and emits when the iterator drains.
2205
+ };
2206
+ var META_KEY = "schema";
2207
+ var Neo4jStorageProvider = class {
2208
+ connection;
2209
+ traversalExecutor;
2210
+ initialized = false;
2211
+ /**
2212
+ * In-process vocabulary cache. Reads hit this map first; writes inside this
2213
+ * process invalidate the entry so cache hits stay coherent with the local
2214
+ * write. Cross-process staleness is bounded by `VOCABULARY_CACHE_TTL_MS`.
2215
+ */
2216
+ vocabularyCache = /* @__PURE__ */ new Map();
2217
+ /**
2218
+ * Captured at construction so `exportAll`'s `trackIterable` can emit
2219
+ * outside the Proxy's promise-resolution path. The Proxy itself relies on
2220
+ * the same `safeSink` captured by closure; this field exists for the
2221
+ * streaming-iterator case only.
2222
+ */
2223
+ reportUsage;
2224
+ constructor(config) {
2225
+ this.connection = new Neo4jConnection(config);
2226
+ this.traversalExecutor = new Neo4jTraversalExecutor(this.connection, {
2227
+ profileTraversals: config.profileTraversals === true
2228
+ });
2229
+ const safeSink = createSafeSink(config.reportUsage);
2230
+ this.reportUsage = safeSink;
2231
+ if (safeSink) {
2232
+ return new Proxy(this, {
2233
+ get(target, prop, receiver) {
2234
+ const value = Reflect.get(target, prop, receiver);
2235
+ if (typeof prop !== "string" || typeof value !== "function") return value;
2236
+ const extractRepoId = TRACKED_METHODS[prop];
2237
+ if (!extractRepoId) return value;
2238
+ const method = value;
2239
+ return (...args) => {
2240
+ const scope = createUsageScope();
2241
+ const repositoryId = extractRepoId(args);
2242
+ const emit = () => {
2243
+ safeSink({
2244
+ provider: PROVIDER_NAME,
2245
+ operation: prop,
2246
+ unit: "server_ms",
2247
+ value: scope.serverMs,
2248
+ ...repositoryId !== void 0 ? { repositoryId } : {},
2249
+ timestamp: /* @__PURE__ */ new Date(),
2250
+ details: buildUsageDetails(scope)
2251
+ });
2252
+ };
2253
+ return runInUsageScope(scope, () => {
2254
+ let result;
2255
+ try {
2256
+ result = method.apply(target, args);
2257
+ } catch (err) {
2258
+ emit();
2259
+ throw err;
2260
+ }
2261
+ if (result && typeof result.then === "function") {
2262
+ return result.then(
2263
+ (v) => {
2264
+ emit();
2265
+ return v;
2266
+ },
2267
+ (err) => {
2268
+ emit();
2269
+ throw err;
2270
+ }
2271
+ );
2272
+ }
2273
+ emit();
2274
+ return result;
2275
+ });
2276
+ };
2277
+ }
2278
+ });
2279
+ }
2280
+ }
2281
+ // ─── Lifecycle ─────────────────────────────────────────────────────
2282
+ async initialize() {
2283
+ if (this.initialized) return;
2284
+ await this.connection.verifyConnectivity();
2285
+ this.initialized = true;
2286
+ }
2287
+ async dispose() {
2288
+ await this.connection.close();
2289
+ this.initialized = false;
2290
+ }
2291
+ // ─── Schema ────────────────────────────────────────────────────────
2292
+ /**
2293
+ * Idempotent constraint / index DDL plus a single `_Meta` schema-version
2294
+ * handshake. Safe to call repeatedly — `CREATE ... IF NOT EXISTS` makes
2295
+ * each statement a no-op on subsequent runs.
2296
+ *
2297
+ * Neo4j Community has no per-tenant database concept — `databaseCreated`
2298
+ * is always `false`. Operators are responsible for the target database
2299
+ * existing before the provider connects.
2300
+ */
2301
+ async ensureSchema() {
2302
+ const currentVersion = await this.readSchemaVersion();
2303
+ if (currentVersion !== null && currentVersion > SCHEMA_VERSION) {
2304
+ throw new ProviderError7(
2305
+ `Database schema version ${currentVersion} is newer than provider version ${SCHEMA_VERSION}. Update the @utaba/deep-memory-storage-neo4j package.`
2306
+ );
2307
+ }
2308
+ if (currentVersion === SCHEMA_VERSION) {
2309
+ return {
2310
+ databaseCreated: false,
2311
+ schemaCreated: false,
2312
+ alreadyUpToDate: true,
2313
+ schemaVersion: SCHEMA_VERSION
2314
+ };
2315
+ }
2316
+ for (const statement of getSchemaCypher()) {
2317
+ await this.connection.executeSystemDdl(statement);
2318
+ }
2319
+ await this.writeSchemaVersion(SCHEMA_VERSION);
2320
+ return {
2321
+ databaseCreated: false,
2322
+ schemaCreated: true,
2323
+ alreadyUpToDate: false,
2324
+ schemaVersion: SCHEMA_VERSION
2325
+ };
2326
+ }
2327
+ async readSchemaVersion() {
2328
+ const result = await this.connection.executeSystemQuery(
2329
+ "MATCH (m:_Meta {key: $key}) RETURN m.schemaVersion AS schemaVersion",
2330
+ { key: META_KEY },
2331
+ { crossRepository: true, routing: "READ" }
2332
+ );
2333
+ const record = result.records[0];
2334
+ if (record === void 0) return null;
2335
+ const raw = record.get("schemaVersion");
2336
+ if (typeof raw === "bigint") {
2337
+ if (raw > BigInt(Number.MAX_SAFE_INTEGER)) {
2338
+ throw new ProviderError7(
2339
+ `_Meta.schemaVersion (${raw.toString()}) exceeds Number.MAX_SAFE_INTEGER.`
2340
+ );
2341
+ }
2342
+ return Number(raw);
2343
+ }
2344
+ if (typeof raw === "number") return raw;
2345
+ if (raw === null) return null;
2346
+ throw new ProviderError7(
2347
+ `_Meta.schemaVersion has unexpected type ${typeof raw}; expected bigint or number.`
2348
+ );
2349
+ }
2350
+ async writeSchemaVersion(version) {
2351
+ await this.connection.executeSystemQuery(
2352
+ "MERGE (m:_Meta {key: $key}) SET m.schemaVersion = $version",
2353
+ { key: META_KEY, version },
2354
+ { crossRepository: true }
2355
+ );
2356
+ }
2357
+ // ─── Repository ────────────────────────────────────────────────────
2358
+ /**
2359
+ * Create a new repository. Fixed-shape `CREATE` template — every optional
2360
+ * field is bound on every call so the server plan-caches one entry across
2361
+ * all repository creates. Optional fields bound as `null` are not persisted
2362
+ * (Cypher drops null property values on write — symmetric with read).
2363
+ *
2364
+ * The `(:_Repository) REQUIRE n.repositoryId IS UNIQUE` constraint surfaces
2365
+ * duplicates as `Neo.ClientError.Schema.ConstraintValidationFailed`, which
2366
+ * `mapDriverError({ kind: 'repository', ... })` routes to
2367
+ * `DuplicateRepositoryError`.
2368
+ */
2369
+ async createRepository(config) {
2370
+ try {
2371
+ await this.connection.executeQuery(
2372
+ `CREATE (r:_Repository {
2373
+ repositoryId: $rid,
2374
+ type: $type,
2375
+ label: $label,
2376
+ description: $description,
2377
+ legal: $legal,
2378
+ owner: $owner,
2379
+ governanceConfig: $governanceConfig,
2380
+ metadata: $metadata,
2381
+ createdAt: $createdAt,
2382
+ createdBy: $createdBy
2383
+ })`,
2384
+ repositoryCreateParams(config),
2385
+ { repositoryId: config.repositoryId }
2386
+ );
2387
+ } catch (err) {
2388
+ mapDriverError(err, {
2389
+ kind: "repository",
2390
+ repositoryId: config.repositoryId,
2391
+ operation: "createRepository"
2392
+ });
2393
+ }
2394
+ const result = {
2395
+ repositoryId: config.repositoryId,
2396
+ label: config.label,
2397
+ governanceConfig: config.governanceConfig,
2398
+ createdAt: config.createdAt,
2399
+ createdBy: config.createdBy
2400
+ };
2401
+ if (config.type !== void 0) result.type = config.type;
2402
+ if (config.description !== void 0) result.description = config.description;
2403
+ if (config.legal !== void 0) result.legal = config.legal;
2404
+ if (config.owner !== void 0) result.owner = config.owner;
2405
+ if (config.metadata !== void 0) result.metadata = config.metadata;
2406
+ return result;
2407
+ }
2408
+ async getRepository(repositoryId) {
2409
+ const result = await this.connection.executeQuery(
2410
+ "MATCH (r:_Repository {repositoryId: $rid}) RETURN r",
2411
+ {},
2412
+ { repositoryId, routing: "READ" }
2413
+ );
2414
+ const record = result.records[0];
2415
+ if (record === void 0) return null;
2416
+ return repositoryFromRecord(record);
2417
+ }
2418
+ async listRepositories(filter) {
2419
+ const limit = filter?.limit ?? 20;
2420
+ const offset = filter?.offset ?? 0;
2421
+ const typeFilter = filter?.type;
2422
+ const wherePredicates = [];
2423
+ const params = { offset: BigInt(offset), limit: BigInt(limit) };
2424
+ if (typeFilter !== void 0) {
2425
+ wherePredicates.push("r.type = $filterType");
2426
+ params["filterType"] = typeFilter;
2427
+ }
2428
+ const whereClause = wherePredicates.length > 0 ? `WHERE ${wherePredicates.join(" AND ")}` : "";
2429
+ const [dataResult, countResult] = await Promise.all([
2430
+ this.connection.executeSystemQuery(
2431
+ `MATCH (r:_Repository) ${whereClause} RETURN r ORDER BY r.repositoryId SKIP $offset LIMIT $limit`,
2432
+ params,
2433
+ { crossRepository: true, routing: "READ" }
2434
+ ),
2435
+ this.connection.executeSystemQuery(
2436
+ `MATCH (r:_Repository) ${whereClause} RETURN count(r) AS total`,
2437
+ typeFilter !== void 0 ? { filterType: typeFilter } : {},
2438
+ { crossRepository: true, routing: "READ" }
2439
+ )
2440
+ ]);
2441
+ const items = dataResult.records.map((record) => repositorySummaryFromRecord(record));
2442
+ const totalRaw = countResult.records[0]?.get("total");
2443
+ const total = totalRaw === void 0 ? 0 : bigintToSafeNumber(totalRaw);
2444
+ return {
2445
+ items,
2446
+ total,
2447
+ hasMore: offset + items.length < total,
2448
+ limit,
2449
+ offset
2450
+ };
2451
+ }
2452
+ /**
2453
+ * Variable-shape Cypher (per D23 trade-off — repository writes are rare so
2454
+ * the plan-cache cost is negligible). Projection-on-write returns the
2455
+ * updated row in one round-trip; empty-rowset → `RepositoryNotFoundError`.
2456
+ */
2457
+ async updateRepository(repositoryId, updates) {
2458
+ const setClauses = [];
2459
+ const params = {};
2460
+ if (updates.label !== void 0) {
2461
+ setClauses.push("r.label = $label");
2462
+ params["label"] = updates.label;
2463
+ }
2464
+ if (updates.description !== void 0) {
2465
+ setClauses.push("r.description = $description");
2466
+ params["description"] = updates.description;
2467
+ }
2468
+ if (updates.type !== void 0) {
2469
+ setClauses.push("r.type = $type");
2470
+ params["type"] = updates.type;
2471
+ }
2472
+ if (updates.legal !== void 0) {
2473
+ setClauses.push("r.legal = $legal");
2474
+ params["legal"] = updates.legal;
2475
+ }
2476
+ if (updates.owner !== void 0) {
2477
+ setClauses.push("r.owner = $owner");
2478
+ params["owner"] = updates.owner;
2479
+ }
2480
+ if (updates.governanceConfig !== void 0) {
2481
+ setClauses.push("r.governanceConfig = $governanceConfig");
2482
+ params["governanceConfig"] = JSON.stringify(updates.governanceConfig);
2483
+ }
2484
+ if (updates.metadata !== void 0) {
2485
+ const existing = await this.getRepository(repositoryId);
2486
+ if (existing === null) throw new RepositoryNotFoundError(repositoryId);
2487
+ const merged = { ...existing.metadata, ...updates.metadata };
2488
+ setClauses.push("r.metadata = $metadata");
2489
+ params["metadata"] = JSON.stringify(merged);
2490
+ }
2491
+ if (setClauses.length === 0) {
2492
+ const existing = await this.getRepository(repositoryId);
2493
+ if (existing === null) throw new RepositoryNotFoundError(repositoryId);
2494
+ return existing;
2495
+ }
2496
+ const cypher = `MATCH (r:_Repository {repositoryId: $rid}) SET ${setClauses.join(", ")} RETURN r`;
2497
+ const result = await this.connection.executeQuery(cypher, params, { repositoryId });
2498
+ const record = result.records[0];
2499
+ if (record === void 0) throw new RepositoryNotFoundError(repositoryId);
2500
+ return repositoryFromRecord(record);
2501
+ }
2502
+ /**
2503
+ * Drop every node and relationship scoped to `repositoryId`, including the
2504
+ * `_Repository` node itself. Two-stage chunked wipe driven by app-side loops
2505
+ * so the progress callback fires at a useful cadence:
2506
+ *
2507
+ * 1. Drain relationships in batches via `CALL ( ) { ... } IN TRANSACTIONS`.
2508
+ * 2. Drain nodes (entities + system) in batches via the same form with
2509
+ * `DETACH DELETE` (catches any straggler edges).
2510
+ *
2511
+ * `IN TRANSACTIONS` can only run on auto-commit sessions — `executeWrite`
2512
+ * fails with `Neo.DatabaseError.Transaction.TransactionStartFailed` per
2513
+ * probe P13. The chokepoint's `executeImplicitInTransactions` is the only
2514
+ * legitimate entry point for this pattern.
2515
+ */
2516
+ async deleteRepository(repositoryId, onProgress) {
2517
+ const { totalEntities, totalRelationships } = await this.countRepositoryContents(repositoryId);
2518
+ let relationshipsDeleted = 0;
2519
+ let entitiesDeleted = 0;
2520
+ while (true) {
2521
+ const summary = await this.connection.executeImplicitInTransactions(
2522
+ `CALL () {
2523
+ MATCH ()-[r {repositoryId: $rid}]-()
2524
+ WITH r LIMIT $batchSize
2525
+ DELETE r
2526
+ } IN TRANSACTIONS OF $batchSize ROWS`,
2527
+ // BigInt so the Cypher LIMIT clause sees a Cypher INTEGER, not FLOAT.
2528
+ { batchSize: BigInt(DELETE_BATCH_SIZE) },
2529
+ { repositoryId }
2530
+ );
2531
+ const stats = summary.counters.updates();
2532
+ const deletedThisBatch = stats["relationshipsDeleted"] ?? 0;
2533
+ if (deletedThisBatch === 0) break;
2534
+ relationshipsDeleted = Math.min(relationshipsDeleted + deletedThisBatch, totalRelationships);
2535
+ await onProgress?.({ entitiesDeleted, relationshipsDeleted, totalEntities, totalRelationships });
2536
+ }
2537
+ while (true) {
2538
+ const summary = await this.connection.executeImplicitInTransactions(
2539
+ `CALL () {
2540
+ MATCH (n {repositoryId: $rid})
2541
+ WITH n LIMIT $batchSize
2542
+ DETACH DELETE n
2543
+ } IN TRANSACTIONS OF $batchSize ROWS`,
2544
+ // BigInt so the Cypher LIMIT clause sees a Cypher INTEGER, not FLOAT.
2545
+ { batchSize: BigInt(DELETE_BATCH_SIZE) },
2546
+ { repositoryId }
2547
+ );
2548
+ const stats = summary.counters.updates();
2549
+ const deletedThisBatch = stats["nodesDeleted"] ?? 0;
2550
+ if (deletedThisBatch === 0) break;
2551
+ entitiesDeleted = Math.min(entitiesDeleted + deletedThisBatch, totalEntities);
2552
+ await onProgress?.({ entitiesDeleted, relationshipsDeleted, totalEntities, totalRelationships });
2553
+ }
2554
+ }
2555
+ /**
2556
+ * Drop every entity and relationship scoped to `repositoryId` but preserve
2557
+ * the `_Repository` and `_Vocabulary` / `_VocabularyChangeLog` system nodes.
2558
+ * Same chunked-wipe contract as `deleteRepository`, restricted to the
2559
+ * `:_Entity` umbrella label for nodes.
2560
+ */
2561
+ async deleteAllContents(repositoryId, onProgress) {
2562
+ const { totalEntities, totalRelationships } = await this.countRepositoryContents(repositoryId);
2563
+ let relationshipsDeleted = 0;
2564
+ let entitiesDeleted = 0;
2565
+ while (true) {
2566
+ const summary = await this.connection.executeImplicitInTransactions(
2567
+ `CALL () {
2568
+ MATCH (:_Entity {repositoryId: $rid})-[r {repositoryId: $rid}]-(:_Entity {repositoryId: $rid})
2569
+ WITH r LIMIT $batchSize
2570
+ DELETE r
2571
+ } IN TRANSACTIONS OF $batchSize ROWS`,
2572
+ // BigInt so the Cypher LIMIT clause sees a Cypher INTEGER, not FLOAT.
2573
+ { batchSize: BigInt(DELETE_BATCH_SIZE) },
2574
+ { repositoryId }
2575
+ );
2576
+ const stats = summary.counters.updates();
2577
+ const deletedThisBatch = stats["relationshipsDeleted"] ?? 0;
2578
+ if (deletedThisBatch === 0) break;
2579
+ relationshipsDeleted = Math.min(relationshipsDeleted + deletedThisBatch, totalRelationships);
2580
+ await onProgress?.({ entitiesDeleted, relationshipsDeleted, totalEntities, totalRelationships });
2581
+ }
2582
+ while (true) {
2583
+ const summary = await this.connection.executeImplicitInTransactions(
2584
+ `CALL () {
2585
+ MATCH (n:_Entity {repositoryId: $rid})
2586
+ WITH n LIMIT $batchSize
2587
+ DETACH DELETE n
2588
+ } IN TRANSACTIONS OF $batchSize ROWS`,
2589
+ // BigInt so the Cypher LIMIT clause sees a Cypher INTEGER, not FLOAT.
2590
+ { batchSize: BigInt(DELETE_BATCH_SIZE) },
2591
+ { repositoryId }
2592
+ );
2593
+ const stats = summary.counters.updates();
2594
+ const deletedThisBatch = stats["nodesDeleted"] ?? 0;
2595
+ if (deletedThisBatch === 0) break;
2596
+ entitiesDeleted = Math.min(entitiesDeleted + deletedThisBatch, totalEntities);
2597
+ await onProgress?.({ entitiesDeleted, relationshipsDeleted, totalEntities, totalRelationships });
2598
+ }
2599
+ return { deletedEntities: totalEntities, deletedRelationships: totalRelationships };
2600
+ }
2601
+ /**
2602
+ * One-shot pre-count used by `deleteRepository` / `deleteAllContents`. The
2603
+ * entity count uses the umbrella `:_Entity` label so system nodes
2604
+ * (`_Repository` / `_Vocabulary`) are excluded — that matches the
2605
+ * user-facing semantics of the progress callback.
2606
+ *
2607
+ * The relationship count uses the **directed** pattern `()-[r]->()`: every
2608
+ * edge is written in its stored direction (D5), so the directed pattern
2609
+ * binds each edge to exactly one row. An undirected `-` pattern would
2610
+ * double-count every edge whose endpoints are distinct vertices, because
2611
+ * Cypher enumerates the pattern from both directions.
2612
+ */
2613
+ async countRepositoryContents(repositoryId) {
2614
+ const [entitiesResult, relationshipsResult] = await Promise.all([
2615
+ this.connection.executeQuery(
2616
+ "MATCH (n:_Entity {repositoryId: $rid}) RETURN count(n) AS total",
2617
+ {},
2618
+ { repositoryId, routing: "READ" }
2619
+ ),
2620
+ this.connection.executeQuery(
2621
+ "MATCH ()-[r {repositoryId: $rid}]->() RETURN count(r) AS total",
2622
+ {},
2623
+ { repositoryId, routing: "READ" }
2624
+ )
2625
+ ]);
2626
+ const totalEntities = bigintToSafeNumber(entitiesResult.records[0]?.get("total") ?? 0);
2627
+ const totalRelationships = bigintToSafeNumber(relationshipsResult.records[0]?.get("total") ?? 0);
2628
+ return { totalEntities, totalRelationships };
2629
+ }
2630
+ // ─── Vocabulary ────────────────────────────────────────────────────
2631
+ /**
2632
+ * Read the vocabulary for a repository. Cache-aware: cache hits return
2633
+ * synchronously with zero Bolt round-trips. The TRACKED_METHODS proxy still
2634
+ * fires for every call — the sink record on a cache hit carries
2635
+ * `details.calls === 0` and `value === 0`, which is the contract the sink
2636
+ * expects to express "this operation ran but did no server work".
2637
+ */
2638
+ async getVocabulary(repositoryId) {
2639
+ return this.getVocabularyCached(repositoryId);
2640
+ }
2641
+ /**
2642
+ * Cached vocabulary read used by `getVocabulary` and (in later phases) by
2643
+ * traversal compilation. The vocabulary is compile-time context for the
2644
+ * Cypher compiler — it changes on the order of once per session, but the
2645
+ * traversal hot path would otherwise pay one round-trip per call. The cache
2646
+ * flips that to one round-trip per TTL window.
2647
+ *
2648
+ * Reads inside an active usage scope still record a round-trip when a fetch
2649
+ * actually happens (cache miss); cache hits emit no round-trip and therefore
2650
+ * contribute nothing to the scope.
2651
+ */
2652
+ async getVocabularyCached(repositoryId) {
2653
+ const now = Date.now();
2654
+ const cached = this.vocabularyCache.get(repositoryId);
2655
+ if (cached && cached.expiresAt > now) {
2656
+ return cached.vocab;
2657
+ }
2658
+ const vocab = await getVocabulary(this.connection, repositoryId);
2659
+ this.vocabularyCache.set(repositoryId, {
2660
+ vocab,
2661
+ expiresAt: now + VOCABULARY_CACHE_TTL_MS
2662
+ });
2663
+ return vocab;
2664
+ }
2665
+ /** Drop the cache entry for a repository — call after every vocabulary write. */
2666
+ invalidateVocabularyCache(repositoryId) {
2667
+ this.vocabularyCache.delete(repositoryId);
2668
+ }
2669
+ /**
2670
+ * Upsert the vocabulary for a repository. Invalidates the in-process cache
2671
+ * on success so subsequent reads observe the new state immediately within
2672
+ * this process (cross-process staleness is bounded by the 60 s TTL).
2673
+ */
2674
+ async saveVocabulary(repositoryId, vocabulary) {
2675
+ await saveVocabulary(this.connection, repositoryId, vocabulary);
2676
+ this.invalidateVocabularyCache(repositoryId);
2677
+ }
2678
+ /**
2679
+ * Page the vocabulary change-log newest first. Writes land in
2680
+ * `proposeVocabularyExtension` (out of scope here) — this method only reads
2681
+ * the `_VocabularyChangeLog` nodes back, ordered by `proposedAt` to match
2682
+ * the audit semantic on `VocabularyChangeRecord`.
2683
+ */
2684
+ async getVocabularyChangeLog(repositoryId, options) {
2685
+ return getVocabularyChangeLog(this.connection, repositoryId, options);
2686
+ }
2687
+ // ─── Entities ──────────────────────────────────────────────────────
2688
+ /**
2689
+ * Create a new entity via fixed-shape `CREATE` + catch on the uniqueness
2690
+ * constraint. A `MERGE`-with-discriminator alternative is marginally faster
2691
+ * on the happy path but mutates the existing node on collisions, writing
2692
+ * a discriminator property onto durable graph state the caller never
2693
+ * requested — correctness wins over the marginal perf delta.
2694
+ */
2695
+ async createEntity(repositoryId, entity) {
2696
+ return createEntity(this.connection, repositoryId, entity);
2697
+ }
2698
+ /** Read a single entity by id; `null` when not found. */
2699
+ async getEntity(repositoryId, entityId, options) {
2700
+ return getEntity(this.connection, repositoryId, entityId, options);
2701
+ }
2702
+ /** Read a single entity by slug; `null` when not found. */
2703
+ async getEntityBySlug(repositoryId, slug, options) {
2704
+ return getEntityBySlug(this.connection, repositoryId, slug, options);
2705
+ }
2706
+ /**
2707
+ * Batch read by ids. Absent ids do not appear in the returned `Map`; empty
2708
+ * input returns an empty map without a round-trip.
2709
+ */
2710
+ async getEntities(repositoryId, entityIds, options) {
2711
+ return getEntities(this.connection, repositoryId, entityIds, options);
2712
+ }
2713
+ /**
2714
+ * Variable-shape projection-on-write update (D23) — single round-trip
2715
+ * MATCH+SET+RETURN. Empty record array → `EntityNotFoundError`.
2716
+ */
2717
+ async updateEntity(repositoryId, entityId, updates) {
2718
+ return updateEntity(this.connection, repositoryId, entityId, updates);
2719
+ }
2720
+ /**
2721
+ * Delete a single entity (and its incident relationships via `DETACH
2722
+ * DELETE`). Throws `EntityNotFoundError` when the match returns zero rows.
2723
+ */
2724
+ async deleteEntity(repositoryId, entityId) {
2725
+ return deleteEntity(this.connection, repositoryId, entityId);
2726
+ }
2727
+ /**
2728
+ * Bulk delete by ids — single round-trip. Returns the ids actually
2729
+ * deleted; missing ids land in `notFound`.
2730
+ */
2731
+ async deleteEntities(repositoryId, ids) {
2732
+ return deleteEntities(this.connection, repositoryId, ids);
2733
+ }
2734
+ /**
2735
+ * Delete every entity of a type plus their incident relationships, with
2736
+ * exact counts (entity + relationship) returned in one round-trip — a
2737
+ * strict improvement over Cosmos's `deletedRelationships: undefined` path
2738
+ * (Gremlin would fan out across every partition the type touches).
2739
+ */
2740
+ async deleteEntitiesByType(repositoryId, entityType) {
2741
+ return deleteEntitiesByType(this.connection, repositoryId, entityType);
2742
+ }
2743
+ /**
2744
+ * Page entities matching a `StorageFindQuery`. Parallel data + count Cypher
2745
+ * pair; `total` is always exact because every filter (entity-type, property
2746
+ * equality, search term, provenance) is server-side via either a typed
2747
+ * predicate or the `dm_entity_text` fulltext index. Search-term queries
2748
+ * order by Lucene score descending; non-search queries order by `n.id` to
2749
+ * pin pagination determinism across slices.
2750
+ */
2751
+ async findEntities(repositoryId, query, options) {
2752
+ return findEntities(this.connection, repositoryId, query, options);
2753
+ }
2754
+ // ─── Relationships ─────────────────────────────────────────────────
2755
+ /**
2756
+ * Create a relationship. Both endpoint entities are matched under the
2757
+ * repository scope before the edge is created, so cross-repository edges
2758
+ * are structurally impossible to write (D3b layer 3). A missing endpoint
2759
+ * surfaces as `EntityNotFoundError` carrying the absent id.
2760
+ */
2761
+ async createRelationship(repositoryId, relationship) {
2762
+ return createRelationship(this.connection, repositoryId, relationship);
2763
+ }
2764
+ /** Read a single relationship by id; `null` when not found. */
2765
+ async getRelationship(repositoryId, relationshipId) {
2766
+ return getRelationship(this.connection, repositoryId, relationshipId);
2767
+ }
2768
+ /**
2769
+ * Page an entity's incident relationships. `direction: 'out' | 'in'`
2770
+ * additionally surfaces edges flagged `bidirectional: true` from the
2771
+ * opposite endpoint, mirroring the Cosmos read-time duplication of bidir
2772
+ * edges. `propertyFilters` is applied client-side and reports
2773
+ * `total: undefined` in that branch — same trade-off as the Cosmos
2774
+ * provider, because relationship `properties` is a JSON blob with no
2775
+ * per-key index.
2776
+ */
2777
+ async getEntityRelationships(repositoryId, entityId, options) {
2778
+ return getEntityRelationships(
2779
+ this.connection,
2780
+ repositoryId,
2781
+ entityId,
2782
+ options
2783
+ );
2784
+ }
2785
+ /** Drop a single relationship by id. No-op when the id does not match. */
2786
+ async deleteRelationship(repositoryId, relationshipId) {
2787
+ return deleteRelationship(this.connection, repositoryId, relationshipId);
2788
+ }
2789
+ /**
2790
+ * Bulk drop by ids — single round-trip. Returns the ids actually deleted;
2791
+ * missing ids land in `notFound`.
2792
+ */
2793
+ async deleteRelationships(repositoryId, ids) {
2794
+ return deleteRelationships(this.connection, repositoryId, ids);
2795
+ }
2796
+ /**
2797
+ * Drop every relationship of a type in the repository. Returns an exact
2798
+ * delete count in a single round-trip.
2799
+ */
2800
+ async deleteRelationshipsByType(repositoryId, relationshipType) {
2801
+ return deleteRelationshipsByType(
2802
+ this.connection,
2803
+ repositoryId,
2804
+ relationshipType
2805
+ );
2806
+ }
2807
+ // ─── Graph Traversal ───────────────────────────────────────────────
2808
+ /**
2809
+ * Capabilities surface used by the dispatcher to decide whether a given
2810
+ * `TraversalSpec` shape is supported natively. Strict improvement over the
2811
+ * Cosmos provider in two cells: `supportsAggregation` is `true` (Cypher's
2812
+ * native aggregation makes `count` / per-key projection a one-statement
2813
+ * shape) and the runtime supports every other traversal lever.
2814
+ */
2815
+ getCapabilities() {
2816
+ return {
2817
+ supportsNativeQuery: true,
2818
+ nativeQueryLanguage: "cypher",
2819
+ maxTraversalDepth: 10,
2820
+ supportsRelationshipPropertyFilters: true,
2821
+ supportsEntityPropertyFilters: true,
2822
+ supportsAggregation: true,
2823
+ supportsRepeat: true,
2824
+ supportsDedup: true,
2825
+ supportsRelationshipSummary: false
2826
+ };
2827
+ }
2828
+ /**
2829
+ * Execute a `TraversalSpec` against this repository's subgraph. The
2830
+ * provider owns Cypher compilation; the spec stays language-agnostic.
2831
+ * `track('traverse')` opens the per-operation usage scope so every
2832
+ * `executeQuery` round-trip the executor performs aggregates into a single
2833
+ * `OperationUsage` record.
2834
+ */
2835
+ async traverse(repositoryId, spec) {
2836
+ return this.traverseInternal(repositoryId, spec);
2837
+ }
2838
+ /**
2839
+ * Internal compile → submit → project pipeline. Shared by the public
2840
+ * `traverse` method and by the compiler-model rewrites of
2841
+ * `exploreNeighborhood` / `findPaths`, which both consume the raw stored
2842
+ * shape to rebuild their storage-level outputs.
2843
+ */
2844
+ async traverseInternal(repositoryId, spec) {
2845
+ const raw = await this.executeRawTraversal(repositoryId, spec);
2846
+ const detailLevel = spec.detailLevel ?? "summary";
2847
+ const projectStoredEntity = (stored) => {
2848
+ const projected = projectEntity(stored, detailLevel);
2849
+ if (!spec.includeProvenance) {
2850
+ delete projected["provenance"];
2851
+ }
2852
+ return projected;
2853
+ };
2854
+ const projectStoredRelationship = (rel, direction = "out") => ({
2855
+ id: rel.id,
2856
+ type: rel.relationshipType,
2857
+ sourceEntityId: rel.sourceEntityId,
2858
+ targetEntityId: rel.targetEntityId,
2859
+ direction,
2860
+ properties: rel.properties
2861
+ });
2862
+ let entities = [];
2863
+ let relationships;
2864
+ let paths;
2865
+ const aggregations = raw.aggregations;
2866
+ const projectionEmitted = aggregations !== void 0;
2867
+ if (projectionEmitted) {
2868
+ } else if (spec.returnMode === "terminal") {
2869
+ entities = raw.terminalEntities.map(projectStoredEntity);
2870
+ relationships = void 0;
2871
+ } else if (spec.returnMode === "all") {
2872
+ entities = raw.allEntities.map(projectStoredEntity);
2873
+ relationships = raw.allRelationships.map((r) => projectStoredRelationship(r));
2874
+ } else {
2875
+ paths = raw.pathRows.map((row) => ({
2876
+ length: Math.max(row.entityIds.length - 1, 0),
2877
+ entities: row.entityIds.map((id) => {
2878
+ const stored = raw.entityMap.get(id);
2879
+ if (!stored) {
2880
+ throw new ProviderError7(
2881
+ "Unpacking Cypher path: entity referenced by path is missing from the result.",
2882
+ "Inspect compiledQuery \u2014 this indicates a path emission shape mismatch."
2883
+ );
2884
+ }
2885
+ return projectStoredEntity(stored);
2886
+ }),
2887
+ relationships: row.relationshipIds.map((id, i) => {
2888
+ const stored = raw.relationshipMap.get(id);
2889
+ if (!stored) {
2890
+ throw new ProviderError7(
2891
+ "Unpacking Cypher path: relationship referenced by path is missing from the result.",
2892
+ "Inspect compiledQuery \u2014 this indicates a path emission shape mismatch."
2893
+ );
2894
+ }
2895
+ return projectStoredRelationship(stored, row.relationshipDirections[i] ?? "out");
2896
+ })
2897
+ }));
2898
+ relationships = Array.from(raw.relationshipMap.values()).map(
2899
+ (rel) => projectStoredRelationship(rel, raw.pathRelFirstDirection.get(rel.id) ?? "out")
2900
+ );
2901
+ }
2902
+ const limit = spec.limit ?? 50;
2903
+ let total;
2904
+ if (projectionEmitted) {
2905
+ total = aggregations.length;
2906
+ } else if (spec.returnMode === "path") {
2907
+ total = paths?.length ?? 0;
2908
+ } else if (spec.returnMode === "all") {
2909
+ total = entities.length + (relationships?.length ?? 0);
2910
+ } else {
2911
+ total = entities.length;
2912
+ }
2913
+ const truncated = total >= limit;
2914
+ const queryMetadata = {
2915
+ executionTimeMs: raw.executionTimeMs,
2916
+ resourceCost: { units: "server_ms", value: raw.serverMs },
2917
+ compiledQuery: raw.compiledQuery,
2918
+ compiledQueryLanguage: "cypher",
2919
+ appliedLimits: {
2920
+ maxResults: limit,
2921
+ ...spec.steps !== void 0 ? { maxDepth: spec.steps.length } : {}
2922
+ },
2923
+ truncated,
2924
+ ...truncated ? { truncationReason: "result_limit" } : {}
2925
+ };
2926
+ return {
2927
+ entities,
2928
+ ...relationships !== void 0 ? { relationships } : {},
2929
+ ...paths !== void 0 ? { paths } : {},
2930
+ ...aggregations !== void 0 ? { aggregations } : {},
2931
+ total,
2932
+ returned: total,
2933
+ hasMore: truncated,
2934
+ queryMetadata
2935
+ };
2936
+ }
2937
+ /**
2938
+ * Lower-level compile + submit + parse helper. Fetches the cached
2939
+ * vocabulary once (D16) and hands it to the executor; the executor handles
2940
+ * the repositoryId-scope rewrite, optional PROFILE prefix, and Path-object
2941
+ * parsing.
2942
+ */
2943
+ async executeRawTraversal(repositoryId, spec) {
2944
+ const vocabulary = await this.getVocabularyCached(repositoryId);
2945
+ return this.traversalExecutor.execute(repositoryId, spec, vocabulary);
2946
+ }
2947
+ /**
2948
+ * BFS-like neighbourhood exploration. For each depth `d` from 1 to
2949
+ * `options.depth`, compile a cumulative `'all'`-mode spec with `d` discrete
2950
+ * `'both'`-direction steps, run it through the executor, and walk one BFS
2951
+ * layer client-side from the previous frontier using the returned edges.
2952
+ *
2953
+ * Round-trips per call: `options.depth`. Server-side step direction is
2954
+ * fixed to `'both'` (catches every edge in either direction); the
2955
+ * directional + bidirectional filter and entity-type filter run client-side
2956
+ * during layer reconstruction — both to preserve the observable contract
2957
+ * shared with the Cosmos provider and because the compiler's prefix walk at
2958
+ * each depth is intentionally unfiltered so deeper layers stay reachable
2959
+ * through any intermediate.
2960
+ */
2961
+ async exploreNeighborhood(repositoryId, entityId, options) {
2962
+ const layers = [];
2963
+ const visited = /* @__PURE__ */ new Set([entityId]);
2964
+ let frontier = /* @__PURE__ */ new Set([entityId]);
2965
+ for (let d = 1; d <= options.depth; d++) {
2966
+ if (frontier.size === 0) break;
2967
+ const spec = {
2968
+ start: { entityId },
2969
+ steps: buildExploreSteps(d, options),
2970
+ returnMode: "all",
2971
+ // The cumulative-d query fetches every node and edge reachable in ≤d
2972
+ // hops in either direction. Size the limit generously so a single
2973
+ // round-trip can hold the layer's full graph regardless of fan-out.
2974
+ limit: 1e4,
2975
+ detailLevel: "full",
2976
+ includeProvenance: true
2977
+ };
2978
+ const raw = await this.executeRawTraversal(repositoryId, spec);
2979
+ const edgesByVertex = /* @__PURE__ */ new Map();
2980
+ for (const rel of raw.allRelationships) {
2981
+ const a = edgesByVertex.get(rel.sourceEntityId);
2982
+ if (a) a.push(rel);
2983
+ else edgesByVertex.set(rel.sourceEntityId, [rel]);
2984
+ const b = edgesByVertex.get(rel.targetEntityId);
2985
+ if (b) b.push(rel);
2986
+ else edgesByVertex.set(rel.targetEntityId, [rel]);
2987
+ }
2988
+ const layer = {};
2989
+ const nextFrontier = /* @__PURE__ */ new Set();
2990
+ const layerBucketSeen = /* @__PURE__ */ new Map();
2991
+ for (const fv of frontier) {
2992
+ const incident = edgesByVertex.get(fv) ?? [];
2993
+ for (const rel of incident) {
2994
+ const isSource = rel.sourceEntityId === fv;
2995
+ const isTarget = rel.targetEntityId === fv;
2996
+ let matchesDirection = false;
2997
+ let connectedId;
2998
+ if (isSource && (options.direction === "out" || options.direction === "both")) {
2999
+ matchesDirection = true;
3000
+ connectedId = rel.targetEntityId;
3001
+ } else if (isTarget && (options.direction === "in" || options.direction === "both")) {
3002
+ matchesDirection = true;
3003
+ connectedId = rel.sourceEntityId;
3004
+ } else if (rel.bidirectional) {
3005
+ if (isSource && options.direction === "in") {
3006
+ matchesDirection = true;
3007
+ connectedId = rel.targetEntityId;
3008
+ } else if (isTarget && options.direction === "out") {
3009
+ matchesDirection = true;
3010
+ connectedId = rel.sourceEntityId;
3011
+ }
3012
+ }
3013
+ if (!matchesDirection || !connectedId) continue;
3014
+ if (visited.has(connectedId)) continue;
3015
+ if (options.relationshipPropertyFilters && options.relationshipPropertyFilters.length > 0) {
3016
+ if (!matchesPropertyFilters2(rel.properties, options.relationshipPropertyFilters))
3017
+ continue;
3018
+ }
3019
+ const connectedEntity = raw.entityMap.get(connectedId);
3020
+ if (!connectedEntity) continue;
3021
+ if (options.entityTypes && options.entityTypes.length > 0 && !options.entityTypes.includes(connectedEntity.entityType)) {
3022
+ continue;
3023
+ }
3024
+ const relType = rel.relationshipType;
3025
+ let bucketSeen = layerBucketSeen.get(relType);
3026
+ if (!bucketSeen) {
3027
+ bucketSeen = /* @__PURE__ */ new Set();
3028
+ layerBucketSeen.set(relType, bucketSeen);
3029
+ }
3030
+ if (bucketSeen.has(connectedId)) continue;
3031
+ bucketSeen.add(connectedId);
3032
+ if (!layer[relType]) {
3033
+ layer[relType] = { total: 0, entities: [], relationships: [] };
3034
+ }
3035
+ layer[relType].entities.push(connectedEntity);
3036
+ layer[relType].relationships.push(rel);
3037
+ layer[relType].total = layer[relType].entities.length;
3038
+ nextFrontier.add(connectedId);
3039
+ }
3040
+ }
3041
+ for (const relType of Object.keys(layer)) {
3042
+ const group = layer[relType];
3043
+ const start = options.offsetPerType;
3044
+ const end = start + options.limitPerType;
3045
+ group.entities = group.entities.slice(start, end);
3046
+ group.relationships = group.relationships.slice(start, end);
3047
+ }
3048
+ if (Object.keys(layer).length > 0) {
3049
+ layers.push(layer);
3050
+ }
3051
+ for (const id of nextFrontier) visited.add(id);
3052
+ frontier = nextFrontier;
3053
+ }
3054
+ return { centerId: entityId, layers };
3055
+ }
3056
+ /**
3057
+ * Path finding between two entities. Single round-trip via a variable-length
3058
+ * `MATCH p = (s)-[*1..N]-(t)` pattern; the compiler's path-binding emission
3059
+ * lets the executor recover ordered nodes and relationships via `nodes(p)`
3060
+ * / `relationships(p)`. The default `DIFFERENT RELATIONSHIPS` match mode in
3061
+ * Cypher 25 prevents edge reuse within a single path — no explicit dedup
3062
+ * filter is needed.
3063
+ *
3064
+ * The traversal walks the graph topologically regardless of relationship
3065
+ * directionality (a path is defined by reachability, not semantic
3066
+ * direction); entity-type and relationship-property filters apply during
3067
+ * compilation, target filtering happens post-fetch.
3068
+ */
3069
+ async findPaths(repositoryId, sourceId, targetId, options) {
3070
+ if (sourceId === targetId) {
3071
+ return { paths: [{ entityIds: [sourceId], relationshipIds: [] }], totalPaths: 1 };
3072
+ }
3073
+ const step = {
3074
+ direction: "both",
3075
+ repeat: { maxDepth: options.maxDepth, emitIntermediates: true }
3076
+ };
3077
+ if (options.relationshipTypes && options.relationshipTypes.length > 0) {
3078
+ step.relationshipTypes = options.relationshipTypes;
3079
+ }
3080
+ if (options.relationshipPropertyFilters && options.relationshipPropertyFilters.length > 0) {
3081
+ step.relationshipFilter = options.relationshipPropertyFilters;
3082
+ }
3083
+ const spec = {
3084
+ start: { entityId: sourceId },
3085
+ steps: [step],
3086
+ returnMode: "path",
3087
+ // Pull a generous candidate pool so the post-fetch filter (paths ending
3088
+ // at targetId) has enough rows to paginate from. The variable-length
3089
+ // pattern returns every walk of length ≤ maxDepth in one round-trip.
3090
+ limit: Math.max(options.limit + options.offset, options.limit) * 10,
3091
+ detailLevel: "full",
3092
+ includeProvenance: true
3093
+ };
3094
+ const raw = await this.executeRawTraversal(repositoryId, spec);
3095
+ const matchingPaths = [];
3096
+ for (const row of raw.pathRows) {
3097
+ const last = row.entityIds[row.entityIds.length - 1];
3098
+ if (last !== targetId) continue;
3099
+ if (new Set(row.entityIds).size !== row.entityIds.length) continue;
3100
+ if (options.entityTypes && options.entityTypes.length > 0) {
3101
+ let rejected = false;
3102
+ for (let i = 1; i < row.entityIds.length - 1; i++) {
3103
+ const intermediate = raw.entityMap.get(row.entityIds[i]);
3104
+ if (!intermediate) {
3105
+ rejected = true;
3106
+ break;
3107
+ }
3108
+ if (!options.entityTypes.includes(intermediate.entityType)) {
3109
+ rejected = true;
3110
+ break;
3111
+ }
3112
+ }
3113
+ if (rejected) continue;
3114
+ }
3115
+ matchingPaths.push({
3116
+ entityIds: [...row.entityIds],
3117
+ relationshipIds: [...row.relationshipIds]
3118
+ });
3119
+ }
3120
+ const paginated = matchingPaths.slice(options.offset, options.offset + options.limit);
3121
+ return {
3122
+ paths: paginated,
3123
+ totalPaths: matchingPaths.length
3124
+ };
3125
+ }
3126
+ // ─── Timeline ──────────────────────────────────────────────────────
3127
+ /**
3128
+ * Reconstruct the timeline event stream for an entity. One server
3129
+ * round-trip: the centre entity's provenance scalars plus every incident
3130
+ * edge's id + createdAt arrive in a single tuple via `OPTIONAL MATCH +
3131
+ * collect()`. The provider walks the row client-side to emit
3132
+ * `entity:created` / `entity:updated` / `relationship:created` events.
3133
+ *
3134
+ * Cosmos pays two round-trips for the same information because Gremlin
3135
+ * cannot bind an aggregated edge list to a vertex projection in one shot —
3136
+ * a platform divergence, not an inherent trade-off.
3137
+ */
3138
+ async getTimeline(repositoryId, entityId, options) {
3139
+ return getTimeline(this.connection, repositoryId, entityId, options);
3140
+ }
3141
+ // ─── Bulk Operations ───────────────────────────────────────────────
3142
+ /**
3143
+ * Stream every entity and every relationship in the repository as
3144
+ * cursor-paginated chunks. Entities come first, then relationships; each
3145
+ * chunk carries a monotonic `sequence` and an `isLast` flag.
3146
+ *
3147
+ * The Proxy-based tracking flow does not apply here. Tracked methods emit
3148
+ * one sink record at promise resolution; an `AsyncIterable` returns
3149
+ * synchronously, before any chunk has streamed. Instead, `trackIterable`
3150
+ * wraps the underlying generator in its own `UsageScope` that opens at
3151
+ * iterator creation, records each round-trip as the consumer pulls the
3152
+ * next chunk, and emits one sink record when the iterator drains — so the
3153
+ * resulting record aggregates server time across every chunk fetch.
3154
+ */
3155
+ exportAll(repositoryId) {
3156
+ return this.trackIterable(
3157
+ "exportAll",
3158
+ repositoryId,
3159
+ exportAll(this.connection, repositoryId)
3160
+ );
3161
+ }
3162
+ /**
3163
+ * Run a bulk import: every entity then every relationship from the input
3164
+ * chunks lands in the repository. `skipExistenceCheck: true` uses CREATE
3165
+ * for the absolute peak throughput; `false` (default) uses MERGE for
3166
+ * idempotent re-imports.
3167
+ *
3168
+ * Returns a single aggregate `BulkImportResult` spanning every chunk.
3169
+ * Per-row failures land in `result.errors`; surviving rows still count
3170
+ * toward `entitiesImported` / `relationshipsImported`.
3171
+ */
3172
+ async importBulk(repositoryId, data, options) {
3173
+ return importBulk(this.connection, repositoryId, data, options);
3174
+ }
3175
+ /**
3176
+ * Wrap an `AsyncIterable` so every chunk it produces is generated inside a
3177
+ * single shared `UsageScope`. The scope aggregates `summary.resultConsumedAfter`
3178
+ * across every round-trip the iterator performs; one sink record fires when
3179
+ * the iterator drains (or is closed / thrown into).
3180
+ *
3181
+ * Implementation mirrors the Cosmos `trackIterable` precedent: each
3182
+ * `iter.next()` call re-enters the scope via `runInUsageScope`. The
3183
+ * `AsyncLocalStorage` chain links async work performed inside the
3184
+ * generator body to the scope for the duration of that step. Between
3185
+ * `next()` calls the scope is dormant — the consumer's awaits do not
3186
+ * accumulate into it, which is exactly the desired semantics.
3187
+ *
3188
+ * When the sink is absent the wrap degenerates to a pass-through; the
3189
+ * Proxy's `createSafeSink` short-circuit covers the no-sink case at
3190
+ * construction time, so this method is only reached when `reportUsage` is
3191
+ * present.
3192
+ */
3193
+ trackIterable(operation, repositoryId, source) {
3194
+ const sink = this.reportUsage;
3195
+ if (sink === void 0) return source;
3196
+ return {
3197
+ [Symbol.asyncIterator]: () => {
3198
+ const iter = source[Symbol.asyncIterator]();
3199
+ const scope = createUsageScope();
3200
+ let emitted = false;
3201
+ const emit = () => {
3202
+ if (emitted) return;
3203
+ emitted = true;
3204
+ sink({
3205
+ provider: PROVIDER_NAME,
3206
+ operation,
3207
+ unit: "server_ms",
3208
+ value: scope.serverMs,
3209
+ repositoryId,
3210
+ timestamp: /* @__PURE__ */ new Date(),
3211
+ details: buildUsageDetails(scope)
3212
+ });
3213
+ };
3214
+ return {
3215
+ async next() {
3216
+ const step = await runInUsageScope(scope, () => iter.next());
3217
+ if (step.done) emit();
3218
+ return step;
3219
+ },
3220
+ async return(value) {
3221
+ emit();
3222
+ if (iter.return) return iter.return(value);
3223
+ return { done: true, value };
3224
+ },
3225
+ async throw(err) {
3226
+ emit();
3227
+ if (iter.throw) return iter.throw(err);
3228
+ throw err;
3229
+ }
3230
+ };
3231
+ }
3232
+ };
3233
+ }
3234
+ // ─── Stats ─────────────────────────────────────────────────────────
3235
+ /**
3236
+ * Aggregate repository statistics — entity / relationship totals, per-type
3237
+ * breakdowns, vocabulary version. Two parallel native-aggregation round-
3238
+ * trips (`count(n)` per `entityType`, `count(r)` per `type(r)`); the
3239
+ * vocabulary version comes from the cached `_Vocabulary` node so a warm
3240
+ * cache costs exactly two round-trips total.
3241
+ *
3242
+ * Strict improvement over Cosmos's Gremlin `.group().by().by(count())`
3243
+ * shape — Cypher's native aggregation collapses each metric to a one-
3244
+ * statement plan that hits the `(repositoryId, entityType)` and
3245
+ * relationship-property indexes directly.
3246
+ */
3247
+ async getRepositoryStats(repositoryId) {
3248
+ const vocabulary = await this.getVocabularyCached(repositoryId);
3249
+ return getRepositoryStats(this.connection, repositoryId, vocabulary);
3250
+ }
3251
+ // ─── Native Query ──────────────────────────────────────────────────
3252
+ /**
3253
+ * Execute a raw Cypher statement with caller-supplied bindings.
3254
+ *
3255
+ * ⚠️ ELEVATED PRIVILEGE — SYSTEM-LEVEL OPERATION ⚠️
3256
+ *
3257
+ * This method is an unscoped pass-through: it does not filter by
3258
+ * repository, does not inject the `$rid` binding, and performs no
3259
+ * validation on the Cypher string. A single call can read or mutate any
3260
+ * node or relationship in the database regardless of which repository it
3261
+ * belongs to.
3262
+ *
3263
+ * DO NOT expose this method to AI agents, end users, or any untrusted
3264
+ * caller. It is intended for:
3265
+ * - administrative tooling (migrations, diagnostics, repairs)
3266
+ * - internal library operations that need cross-repository reach
3267
+ *
3268
+ * `repositoryId` is accepted for interface symmetry but is intentionally
3269
+ * ignored here — the caller is trusted to scope the query themselves.
3270
+ * Because the call is cross-repository by design, the emitted usage
3271
+ * record carries no `repositoryId` (see `TRACKED_METHODS`).
3272
+ *
3273
+ * For agent-facing graph queries use {@link traverse}, which enforces the
3274
+ * repositoryId scope predicate.
3275
+ */
3276
+ async executeNativeQuery(_repositoryId, query, params) {
3277
+ const result = await this.connection.executeSystemQuery(query, params ?? {}, {
3278
+ crossRepository: true
3279
+ });
3280
+ return result.records.map((record) => record.toObject());
3281
+ }
3282
+ };
3283
+ function buildExploreSteps(depth, options) {
3284
+ const base = { direction: "both" };
3285
+ if (options.relationshipTypes && options.relationshipTypes.length > 0) {
3286
+ base.relationshipTypes = options.relationshipTypes;
3287
+ }
3288
+ const steps = [];
3289
+ for (let i = 0; i < depth; i++) {
3290
+ steps.push({ ...base });
3291
+ }
3292
+ return steps;
3293
+ }
3294
+ export {
3295
+ Neo4jStorageProvider,
3296
+ SCHEMA_VERSION,
3297
+ getSchemaCypher
3298
+ };
3299
+ //# sourceMappingURL=index.js.map