@utaba/deep-memory-storage-cosmosdb 0.16.0 → 0.18.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 CHANGED
@@ -1,11 +1,22 @@
1
1
  // src/CosmosDbProvider.ts
2
- import crypto from "crypto";
3
- import { GremlinCompiler, ProviderError, InvalidInputError, isValidUuid, projectEntity, createSafeSink } from "@utaba/deep-memory";
2
+ import {
3
+ GremlinCompiler,
4
+ ProviderError,
5
+ InvalidInputError,
6
+ isValidUuid,
7
+ projectEntity,
8
+ createSafeSink,
9
+ matchesPropertyFilters as matchesPropertyFilters3
10
+ } from "@utaba/deep-memory";
4
11
 
5
12
  // src/CosmosDbConnection.ts
6
13
  import gremlin from "gremlin";
14
+
15
+ // src/usage.ts
7
16
  import { AsyncLocalStorage } from "async_hooks";
8
17
  var usageScope = new AsyncLocalStorage();
18
+
19
+ // src/CosmosDbConnection.ts
9
20
  var CosmosDbConnection = class {
10
21
  client = null;
11
22
  config;
@@ -101,14 +112,220 @@ function getRetryAfterMs(err, attempt) {
101
112
  function extractRequestCharge(resultSet) {
102
113
  const attrs = resultSet.attributes;
103
114
  if (!attrs) return void 0;
104
- const charge = attrs["x-ms-request-charge"] ?? attrs["x-ms-total-request-charge"];
105
- return typeof charge === "number" ? charge : void 0;
115
+ const total = attrs["x-ms-total-request-charge"];
116
+ if (typeof total === "number") return total;
117
+ const single = attrs["x-ms-request-charge"];
118
+ if (typeof single === "number") return single;
119
+ return void 0;
106
120
  }
107
121
  function sleep(ms) {
108
122
  return new Promise((resolve) => setTimeout(resolve, ms));
109
123
  }
110
124
 
125
+ // src/cosmos-rest-auth.ts
126
+ import crypto from "crypto";
127
+ function cosmosAuthToken(verb, resourceType, resourceLink, date, key) {
128
+ const payload = `${verb.toLowerCase()}
129
+ ${resourceType.toLowerCase()}
130
+ ${resourceLink}
131
+ ${date.toLowerCase()}
132
+
133
+ `;
134
+ const keyBuffer = Buffer.from(key, "base64");
135
+ const hmac = crypto.createHmac("sha256", keyBuffer);
136
+ hmac.update(payload);
137
+ const signature = hmac.digest("base64");
138
+ return encodeURIComponent(`type=master&ver=1.0&sig=${signature}`);
139
+ }
140
+ async function cosmosRestPut(restBase, key, urlPath, resourceLink, resourceType, body, rejectUnauthorized) {
141
+ const date = (/* @__PURE__ */ new Date()).toUTCString();
142
+ const token = cosmosAuthToken("post", resourceType, resourceLink, date, key);
143
+ const url = `${restBase}/${urlPath}`;
144
+ const options = {
145
+ method: "POST",
146
+ headers: {
147
+ "Authorization": token,
148
+ "x-ms-version": "2018-12-31",
149
+ "x-ms-date": date,
150
+ "Content-Type": "application/json"
151
+ },
152
+ body: JSON.stringify(body)
153
+ };
154
+ if (!rejectUnauthorized) {
155
+ process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
156
+ }
157
+ const response = await fetch(url, options);
158
+ if (response.status === 201) return true;
159
+ if (response.status === 409) return false;
160
+ const text = await response.text();
161
+ throw new Error(`CosmosDB REST ${response.status}: ${text}`);
162
+ }
163
+
164
+ // src/CosmosDocumentClient.ts
165
+ var CosmosDocumentClient = class {
166
+ config;
167
+ fetchImpl;
168
+ /**
169
+ * `fetchImpl` is for tests only — production code should pass nothing and
170
+ * inherit `globalThis.fetch`. Keeping it on the constructor (rather than
171
+ * stubbing globals) means each test instance is hermetic.
172
+ */
173
+ constructor(config, fetchImpl) {
174
+ this.config = {
175
+ ...config,
176
+ maxRetries: config.maxRetries ?? 3,
177
+ defaultTimeoutMs: config.defaultTimeoutMs ?? 3e4
178
+ };
179
+ this.fetchImpl = fetchImpl ?? fetch;
180
+ }
181
+ /**
182
+ * Execute a parameterised Cosmos SQL query against the container's `docs`
183
+ * resource. Retries on 429/503 with the response's `x-ms-retry-after-ms`
184
+ * when present, otherwise exponential backoff. RU is accumulated into the
185
+ * active {@link usageScope}.
186
+ */
187
+ async query(sql, parameters, options) {
188
+ const resourceLink = `dbs/${this.config.database}/colls/${this.config.container}`;
189
+ const url = `${this.restBase()}/${resourceLink}/docs`;
190
+ if (!this.config.rejectUnauthorized) {
191
+ process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
192
+ }
193
+ const body = JSON.stringify({ query: sql, parameters });
194
+ for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
195
+ const date = (/* @__PURE__ */ new Date()).toUTCString();
196
+ const token = cosmosAuthToken("post", "docs", resourceLink, date, this.config.key);
197
+ const headers = {
198
+ "Authorization": token,
199
+ "x-ms-version": "2018-12-31",
200
+ "x-ms-date": date,
201
+ "Content-Type": "application/query+json",
202
+ "x-ms-documentdb-isquery": "true"
203
+ };
204
+ if (options.partitionKey != null) {
205
+ headers["x-ms-documentdb-partitionkey"] = JSON.stringify([options.partitionKey]);
206
+ } else {
207
+ headers["x-ms-documentdb-query-enablecrosspartition"] = "true";
208
+ }
209
+ if (options.populateMetrics) {
210
+ headers["x-ms-documentdb-populatequerymetrics"] = "true";
211
+ }
212
+ if (options.continuationToken) {
213
+ headers["x-ms-continuation"] = options.continuationToken;
214
+ }
215
+ const response = await this.fetchImpl(url, { method: "POST", headers, body });
216
+ if (response.status === 429 || response.status === 503) {
217
+ if (attempt < this.config.maxRetries) {
218
+ const waitMs = parseRetryAfterMs(response, attempt);
219
+ const acc2 = usageScope.getStore();
220
+ if (acc2) acc2.retries++;
221
+ await sleep2(waitMs);
222
+ continue;
223
+ }
224
+ const text = await response.text();
225
+ throw new Error(`CosmosDB Document query ${response.status}: ${text}`);
226
+ }
227
+ if (!response.ok) {
228
+ const text = await response.text();
229
+ throw new Error(`CosmosDB Document query ${response.status}: ${text}`);
230
+ }
231
+ const json = await response.json();
232
+ const requestCharge = Number(response.headers.get("x-ms-request-charge") ?? "0") || 0;
233
+ const queryMetrics = response.headers.get("x-ms-documentdb-query-metrics");
234
+ const continuationToken = response.headers.get("x-ms-continuation");
235
+ const acc = usageScope.getStore();
236
+ if (acc) {
237
+ acc.calls++;
238
+ acc.ru += requestCharge;
239
+ }
240
+ return {
241
+ documents: json.Documents ?? [],
242
+ requestCharge,
243
+ queryMetrics,
244
+ continuationToken
245
+ };
246
+ }
247
+ throw new Error("CosmosDocumentClient.query: retry loop exhausted without resolution");
248
+ }
249
+ /**
250
+ * Read container metadata (id, partition key, indexing policy). Used by
251
+ * `ensureSchema()` to warn when externally-provisioned containers have
252
+ * excluded paths the findEntities SQL needs.
253
+ */
254
+ async getContainerProperties() {
255
+ const resourceLink = `dbs/${this.config.database}/colls/${this.config.container}`;
256
+ const url = `${this.restBase()}/${resourceLink}`;
257
+ if (!this.config.rejectUnauthorized) {
258
+ process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
259
+ }
260
+ const date = (/* @__PURE__ */ new Date()).toUTCString();
261
+ const token = cosmosAuthToken("get", "colls", resourceLink, date, this.config.key);
262
+ const response = await this.fetchImpl(url, {
263
+ method: "GET",
264
+ headers: {
265
+ "Authorization": token,
266
+ "x-ms-version": "2018-12-31",
267
+ "x-ms-date": date
268
+ }
269
+ });
270
+ if (!response.ok) {
271
+ const text = await response.text();
272
+ throw new Error(`CosmosDB Document getContainerProperties ${response.status}: ${text}`);
273
+ }
274
+ return await response.json();
275
+ }
276
+ restBase() {
277
+ return this.config.restEndpoint.replace(/\/+$/, "");
278
+ }
279
+ };
280
+ function parseRetryAfterMs(response, attempt) {
281
+ const header = response.headers.get("x-ms-retry-after-ms");
282
+ if (header) {
283
+ const n = Number(header);
284
+ if (Number.isFinite(n) && n > 0) return n;
285
+ }
286
+ return Math.min(500 * Math.pow(2, attempt), 1e4);
287
+ }
288
+ function sleep2(ms) {
289
+ return new Promise((resolve) => setTimeout(resolve, ms));
290
+ }
291
+
111
292
  // src/mapping.ts
293
+ var STORED_ENTITY_FIELDS = [
294
+ "id",
295
+ "entityType",
296
+ "entityLabel",
297
+ "slug",
298
+ "summary",
299
+ "properties",
300
+ "data",
301
+ "dataFormat",
302
+ "createdBy",
303
+ "createdByType",
304
+ "createdAt",
305
+ "createdInConversation",
306
+ "createdFromMessage",
307
+ "modifiedBy",
308
+ "modifiedByType",
309
+ "modifiedAt",
310
+ "modifiedInConversation",
311
+ "modifiedFromMessage"
312
+ ];
313
+ function buildRepositoryProjectChain() {
314
+ return [
315
+ `project('id','repositoryId','repoLabel','description','type','legal','owner','governanceConfig','metadata','createdAt','createdBy')`,
316
+ `.by(id)`,
317
+ `.by('repositoryId')`,
318
+ `.by('repoLabel')`,
319
+ `.by(coalesce(values('description'), constant('')))`,
320
+ `.by(coalesce(values('type'), constant('')))`,
321
+ `.by(coalesce(values('legal'), constant('')))`,
322
+ `.by(coalesce(values('owner'), constant('')))`,
323
+ `.by('governanceConfig')`,
324
+ `.by(coalesce(values('metadata'), constant('')))`,
325
+ `.by('createdAt')`,
326
+ `.by('createdBy')`
327
+ ].join("");
328
+ }
112
329
  function unwrap(val) {
113
330
  if (Array.isArray(val) && val.length > 0) return val[0];
114
331
  return val;
@@ -160,6 +377,54 @@ function entityFromGremlin(props) {
160
377
  embedding: embeddingStr ? safeParseJson(embeddingStr, void 0) : void 0
161
378
  };
162
379
  }
380
+ function pluckDocValue(doc, key) {
381
+ const arr = doc[key];
382
+ if (Array.isArray(arr) && arr.length > 0) {
383
+ const entry = arr[0];
384
+ if (entry && typeof entry === "object") {
385
+ return entry["_value"];
386
+ }
387
+ }
388
+ return void 0;
389
+ }
390
+ function pluckDocStr(doc, key) {
391
+ const v = pluckDocValue(doc, key);
392
+ return typeof v === "string" ? v : String(v ?? "");
393
+ }
394
+ function pluckDocOptStr(doc, key) {
395
+ const v = pluckDocValue(doc, key);
396
+ return v != null && v !== "" ? String(v) : void 0;
397
+ }
398
+ function provenanceFromDocument(doc) {
399
+ return {
400
+ createdBy: pluckDocStr(doc, "createdBy"),
401
+ createdByType: pluckDocStr(doc, "createdByType") || "agent",
402
+ createdAt: pluckDocStr(doc, "createdAt"),
403
+ createdInConversation: pluckDocOptStr(doc, "createdInConversation"),
404
+ createdFromMessage: pluckDocOptStr(doc, "createdFromMessage"),
405
+ modifiedBy: pluckDocStr(doc, "modifiedBy"),
406
+ modifiedByType: pluckDocStr(doc, "modifiedByType") || "agent",
407
+ modifiedAt: pluckDocStr(doc, "modifiedAt"),
408
+ modifiedInConversation: pluckDocOptStr(doc, "modifiedInConversation"),
409
+ modifiedFromMessage: pluckDocOptStr(doc, "modifiedFromMessage")
410
+ };
411
+ }
412
+ function entityFromDocument(doc) {
413
+ const id = typeof doc["id"] === "string" ? doc["id"] : String(doc["id"] ?? "");
414
+ const embeddingStr = pluckDocOptStr(doc, "embedding");
415
+ return {
416
+ id,
417
+ slug: pluckDocStr(doc, "slug"),
418
+ entityType: pluckDocStr(doc, "entityType"),
419
+ label: pluckDocStr(doc, "entityLabel"),
420
+ summary: pluckDocOptStr(doc, "summary"),
421
+ properties: safeParseJson(pluckDocValue(doc, "properties"), {}),
422
+ data: pluckDocOptStr(doc, "data"),
423
+ dataFormat: pluckDocOptStr(doc, "dataFormat"),
424
+ provenance: provenanceFromDocument(doc),
425
+ embedding: embeddingStr ? safeParseJson(embeddingStr, void 0) : void 0
426
+ };
427
+ }
163
428
  function relationshipFromGremlin(props) {
164
429
  const bidir = unwrap(props["bidirectional"]);
165
430
  return {
@@ -186,24 +451,6 @@ function repositoryFromGremlin(props) {
186
451
  createdBy: unwrapStr(props["createdBy"])
187
452
  };
188
453
  }
189
- function repositorySummaryFromGremlin(props) {
190
- return {
191
- repositoryId: unwrapStr(props["repositoryId"]),
192
- type: unwrapOptStr(props["type"]),
193
- label: unwrapStr(props["repoLabel"]),
194
- description: unwrapOptStr(props["description"]),
195
- governanceConfig: safeParseJson(unwrap(props["governanceConfig"]), { mode: "open" })
196
- };
197
- }
198
- function vocabularyFromGremlin(props) {
199
- return safeParseJson(unwrap(props["vocabulary"]), {
200
- version: "0.0.0",
201
- lastModified: (/* @__PURE__ */ new Date()).toISOString(),
202
- modifiedBy: "system",
203
- entityTypes: [],
204
- relationshipTypes: []
205
- });
206
- }
207
454
  function changeRecordFromGremlin(props) {
208
455
  return {
209
456
  changeId: unwrapStr(props["changeId"]),
@@ -218,9 +465,97 @@ function changeRecordFromGremlin(props) {
218
465
  reason: unwrapStr(props["reason"])
219
466
  };
220
467
  }
221
- function entityToGremlinProps(repositoryId, entity) {
222
- const props = {
223
- repositoryId,
468
+ var ABSENT_STRING_SENTINEL = "";
469
+ var SENTINEL_BINDING = "absentSentinel";
470
+ var ENTITY_REQUIRED_SLOTS = [
471
+ "entityType",
472
+ "entityLabel",
473
+ "slug",
474
+ "properties",
475
+ "createdBy",
476
+ "createdByType",
477
+ "createdAt",
478
+ "modifiedBy",
479
+ "modifiedByType",
480
+ "modifiedAt"
481
+ ];
482
+ var ENTITY_OPTIONAL_SLOTS = [
483
+ "summary",
484
+ "data",
485
+ "dataFormat",
486
+ "embedding",
487
+ "createdInConversation",
488
+ "createdFromMessage",
489
+ "modifiedInConversation",
490
+ "modifiedFromMessage"
491
+ ];
492
+ var RELATIONSHIP_REQUIRED_SLOTS = [
493
+ "relationshipType",
494
+ "sourceEntityId",
495
+ "targetEntityId",
496
+ "bidirectional",
497
+ "properties",
498
+ "createdBy",
499
+ "createdByType",
500
+ "createdAt",
501
+ "modifiedBy",
502
+ "modifiedByType",
503
+ "modifiedAt"
504
+ ];
505
+ var RELATIONSHIP_OPTIONAL_SLOTS = [
506
+ "createdInConversation",
507
+ "createdFromMessage",
508
+ "modifiedInConversation",
509
+ "modifiedFromMessage"
510
+ ];
511
+ var REPOSITORY_REQUIRED_SLOTS = [
512
+ "repoLabel",
513
+ "governanceConfig",
514
+ "createdAt",
515
+ "createdBy"
516
+ ];
517
+ var REPOSITORY_OPTIONAL_SLOTS = [
518
+ "description",
519
+ "type",
520
+ "legal",
521
+ "owner",
522
+ "metadata"
523
+ ];
524
+ function buildLadder(required, optional, paramPrefix) {
525
+ const parts = [];
526
+ let i = 0;
527
+ for (const slot of required) {
528
+ parts.push(`.property('${slot}', ${paramPrefix}${i++})`);
529
+ }
530
+ for (const slot of optional) {
531
+ parts.push(
532
+ `.choose(__.constant(${paramPrefix}${i}).is(neq(${SENTINEL_BINDING})), __.property('${slot}', ${paramPrefix}${i}), __.identity())`
533
+ );
534
+ i++;
535
+ }
536
+ return parts.join("");
537
+ }
538
+ function buildLadderBindings(required, optional, paramPrefix, values) {
539
+ const bindings = {};
540
+ let i = 0;
541
+ for (const slot of required) {
542
+ const v = values[slot];
543
+ if (v == null) {
544
+ throw new Error(`Fixed-shape ladder: required slot '${slot}' is null/undefined`);
545
+ }
546
+ bindings[`${paramPrefix}${i++}`] = v;
547
+ }
548
+ for (const slot of optional) {
549
+ const v = values[slot];
550
+ bindings[`${paramPrefix}${i++}`] = v ?? ABSENT_STRING_SENTINEL;
551
+ }
552
+ return bindings;
553
+ }
554
+ function buildEntityPropertyLadder() {
555
+ return buildLadder(ENTITY_REQUIRED_SLOTS, ENTITY_OPTIONAL_SLOTS, "p");
556
+ }
557
+ function entityToLadderBindings(entity) {
558
+ const bindings = buildLadderBindings(ENTITY_REQUIRED_SLOTS, ENTITY_OPTIONAL_SLOTS, "p", {
224
559
  entityType: entity.entityType,
225
560
  entityLabel: entity.label,
226
561
  slug: entity.slug,
@@ -230,46 +565,120 @@ function entityToGremlinProps(repositoryId, entity) {
230
565
  createdAt: entity.provenance.createdAt,
231
566
  modifiedBy: entity.provenance.modifiedBy,
232
567
  modifiedByType: entity.provenance.modifiedByType,
233
- modifiedAt: entity.provenance.modifiedAt
234
- };
235
- if (entity.summary != null) props["summary"] = entity.summary;
236
- if (entity.data != null) props["data"] = entity.data;
237
- if (entity.dataFormat != null) props["dataFormat"] = entity.dataFormat;
238
- if (entity.provenance.createdInConversation != null) props["createdInConversation"] = entity.provenance.createdInConversation;
239
- if (entity.provenance.createdFromMessage != null) props["createdFromMessage"] = entity.provenance.createdFromMessage;
240
- if (entity.provenance.modifiedInConversation != null) props["modifiedInConversation"] = entity.provenance.modifiedInConversation;
241
- if (entity.provenance.modifiedFromMessage != null) props["modifiedFromMessage"] = entity.provenance.modifiedFromMessage;
242
- if (entity.embedding != null) props["embedding"] = JSON.stringify(entity.embedding);
243
- return props;
244
- }
245
- function relationshipToGremlinProps(repositoryId, rel) {
246
- const props = {
247
- repositoryId,
248
- relationshipType: rel.relationshipType,
249
- sourceEntityId: rel.sourceEntityId,
250
- targetEntityId: rel.targetEntityId,
251
- bidirectional: rel.bidirectional,
252
- properties: JSON.stringify(rel.properties ?? {}),
253
- createdBy: rel.provenance.createdBy,
254
- createdByType: rel.provenance.createdByType,
255
- createdAt: rel.provenance.createdAt,
256
- modifiedBy: rel.provenance.modifiedBy,
257
- modifiedByType: rel.provenance.modifiedByType,
258
- modifiedAt: rel.provenance.modifiedAt
259
- };
260
- if (rel.provenance.createdInConversation != null) props["createdInConversation"] = rel.provenance.createdInConversation;
261
- if (rel.provenance.createdFromMessage != null) props["createdFromMessage"] = rel.provenance.createdFromMessage;
262
- if (rel.provenance.modifiedInConversation != null) props["modifiedInConversation"] = rel.provenance.modifiedInConversation;
263
- if (rel.provenance.modifiedFromMessage != null) props["modifiedFromMessage"] = rel.provenance.modifiedFromMessage;
264
- return props;
568
+ modifiedAt: entity.provenance.modifiedAt,
569
+ summary: entity.summary,
570
+ data: entity.data,
571
+ dataFormat: entity.dataFormat,
572
+ embedding: entity.embedding != null ? JSON.stringify(entity.embedding) : void 0,
573
+ createdInConversation: entity.provenance.createdInConversation,
574
+ createdFromMessage: entity.provenance.createdFromMessage,
575
+ modifiedInConversation: entity.provenance.modifiedInConversation,
576
+ modifiedFromMessage: entity.provenance.modifiedFromMessage
577
+ });
578
+ bindings[SENTINEL_BINDING] = ABSENT_STRING_SENTINEL;
579
+ return bindings;
580
+ }
581
+ function buildRelationshipPropertyLadder() {
582
+ return buildLadder(RELATIONSHIP_REQUIRED_SLOTS, RELATIONSHIP_OPTIONAL_SLOTS, "p");
583
+ }
584
+ function relationshipToLadderBindings(rel) {
585
+ const bindings = buildLadderBindings(
586
+ RELATIONSHIP_REQUIRED_SLOTS,
587
+ RELATIONSHIP_OPTIONAL_SLOTS,
588
+ "p",
589
+ {
590
+ relationshipType: rel.relationshipType,
591
+ sourceEntityId: rel.sourceEntityId,
592
+ targetEntityId: rel.targetEntityId,
593
+ bidirectional: rel.bidirectional,
594
+ properties: JSON.stringify(rel.properties ?? {}),
595
+ createdBy: rel.provenance.createdBy,
596
+ createdByType: rel.provenance.createdByType,
597
+ createdAt: rel.provenance.createdAt,
598
+ modifiedBy: rel.provenance.modifiedBy,
599
+ modifiedByType: rel.provenance.modifiedByType,
600
+ modifiedAt: rel.provenance.modifiedAt,
601
+ createdInConversation: rel.provenance.createdInConversation,
602
+ createdFromMessage: rel.provenance.createdFromMessage,
603
+ modifiedInConversation: rel.provenance.modifiedInConversation,
604
+ modifiedFromMessage: rel.provenance.modifiedFromMessage
605
+ }
606
+ );
607
+ bindings[SENTINEL_BINDING] = ABSENT_STRING_SENTINEL;
608
+ return bindings;
609
+ }
610
+ function buildRepositoryPropertyLadder() {
611
+ return buildLadder(REPOSITORY_REQUIRED_SLOTS, REPOSITORY_OPTIONAL_SLOTS, "p");
612
+ }
613
+ function repositoryConfigToLadderBindings(config) {
614
+ const bindings = buildLadderBindings(
615
+ REPOSITORY_REQUIRED_SLOTS,
616
+ REPOSITORY_OPTIONAL_SLOTS,
617
+ "p",
618
+ {
619
+ repoLabel: config.label,
620
+ governanceConfig: JSON.stringify(config.governanceConfig),
621
+ createdAt: config.createdAt,
622
+ createdBy: config.createdBy,
623
+ description: config.description,
624
+ type: config.type,
625
+ legal: config.legal,
626
+ owner: config.owner,
627
+ metadata: config.metadata != null ? JSON.stringify(config.metadata) : void 0
628
+ }
629
+ );
630
+ bindings[SENTINEL_BINDING] = ABSENT_STRING_SENTINEL;
631
+ return bindings;
265
632
  }
266
633
 
267
634
  // src/queries/repository.ts
268
635
  import { DuplicateRepositoryError, RepositoryNotFoundError } from "@utaba/deep-memory";
269
636
  var REPO_LABEL = "_repository";
637
+ var REPOSITORY_INDEX_VERTEX_ID = "_repository_index";
638
+ var REPOSITORY_INDEX_PARTITION = "_index";
639
+ var REPOSITORY_INDEX_LABEL = "_repository_index";
270
640
  function repoVertexId(repositoryId) {
271
641
  return `repo:${repositoryId}`;
272
642
  }
643
+ async function ensureRepositoryIndex(conn) {
644
+ const existing = await conn.submit(
645
+ "g.V().has('repositoryId', pk).hasId(sid).count()",
646
+ { pk: REPOSITORY_INDEX_PARTITION, sid: REPOSITORY_INDEX_VERTEX_ID }
647
+ );
648
+ if (Number(existing.items[0] ?? 0) > 0) {
649
+ return null;
650
+ }
651
+ const scan = await conn.submit(
652
+ "g.V().hasLabel('_repository').values('repositoryId')",
653
+ {}
654
+ );
655
+ const ids = scan.items.map((item) => typeof item === "string" ? item : String(item ?? "")).filter((id) => id.length > 0);
656
+ await conn.submit(
657
+ "g.addV('" + REPOSITORY_INDEX_LABEL + "').property('id', sid).property('repositoryId', pk).property('repositoryIds', initial)",
658
+ {
659
+ pk: REPOSITORY_INDEX_PARTITION,
660
+ sid: REPOSITORY_INDEX_VERTEX_ID,
661
+ initial: JSON.stringify(ids)
662
+ }
663
+ );
664
+ return ids.length;
665
+ }
666
+ async function readRepositoryIndex(conn) {
667
+ const result = await conn.submit(
668
+ "g.V().has('repositoryId', pk).hasId(sid).values('repositoryIds')",
669
+ { pk: REPOSITORY_INDEX_PARTITION, sid: REPOSITORY_INDEX_VERTEX_ID }
670
+ );
671
+ if (result.items.length === 0) return [];
672
+ const raw = result.items[0];
673
+ const json = typeof raw === "string" ? raw : String(raw ?? "");
674
+ if (!json) return [];
675
+ try {
676
+ const parsed = JSON.parse(json);
677
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : [];
678
+ } catch {
679
+ return [];
680
+ }
681
+ }
273
682
  function propertyChain(bindings, props, startIndex) {
274
683
  const parts = [];
275
684
  let idx = startIndex;
@@ -281,33 +690,27 @@ function propertyChain(bindings, props, startIndex) {
281
690
  }
282
691
  return { chain: parts.join(""), nextIndex: idx };
283
692
  }
693
+ var REPOSITORY_CREATE_QUERY = `g.addV('${REPO_LABEL}').property('id', vid).property('repositoryId', rid)${buildRepositoryPropertyLadder()}.sideEffect(__.V().has('repositoryId', pk).hasId(sid).property('repositoryIds', updatedIndex))`;
284
694
  async function createRepository(conn, config) {
285
695
  const vertexId = repoVertexId(config.repositoryId);
286
696
  const existing = await conn.submit(
287
- "g.V().has('id', vid).has('label', lbl).count()",
288
- { vid: vertexId, lbl: REPO_LABEL }
697
+ "g.V().has('repositoryId', rid).hasId(vid).has('label', lbl).count()",
698
+ { vid: vertexId, rid: config.repositoryId, lbl: REPO_LABEL }
289
699
  );
290
700
  if (existing.items.length > 0 && Number(existing.items[0]) > 0) {
291
701
  throw new DuplicateRepositoryError(config.repositoryId);
292
702
  }
703
+ const currentIds = await readRepositoryIndex(conn);
704
+ const updatedIds = currentIds.includes(config.repositoryId) ? currentIds : [...currentIds, config.repositoryId];
293
705
  const bindings = {
294
706
  vid: vertexId,
295
- rid: config.repositoryId
296
- };
297
- const props = {
298
- repoLabel: config.label,
299
- description: config.description,
300
- type: config.type,
301
- legal: config.legal,
302
- owner: config.owner,
303
- governanceConfig: JSON.stringify(config.governanceConfig),
304
- metadata: config.metadata ? JSON.stringify(config.metadata) : void 0,
305
- createdAt: config.createdAt,
306
- createdBy: config.createdBy
707
+ rid: config.repositoryId,
708
+ pk: REPOSITORY_INDEX_PARTITION,
709
+ sid: REPOSITORY_INDEX_VERTEX_ID,
710
+ updatedIndex: JSON.stringify(updatedIds),
711
+ ...repositoryConfigToLadderBindings(config)
307
712
  };
308
- const { chain } = propertyChain(bindings, props, 0);
309
- const query = `g.addV('${REPO_LABEL}').property('id', vid).property('repositoryId', rid)${chain}`;
310
- await conn.submit(query, bindings);
713
+ await conn.submit(REPOSITORY_CREATE_QUERY, bindings);
311
714
  return {
312
715
  repositoryId: config.repositoryId,
313
716
  type: config.type,
@@ -322,9 +725,10 @@ async function createRepository(conn, config) {
322
725
  };
323
726
  }
324
727
  async function getRepository(conn, repositoryId) {
728
+ const projection = buildRepositoryProjectChain();
325
729
  const result = await conn.submit(
326
- "g.V().has('id', vid).hasLabel('_repository').valueMap(true)",
327
- { vid: repoVertexId(repositoryId) }
730
+ `g.V().has('repositoryId', rid).hasId(vid).hasLabel('_repository').${projection}`,
731
+ { vid: repoVertexId(repositoryId), rid: repositoryId }
328
732
  );
329
733
  if (result.items.length === 0) return null;
330
734
  return repositoryFromGremlin(result.items[0]);
@@ -332,27 +736,32 @@ async function getRepository(conn, repositoryId) {
332
736
  async function listRepositories(conn, filter) {
333
737
  const limit = filter?.limit ?? 20;
334
738
  const offset = filter?.offset ?? 0;
335
- let countQuery = "g.V().hasLabel('_repository')";
336
- let dataQuery = "g.V().hasLabel('_repository')";
337
- const bindings = {};
338
- if (filter?.type) {
339
- countQuery += ".has('type', filterType)";
340
- dataQuery += ".has('type', filterType)";
341
- bindings["filterType"] = filter.type;
739
+ const repositoryIds = await readRepositoryIndex(conn);
740
+ if (repositoryIds.length === 0) {
741
+ return { items: [], total: 0, hasMore: false, limit, offset };
342
742
  }
343
- const countResult = await conn.submit(`${countQuery}.count()`, bindings);
344
- const total = Number(countResult.items[0] ?? 0);
345
- bindings["rangeStart"] = offset;
346
- bindings["rangeEnd"] = offset + limit;
347
- const dataResult = await conn.submit(
348
- `${dataQuery}.range(rangeStart, rangeEnd).valueMap(true)`,
349
- bindings
743
+ const hydrated = await Promise.all(
744
+ repositoryIds.map((rid) => getRepository(conn, rid))
350
745
  );
351
- const items = dataResult.items.map(repositorySummaryFromGremlin);
746
+ let summaries = hydrated.filter((r) => r != null).map((r) => {
747
+ const summary = {
748
+ repositoryId: r.repositoryId,
749
+ label: r.label,
750
+ governanceConfig: r.governanceConfig
751
+ };
752
+ if (r.type !== void 0) summary.type = r.type;
753
+ if (r.description !== void 0) summary.description = r.description;
754
+ return summary;
755
+ });
756
+ if (filter?.type) {
757
+ summaries = summaries.filter((s) => s.type === filter.type);
758
+ }
759
+ const total = summaries.length;
760
+ const items = summaries.slice(offset, offset + limit);
352
761
  return {
353
762
  items,
354
763
  total,
355
- hasMore: offset + limit < total,
764
+ hasMore: offset + items.length < total,
356
765
  limit,
357
766
  offset
358
767
  };
@@ -361,7 +770,7 @@ async function updateRepository(conn, repositoryId, updates) {
361
770
  const vertexId = repoVertexId(repositoryId);
362
771
  const existing = await getRepository(conn, repositoryId);
363
772
  if (!existing) throw new RepositoryNotFoundError(repositoryId);
364
- const bindings = { vid: vertexId };
773
+ const bindings = { vid: vertexId, rid: repositoryId };
365
774
  const props = {};
366
775
  if (updates.label !== void 0) props["repoLabel"] = updates.label;
367
776
  if (updates.description !== void 0) props["description"] = updates.description;
@@ -375,7 +784,7 @@ async function updateRepository(conn, repositoryId, updates) {
375
784
  }
376
785
  if (Object.keys(props).length === 0) return existing;
377
786
  const { chain } = propertyChain(bindings, props, 0);
378
- const query = `g.V().has('id', vid).hasLabel('_repository')${chain}`;
787
+ const query = `g.V().has('repositoryId', rid).hasId(vid).hasLabel('_repository')${chain}`;
379
788
  await conn.submit(query, bindings);
380
789
  return await getRepository(conn, repositoryId);
381
790
  }
@@ -421,6 +830,18 @@ async function deleteRepository(conn, repositoryId, onProgress) {
421
830
  await onProgress?.({ entitiesDeleted, relationshipsDeleted, totalEntities, totalRelationships });
422
831
  if (remainingCount === 0) break;
423
832
  }
833
+ const currentIds = await readRepositoryIndex(conn);
834
+ const updatedIds = currentIds.filter((id) => id !== repositoryId);
835
+ if (updatedIds.length !== currentIds.length) {
836
+ await conn.submit(
837
+ "g.V().has('repositoryId', pk).hasId(sid).property('repositoryIds', updatedIndex)",
838
+ {
839
+ pk: REPOSITORY_INDEX_PARTITION,
840
+ sid: REPOSITORY_INDEX_VERTEX_ID,
841
+ updatedIndex: JSON.stringify(updatedIds)
842
+ }
843
+ );
844
+ }
424
845
  }
425
846
  async function deleteAllContents(conn, repositoryId, onProgress) {
426
847
  const entityCountResult = await conn.submit(
@@ -517,33 +938,39 @@ async function getRepositoryStats(conn, repositoryId) {
517
938
  function vocabVertexId(repositoryId) {
518
939
  return `vocab:${repositoryId}`;
519
940
  }
941
+ var EMPTY_VOCABULARY = () => ({
942
+ version: "0.0.0",
943
+ lastModified: (/* @__PURE__ */ new Date()).toISOString(),
944
+ modifiedBy: "system",
945
+ entityTypes: [],
946
+ relationshipTypes: []
947
+ });
520
948
  async function getVocabulary(conn, repositoryId) {
521
949
  const result = await conn.submit(
522
- "g.V().has('id', vid).hasLabel('_vocabulary').valueMap(true)",
523
- { vid: vocabVertexId(repositoryId) }
950
+ "g.V().has('repositoryId', rid).hasId(vid).hasLabel('_vocabulary').values('vocabulary').limit(1)",
951
+ { vid: vocabVertexId(repositoryId), rid: repositoryId }
524
952
  );
525
- if (result.items.length === 0) {
526
- return {
527
- version: "0.0.0",
528
- lastModified: (/* @__PURE__ */ new Date()).toISOString(),
529
- modifiedBy: "system",
530
- entityTypes: [],
531
- relationshipTypes: []
532
- };
953
+ if (result.items.length === 0) return EMPTY_VOCABULARY();
954
+ const raw = result.items[0];
955
+ const json = typeof raw === "string" ? raw : String(raw ?? "");
956
+ if (!json) return EMPTY_VOCABULARY();
957
+ try {
958
+ return JSON.parse(json);
959
+ } catch {
960
+ return EMPTY_VOCABULARY();
533
961
  }
534
- return vocabularyFromGremlin(result.items[0]);
535
962
  }
536
963
  async function saveVocabulary(conn, repositoryId, vocabulary) {
537
964
  const vid = vocabVertexId(repositoryId);
538
965
  const vocabJson = JSON.stringify(vocabulary);
539
966
  const existing = await conn.submit(
540
- "g.V().has('id', vid).hasLabel('_vocabulary').count()",
541
- { vid }
967
+ "g.V().has('repositoryId', rid).hasId(vid).hasLabel('_vocabulary').count()",
968
+ { vid, rid: repositoryId }
542
969
  );
543
970
  if (Number(existing.items[0] ?? 0) > 0) {
544
971
  await conn.submit(
545
- "g.V().has('id', vid).hasLabel('_vocabulary').property('vocabulary', vocabJson)",
546
- { vid, vocabJson }
972
+ "g.V().has('repositoryId', rid).hasId(vid).hasLabel('_vocabulary').property('vocabulary', vocabJson)",
973
+ { vid, rid: repositoryId, vocabJson }
547
974
  );
548
975
  } else {
549
976
  await conn.submit(
@@ -555,66 +982,68 @@ async function saveVocabulary(conn, repositoryId, vocabulary) {
555
982
  async function getVocabularyChangeLog(conn, repositoryId, options) {
556
983
  const limit = options?.limit ?? 10;
557
984
  const offset = options?.offset ?? 0;
558
- const countResult = await conn.submit(
559
- "g.V().has('repositoryId', rid).hasLabel('_vocabularyChangeLog').count()",
560
- { rid: repositoryId }
561
- );
985
+ const [countResult, dataResult] = await Promise.all([
986
+ conn.submit(
987
+ "g.V().has('repositoryId', rid).hasLabel('_vocabularyChangeLog').count()",
988
+ { rid: repositoryId }
989
+ ),
990
+ conn.submit(
991
+ "g.V().has('repositoryId', rid).hasLabel('_vocabularyChangeLog').order().by('proposedAt', decr).range(rangeStart, rangeEnd).valueMap(true)",
992
+ { rid: repositoryId, rangeStart: offset, rangeEnd: offset + limit }
993
+ )
994
+ ]);
562
995
  const total = Number(countResult.items[0] ?? 0);
563
- const dataResult = await conn.submit(
564
- "g.V().has('repositoryId', rid).hasLabel('_vocabularyChangeLog').order().by('proposedAt', decr).range(rangeStart, rangeEnd).valueMap(true)",
565
- { rid: repositoryId, rangeStart: offset, rangeEnd: offset + limit }
566
- );
567
996
  const items = dataResult.items.map(changeRecordFromGremlin);
568
997
  return {
569
998
  items,
570
999
  total,
571
- hasMore: offset + limit < total,
1000
+ hasMore: offset + items.length < total,
572
1001
  limit,
573
1002
  offset
574
1003
  };
575
1004
  }
576
1005
 
577
1006
  // src/queries/entity.ts
578
- import { DuplicateEntityError } from "@utaba/deep-memory";
1007
+ import {
1008
+ DuplicateEntityError,
1009
+ EntityNotFoundError,
1010
+ buildVertexProjectChain,
1011
+ matchesPropertyFilters
1012
+ } from "@utaba/deep-memory";
1013
+ var DUPLICATE_SENTINEL = "__duplicate";
1014
+ var ENTITY_CREATE_QUERY = `g.V().has('repositoryId', rid).hasId(vid).fold().coalesce(unfold().constant('${DUPLICATE_SENTINEL}'),addV(vertexLabel).property('id', vid).property('repositoryId', rid)${buildEntityPropertyLadder()})`;
579
1015
  async function createEntity(conn, repositoryId, entity) {
580
- const existing = await conn.submit(
581
- "g.V().has('repositoryId', rid).has('id', eid).count()",
582
- { rid: repositoryId, eid: entity.id }
583
- );
584
- if (Number(existing.items[0] ?? 0) > 0) {
1016
+ const bindings = {
1017
+ rid: repositoryId,
1018
+ vid: entity.id,
1019
+ vertexLabel: entity.entityType,
1020
+ ...entityToLadderBindings(entity)
1021
+ };
1022
+ const result = await conn.submit(ENTITY_CREATE_QUERY, bindings);
1023
+ if (result.items[0] === DUPLICATE_SENTINEL) {
585
1024
  throw new DuplicateEntityError(entity.id);
586
1025
  }
587
- const props = entityToGremlinProps(repositoryId, entity);
588
- const bindings = { vid: entity.id };
589
- const propParts = [];
590
- let idx = 0;
591
- for (const [key, value] of Object.entries(props)) {
592
- const paramName = `p${idx++}`;
593
- bindings[paramName] = value;
594
- propParts.push(`.property('${key}', ${paramName})`);
595
- }
596
- bindings["vertexLabel"] = entity.entityType;
597
- const query = `g.addV(vertexLabel).property('id', vid)${propParts.join("")}`;
598
- await conn.submit(query, bindings);
599
1026
  return entity;
600
1027
  }
601
- async function getEntity(conn, repositoryId, entityId) {
1028
+ async function getEntity(conn, repositoryId, entityId, options) {
1029
+ const projection = buildVertexProjectChain({ withEmbedding: options?.loadEmbeddings });
602
1030
  const result = await conn.submit(
603
- "g.V().has('repositoryId', rid).has('id', eid).has('entityType').valueMap(true)",
1031
+ `g.V().has('repositoryId', rid).hasId(eid).has('entityType').${projection}`,
604
1032
  { rid: repositoryId, eid: entityId }
605
1033
  );
606
1034
  if (result.items.length === 0) return null;
607
1035
  return entityFromGremlin(result.items[0]);
608
1036
  }
609
- async function getEntityBySlug(conn, repositoryId, slug) {
1037
+ async function getEntityBySlug(conn, repositoryId, slug, options) {
1038
+ const projection = buildVertexProjectChain({ withEmbedding: options?.loadEmbeddings });
610
1039
  const result = await conn.submit(
611
- "g.V().has('repositoryId', rid).has('slug', slugVal).has('entityType').valueMap(true)",
1040
+ `g.V().has('repositoryId', rid).has('slug', slugVal).has('entityType').${projection}`,
612
1041
  { rid: repositoryId, slugVal: slug }
613
1042
  );
614
1043
  if (result.items.length === 0) return null;
615
1044
  return entityFromGremlin(result.items[0]);
616
1045
  }
617
- async function getEntities(conn, repositoryId, entityIds) {
1046
+ async function getEntities(conn, repositoryId, entityIds, options) {
618
1047
  if (entityIds.length === 0) return /* @__PURE__ */ new Map();
619
1048
  const bindings = { rid: repositoryId };
620
1049
  const idParams = [];
@@ -624,8 +1053,9 @@ async function getEntities(conn, repositoryId, entityIds) {
624
1053
  idParams.push(paramName);
625
1054
  });
626
1055
  const withinClause = `within(${idParams.join(", ")})`;
1056
+ const projection = buildVertexProjectChain({ withEmbedding: options?.loadEmbeddings });
627
1057
  const result = await conn.submit(
628
- `g.V().has('repositoryId', rid).has('id', ${withinClause}).has('entityType').valueMap(true)`,
1058
+ `g.V().has('repositoryId', rid).hasId(${withinClause}).has('entityType').${projection}`,
629
1059
  bindings
630
1060
  );
631
1061
  const map = /* @__PURE__ */ new Map();
@@ -663,132 +1093,124 @@ async function updateEntity(conn, repositoryId, entityId, updates) {
663
1093
  addProp("modifiedAt", updates.provenance.modifiedAt);
664
1094
  if (updates.provenance.modifiedInConversation != null) addProp("modifiedInConversation", updates.provenance.modifiedInConversation);
665
1095
  if (updates.provenance.modifiedFromMessage != null) addProp("modifiedFromMessage", updates.provenance.modifiedFromMessage);
666
- const query = `g.V().has('repositoryId', rid).has('id', eid).has('entityType')${propParts.join("")}`;
667
- await conn.submit(query, bindings);
668
- return await getEntity(conn, repositoryId, entityId);
1096
+ const projection = buildVertexProjectChain();
1097
+ const query = `g.V().has('repositoryId', rid).hasId(eid).has('entityType')${propParts.join("")}.${projection}`;
1098
+ const result = await conn.submit(query, bindings);
1099
+ if (result.items.length === 0) {
1100
+ throw new EntityNotFoundError(entityId);
1101
+ }
1102
+ return entityFromGremlin(result.items[0]);
669
1103
  }
670
1104
  async function deleteEntity(conn, repositoryId, entityId) {
671
1105
  await conn.submit(
672
- "g.V().has('repositoryId', rid).has('id', eid).has('entityType').drop()",
1106
+ "g.V().has('repositoryId', rid).hasId(eid).has('entityType').drop()",
673
1107
  { rid: repositoryId, eid: entityId }
674
1108
  );
675
1109
  }
676
1110
  async function deleteEntitiesByType(conn, repositoryId, entityType) {
677
- const entityCountResult = await conn.submit(
678
- "g.V().has('repositoryId', rid).has('entityType', etype).count()",
679
- { rid: repositoryId, etype: entityType }
680
- );
681
- const deletedEntities = Number(entityCountResult.items[0] ?? 0);
682
- const relCountResult = await conn.submit(
683
- "g.V().has('repositoryId', rid).has('entityType', etype).bothE().dedup().count()",
1111
+ const result = await conn.submit(
1112
+ "g.V().has('repositoryId', rid).has('entityType', etype).aggregate('found').by('id').drop().cap('found')",
684
1113
  { rid: repositoryId, etype: entityType }
685
1114
  );
686
- const deletedRelationships = Number(relCountResult.items[0] ?? 0);
687
- if (deletedEntities > 0) {
688
- await conn.submit(
689
- "g.V().has('repositoryId', rid).has('entityType', etype).drop()",
690
- { rid: repositoryId, etype: entityType }
691
- );
692
- }
693
- return { deletedEntities, deletedRelationships };
1115
+ const bucket = result.items[0];
1116
+ const deletedEntities = Array.isArray(bucket) ? bucket.length : 0;
1117
+ return { deletedEntities, deletedRelationships: void 0 };
694
1118
  }
695
- async function findEntities(conn, repositoryId, query) {
696
- const bindings = { rid: repositoryId };
697
- let filterClause = ".has('repositoryId', rid).has('entityType')";
1119
+ function sqlPath(key) {
1120
+ return `c.${key}[0]._value`;
1121
+ }
1122
+ function buildWhereClause(query, repositoryId) {
1123
+ const params = [{ name: "@rid", value: repositoryId }];
1124
+ const predicates = ["c.repositoryId = @rid", "IS_DEFINED(c.entityType)"];
698
1125
  if (query.entityTypes && query.entityTypes.length > 0) {
699
- const typeParams = [];
1126
+ const typeParamNames = [];
700
1127
  query.entityTypes.forEach((t, i) => {
701
- const paramName = `etype${i}`;
702
- bindings[paramName] = t;
703
- typeParams.push(paramName);
1128
+ const name = `@etype${i}`;
1129
+ params.push({ name, value: t });
1130
+ typeParamNames.push(name);
704
1131
  });
705
- filterClause += `.has('entityType', within(${typeParams.join(", ")}))`;
706
- }
707
- const hasPropertyFilter = query.properties != null && Object.keys(query.properties).length > 0;
708
- const needsClientFilter = Boolean(query.searchTerm) || hasPropertyFilter;
709
- if (needsClientFilter) {
710
- const dataResult2 = await conn.submit(
711
- `g.V()${filterClause}.valueMap(true)`,
712
- bindings
1132
+ predicates.push(`${sqlPath("entityType")} IN (${typeParamNames.join(", ")})`);
1133
+ }
1134
+ if (query.searchTerm) {
1135
+ params.push({ name: "@term", value: query.searchTerm });
1136
+ predicates.push(
1137
+ `(CONTAINS(${sqlPath("entityLabel")}, @term, true) OR CONTAINS(${sqlPath("slug")}, @term, true) OR CONTAINS(${sqlPath("summary")}, @term, true))`
713
1138
  );
714
- let items2 = dataResult2.items.map(entityFromGremlin);
715
- if (query.searchTerm) {
716
- const term = query.searchTerm.toLowerCase();
717
- items2 = items2.filter(
718
- (e) => e.label.toLowerCase().includes(term) || e.slug.toLowerCase().includes(term) || e.summary != null && e.summary.toLowerCase().includes(term)
719
- );
720
- }
721
- if (hasPropertyFilter) {
722
- items2 = items2.filter((entity) => {
723
- for (const [key, value] of Object.entries(query.properties)) {
724
- if (entity.properties[key] !== value) return false;
725
- }
726
- return true;
727
- });
1139
+ }
1140
+ if (query.properties != null) {
1141
+ let i = 0;
1142
+ for (const [key, value] of Object.entries(query.properties)) {
1143
+ const fragment = JSON.stringify({ [key]: value }).slice(1, -1);
1144
+ const name = `@kv${i++}`;
1145
+ params.push({ name, value: fragment });
1146
+ predicates.push(`CONTAINS(${sqlPath("properties")}, ${name}, false)`);
728
1147
  }
729
- const total2 = items2.length;
730
- const paged = items2.slice(query.offset, query.offset + query.limit);
731
- return {
732
- items: paged,
733
- total: total2,
734
- hasMore: query.offset + query.limit < total2,
735
- limit: query.limit,
736
- offset: query.offset
737
- };
738
1148
  }
739
- const countResult = await conn.submit(
740
- `g.V()${filterClause}.count()`,
741
- bindings
742
- );
743
- const total = Number(countResult.items[0] ?? 0);
744
- bindings["rangeStart"] = query.offset;
745
- bindings["rangeEnd"] = query.offset + query.limit;
746
- const dataResult = await conn.submit(
747
- `g.V()${filterClause}.range(rangeStart, rangeEnd).valueMap(true)`,
748
- bindings
749
- );
750
- const items = dataResult.items.map(entityFromGremlin);
1149
+ return { sqlWhere: `WHERE ${predicates.join(" AND ")}`, params };
1150
+ }
1151
+ function buildSelectClause(loadEmbeddings) {
1152
+ const fields = ["c.id", ...STORED_ENTITY_FIELDS.filter((f) => f !== "id").map((f) => `c.${f}`)];
1153
+ if (loadEmbeddings) fields.push("c.embedding");
1154
+ return `SELECT ${fields.join(", ")}`;
1155
+ }
1156
+ async function findEntities(docClient, repositoryId, query, options) {
1157
+ const { sqlWhere, params } = buildWhereClause(query, repositoryId);
1158
+ const dataParams = [
1159
+ ...params,
1160
+ { name: "@off", value: query.offset },
1161
+ { name: "@lim", value: query.limit }
1162
+ ];
1163
+ const selectClause = buildSelectClause(options?.loadEmbeddings === true);
1164
+ const dataSql = `${selectClause} FROM c ${sqlWhere} ORDER BY c.id OFFSET @off LIMIT @lim`;
1165
+ const countSql = `SELECT VALUE COUNT(1) FROM c ${sqlWhere}`;
1166
+ const skipCount = query.properties != null && Object.keys(query.properties).length > 0;
1167
+ const [dataResult, countResult] = await Promise.all([
1168
+ docClient.query(dataSql, dataParams, {
1169
+ partitionKey: repositoryId
1170
+ }),
1171
+ skipCount ? Promise.resolve(null) : docClient.query(countSql, params, { partitionKey: repositoryId })
1172
+ ]);
1173
+ let items = dataResult.documents.map(entityFromDocument);
1174
+ if (query.properties != null && Object.keys(query.properties).length > 0) {
1175
+ const filters = Object.entries(query.properties).map(
1176
+ ([key, value]) => ({ key, operator: "eq", value })
1177
+ );
1178
+ items = items.filter((entity) => matchesPropertyFilters(entity.properties, filters));
1179
+ }
1180
+ const total = countResult && countResult.documents.length > 0 ? Number(countResult.documents[0]) : void 0;
1181
+ const hasMore = total != null ? query.offset + items.length < total : items.length === query.limit;
751
1182
  return {
752
1183
  items,
753
1184
  total,
754
- hasMore: query.offset + query.limit < total,
1185
+ hasMore,
755
1186
  limit: query.limit,
756
1187
  offset: query.offset
757
1188
  };
758
1189
  }
759
1190
 
760
1191
  // src/queries/relationship.ts
761
- import { DuplicateRelationshipError, matchesPropertyFilters } from "@utaba/deep-memory";
1192
+ import { DuplicateRelationshipError, matchesPropertyFilters as matchesPropertyFilters2, buildEdgeProjectChain } from "@utaba/deep-memory";
1193
+ var DUPLICATE_SENTINEL2 = "__duplicate";
1194
+ var RELATIONSHIP_CREATE_QUERY = `g.E().has('repositoryId', rid).hasId(relId).fold().coalesce(unfold().constant('${DUPLICATE_SENTINEL2}'),g.V().has('repositoryId', rid).hasId(srcId).has('entityType').addE(edgeLabel).to(g.V().has('repositoryId', rid).hasId(tgtId).has('entityType')).property('id', relId).property('repositoryId', rid)${buildRelationshipPropertyLadder()})`;
762
1195
  async function createRelationship(conn, repositoryId, relationship) {
763
- const existing = await conn.submit(
764
- "g.E().has('repositoryId', rid).has('id', relId).count()",
765
- { rid: repositoryId, relId: relationship.id }
766
- );
767
- if (Number(existing.items[0] ?? 0) > 0) {
768
- throw new DuplicateRelationshipError(relationship.id);
769
- }
770
- const props = relationshipToGremlinProps(repositoryId, relationship);
771
1196
  const bindings = {
1197
+ rid: repositoryId,
772
1198
  relId: relationship.id,
773
1199
  srcId: relationship.sourceEntityId,
774
1200
  tgtId: relationship.targetEntityId,
775
- rid: repositoryId,
776
- edgeLabel: relationship.relationshipType
1201
+ edgeLabel: relationship.relationshipType,
1202
+ ...relationshipToLadderBindings(relationship)
777
1203
  };
778
- const propParts = [];
779
- let idx = 0;
780
- for (const [key, value] of Object.entries(props)) {
781
- const paramName = `p${idx++}`;
782
- bindings[paramName] = value;
783
- propParts.push(`.property('${key}', ${paramName})`);
1204
+ const result = await conn.submit(RELATIONSHIP_CREATE_QUERY, bindings);
1205
+ if (result.items[0] === DUPLICATE_SENTINEL2) {
1206
+ throw new DuplicateRelationshipError(relationship.id);
784
1207
  }
785
- const query = `g.V().has('repositoryId', rid).has('id', srcId).has('entityType').addE(edgeLabel).to(g.V().has('repositoryId', rid).has('id', tgtId).has('entityType')).property('id', relId)${propParts.join("")}`;
786
- await conn.submit(query, bindings);
787
1208
  return relationship;
788
1209
  }
789
1210
  async function getRelationship(conn, repositoryId, relationshipId) {
1211
+ const projection = buildEdgeProjectChain();
790
1212
  const result = await conn.submit(
791
- "g.E().has('id', relId).has('repositoryId', rid).valueMap(true)",
1213
+ `g.E().hasId(relId).has('repositoryId', rid).${projection}`,
792
1214
  { relId: relationshipId, rid: repositoryId }
793
1215
  );
794
1216
  if (result.items.length === 0) return null;
@@ -798,21 +1220,22 @@ async function getEntityRelationships(conn, repositoryId, entityId, options) {
798
1220
  const limit = options?.limit ?? 50;
799
1221
  const offset = options?.offset ?? 0;
800
1222
  const direction = options?.direction ?? "both";
801
- const bindings = {
1223
+ const hasPropertyFilters = options?.propertyFilters != null && options.propertyFilters.length > 0;
1224
+ const baseBindings = {
802
1225
  rid: repositoryId,
803
1226
  eid: entityId
804
1227
  };
805
1228
  let edgeTraversal;
806
1229
  switch (direction) {
807
- case "outbound":
808
- edgeTraversal = "g.V().has('repositoryId', rid).has('id', eid).has('entityType').outE()";
1230
+ case "out":
1231
+ edgeTraversal = "g.V().has('repositoryId', rid).hasId(eid).has('entityType').outE()";
809
1232
  break;
810
- case "inbound":
811
- edgeTraversal = "g.V().has('repositoryId', rid).has('id', eid).has('entityType').inE()";
1233
+ case "in":
1234
+ edgeTraversal = "g.V().has('repositoryId', rid).hasId(eid).has('entityType').inE()";
812
1235
  break;
813
1236
  case "both":
814
1237
  default:
815
- edgeTraversal = "g.V().has('repositoryId', rid).has('id', eid).has('entityType').bothE()";
1238
+ edgeTraversal = "g.V().has('repositoryId', rid).hasId(eid).has('entityType').bothE()";
816
1239
  break;
817
1240
  }
818
1241
  let typeFilter = "";
@@ -820,218 +1243,63 @@ async function getEntityRelationships(conn, repositoryId, entityId, options) {
820
1243
  const typeParams = [];
821
1244
  options.relationshipTypes.forEach((t, i) => {
822
1245
  const paramName = `rtype${i}`;
823
- bindings[paramName] = t;
1246
+ baseBindings[paramName] = t;
824
1247
  typeParams.push(paramName);
825
1248
  });
826
1249
  typeFilter = `.hasLabel(${typeParams.join(", ")})`;
827
1250
  }
828
1251
  let unionQuery = null;
829
- if (direction === "outbound") {
830
- unionQuery = `g.V().has('repositoryId', rid).has('id', eid).has('entityType').union(outE()${typeFilter}, inE()${typeFilter}.has('bidirectional', true))`;
831
- } else if (direction === "inbound") {
832
- unionQuery = `g.V().has('repositoryId', rid).has('id', eid).has('entityType').union(inE()${typeFilter}, outE()${typeFilter}.has('bidirectional', true))`;
1252
+ if (direction === "out") {
1253
+ unionQuery = `g.V().has('repositoryId', rid).hasId(eid).has('entityType').union(outE()${typeFilter}, inE()${typeFilter}.has('bidirectional', true))`;
1254
+ } else if (direction === "in") {
1255
+ unionQuery = `g.V().has('repositoryId', rid).hasId(eid).has('entityType').union(inE()${typeFilter}, outE()${typeFilter}.has('bidirectional', true))`;
833
1256
  }
834
1257
  const baseQuery = unionQuery ?? `${edgeTraversal}${typeFilter}`;
835
- const countResult = await conn.submit(`${baseQuery}.dedup().count()`, bindings);
836
- const total = Number(countResult.items[0] ?? 0);
837
- bindings["rangeStart"] = offset;
838
- bindings["rangeEnd"] = offset + limit;
839
- const dataResult = await conn.submit(
840
- `${baseQuery}.dedup().range(rangeStart, rangeEnd).valueMap(true)`,
841
- bindings
842
- );
843
- let items = dataResult.items.map(relationshipFromGremlin);
844
- if (options?.propertyFilters && options.propertyFilters.length > 0) {
845
- items = items.filter((rel) => matchesPropertyFilters(rel.properties, options.propertyFilters));
846
- }
1258
+ const projection = buildEdgeProjectChain();
1259
+ const dataBindings = { ...baseBindings, rangeStart: offset, rangeEnd: offset + limit };
1260
+ const [countResult, dataResult] = await Promise.all([
1261
+ hasPropertyFilters ? Promise.resolve(null) : conn.submit(`${baseQuery}.dedup().count()`, baseBindings),
1262
+ conn.submit(
1263
+ `${baseQuery}.dedup().range(rangeStart, rangeEnd).${projection}`,
1264
+ dataBindings
1265
+ )
1266
+ ]);
1267
+ const rawItems = dataResult.items;
1268
+ let items = rawItems.map(relationshipFromGremlin);
1269
+ if (hasPropertyFilters) {
1270
+ items = items.filter((rel) => matchesPropertyFilters2(rel.properties, options.propertyFilters));
1271
+ }
1272
+ const total = countResult ? Number(countResult.items[0] ?? 0) : void 0;
1273
+ const hasMore = total != null ? offset + rawItems.length < total : rawItems.length === limit;
847
1274
  return {
848
1275
  items,
849
1276
  total,
850
- hasMore: offset + limit < total,
1277
+ hasMore,
851
1278
  limit,
852
1279
  offset
853
1280
  };
854
1281
  }
855
1282
  async function deleteRelationship(conn, repositoryId, relationshipId) {
856
1283
  await conn.submit(
857
- "g.E().has('id', relId).has('repositoryId', rid).drop()",
1284
+ "g.E().hasId(relId).has('repositoryId', rid).drop()",
858
1285
  { relId: relationshipId, rid: repositoryId }
859
1286
  );
860
1287
  }
861
1288
  async function deleteRelationshipsByType(conn, repositoryId, relationshipType) {
862
- const countResult = await conn.submit(
863
- "g.E().has('repositoryId', rid).hasLabel(rtype).count()",
1289
+ const result = await conn.submit(
1290
+ "g.E().has('repositoryId', rid).hasLabel(rtype).aggregate('found').by('id').drop().cap('found')",
864
1291
  { rid: repositoryId, rtype: relationshipType }
865
1292
  );
866
- const deletedRelationships = Number(countResult.items[0] ?? 0);
867
- if (deletedRelationships > 0) {
868
- await conn.submit(
869
- "g.E().has('repositoryId', rid).hasLabel(rtype).drop()",
870
- { rid: repositoryId, rtype: relationshipType }
871
- );
872
- }
1293
+ const bucket = result.items[0];
1294
+ const deletedRelationships = Array.isArray(bucket) ? bucket.length : 0;
873
1295
  return { deletedRelationships };
874
1296
  }
875
1297
 
876
- // src/queries/traversal.ts
877
- import { matchesPropertyFilters as matchesPropertyFilters2 } from "@utaba/deep-memory";
878
- async function exploreNeighborhood(conn, repositoryId, entityId, options) {
879
- const layers = [];
880
- let frontier = /* @__PURE__ */ new Set([entityId]);
881
- const visited = /* @__PURE__ */ new Set([entityId]);
882
- for (let depth = 0; depth < options.depth; depth++) {
883
- const layer = {};
884
- const nextFrontier = /* @__PURE__ */ new Set();
885
- const layerCache = /* @__PURE__ */ new Map();
886
- for (const currentId of frontier) {
887
- const rels = await getRelationshipsForEntity(conn, repositoryId, currentId, options);
888
- for (const rel of rels) {
889
- const relType = rel.relationshipType;
890
- if (!layer[relType]) {
891
- layer[relType] = { total: 0, entities: [], relationships: [] };
892
- }
893
- let connectedId;
894
- if (rel.sourceEntityId === currentId) {
895
- connectedId = rel.targetEntityId;
896
- } else {
897
- connectedId = rel.sourceEntityId;
898
- }
899
- if (visited.has(connectedId)) continue;
900
- let entity = layerCache.get(connectedId);
901
- if (entity === void 0) {
902
- const loaded = await getEntityLight(conn, repositoryId, connectedId);
903
- if (loaded && (!options.entityTypes || options.entityTypes.length === 0 || options.entityTypes.includes(loaded.entityType))) {
904
- entity = loaded;
905
- } else {
906
- entity = null;
907
- }
908
- layerCache.set(connectedId, entity);
909
- if (entity) nextFrontier.add(connectedId);
910
- }
911
- if (!entity) continue;
912
- layer[relType].entities.push(entity);
913
- layer[relType].relationships.push(rel);
914
- layer[relType].total = layer[relType].entities.length;
915
- }
916
- }
917
- for (const relType of Object.keys(layer)) {
918
- const group = layer[relType];
919
- const start = options.offsetPerType;
920
- const end = start + options.limitPerType;
921
- group.entities = group.entities.slice(start, end);
922
- group.relationships = group.relationships.slice(start, end);
923
- }
924
- if (Object.keys(layer).length > 0) {
925
- layers.push(layer);
926
- }
927
- for (const id of nextFrontier) {
928
- visited.add(id);
929
- }
930
- frontier = nextFrontier;
931
- if (frontier.size === 0) break;
932
- }
933
- return { centerId: entityId, layers };
934
- }
935
- async function getRelationshipsForEntity(conn, repositoryId, entityId, options) {
936
- const bindings = { rid: repositoryId, eid: entityId };
937
- let edgeTraversal;
938
- switch (options.direction) {
939
- case "outbound":
940
- edgeTraversal = "g.V().has('repositoryId', rid).has('id', eid).has('entityType').union(outE(), inE().has('bidirectional', true))";
941
- break;
942
- case "inbound":
943
- edgeTraversal = "g.V().has('repositoryId', rid).has('id', eid).has('entityType').union(inE(), outE().has('bidirectional', true))";
944
- break;
945
- case "both":
946
- default:
947
- edgeTraversal = "g.V().has('repositoryId', rid).has('id', eid).has('entityType').bothE()";
948
- break;
949
- }
950
- let typeFilter = "";
951
- if (options.relationshipTypes && options.relationshipTypes.length > 0) {
952
- const typeParams = [];
953
- options.relationshipTypes.forEach((t, i) => {
954
- const paramName = `rtype${i}`;
955
- bindings[paramName] = t;
956
- typeParams.push(paramName);
957
- });
958
- typeFilter = `.hasLabel(${typeParams.join(", ")})`;
959
- }
960
- const result = await conn.submit(
961
- `${edgeTraversal}${typeFilter}.dedup().valueMap(true)`,
962
- bindings
963
- );
964
- let rels = result.items.map(relationshipFromGremlin);
965
- if (options.relationshipPropertyFilters && options.relationshipPropertyFilters.length > 0) {
966
- rels = rels.filter((rel) => matchesPropertyFilters2(rel.properties, options.relationshipPropertyFilters));
967
- }
968
- return rels;
969
- }
970
- async function getEntityLight(conn, repositoryId, entityId) {
971
- const result = await conn.submit(
972
- "g.V().has('repositoryId', rid).has('id', eid).has('entityType').valueMap(true)",
973
- { rid: repositoryId, eid: entityId }
974
- );
975
- if (result.items.length === 0) return null;
976
- return entityFromGremlin(result.items[0]);
977
- }
978
- async function findPaths(conn, repositoryId, sourceId, targetId, options) {
979
- const paths = [];
980
- const maxDepth = options.maxDepth;
981
- const limit = options.limit;
982
- let queue = [{ entityId: sourceId, path: [sourceId], relationshipIds: [] }];
983
- for (let depth = 0; depth < maxDepth && paths.length < limit; depth++) {
984
- const nextQueue = [];
985
- for (const state of queue) {
986
- if (paths.length >= limit) break;
987
- const bindings = { rid: repositoryId, eid: state.entityId };
988
- let edgeQuery = "g.V().has('repositoryId', rid).has('id', eid).has('entityType').bothE()";
989
- if (options.relationshipTypes && options.relationshipTypes.length > 0) {
990
- const typeParams = [];
991
- options.relationshipTypes.forEach((t, i) => {
992
- const paramName = `rtype${i}`;
993
- bindings[paramName] = t;
994
- typeParams.push(paramName);
995
- });
996
- edgeQuery += `.hasLabel(${typeParams.join(", ")})`;
997
- }
998
- const relResult = await conn.submit(`${edgeQuery}.valueMap(true)`, bindings);
999
- const rels = relResult.items.map(relationshipFromGremlin);
1000
- let filteredRels = rels;
1001
- if (options.relationshipPropertyFilters && options.relationshipPropertyFilters.length > 0) {
1002
- filteredRels = rels.filter((r) => matchesPropertyFilters2(r.properties, options.relationshipPropertyFilters));
1003
- }
1004
- for (const rel of filteredRels) {
1005
- const nextId = rel.sourceEntityId === state.entityId ? rel.targetEntityId : rel.sourceEntityId;
1006
- if (state.path.includes(nextId)) continue;
1007
- if (options.entityTypes && options.entityTypes.length > 0 && nextId !== targetId) {
1008
- const entity = await getEntityLight(conn, repositoryId, nextId);
1009
- if (!entity || !options.entityTypes.includes(entity.entityType)) continue;
1010
- }
1011
- const newPath = [...state.path, nextId];
1012
- const newRelIds = [...state.relationshipIds, rel.id];
1013
- if (nextId === targetId) {
1014
- paths.push({ entityIds: newPath, relationshipIds: newRelIds });
1015
- if (paths.length >= limit) break;
1016
- } else {
1017
- nextQueue.push({ entityId: nextId, path: newPath, relationshipIds: newRelIds });
1018
- }
1019
- }
1020
- }
1021
- queue = nextQueue;
1022
- if (queue.length === 0) break;
1023
- }
1024
- return {
1025
- paths,
1026
- totalPaths: paths.length
1027
- };
1028
- }
1029
-
1030
1298
  // src/queries/timeline.ts
1031
1299
  async function getTimeline(conn, repositoryId, entityId, options) {
1032
1300
  const events = [];
1033
1301
  const entityResult = await conn.submit(
1034
- "g.V().has('repositoryId', rid).has('id', eid).has('entityType').valueMap('createdAt', 'modifiedAt')",
1302
+ "g.V().has('repositoryId', rid).hasId(eid).has('entityType').valueMap('createdAt', 'modifiedAt')",
1035
1303
  { rid: repositoryId, eid: entityId }
1036
1304
  );
1037
1305
  if (entityResult.items.length > 0) {
@@ -1054,7 +1322,7 @@ async function getTimeline(conn, repositoryId, entityId, options) {
1054
1322
  }
1055
1323
  }
1056
1324
  const relResult = await conn.submit(
1057
- "g.V().has('repositoryId', rid).has('id', eid).has('entityType').bothE().valueMap('id', 'createdAt')",
1325
+ "g.V().has('repositoryId', rid).hasId(eid).has('entityType').bothE().valueMap('id', 'createdAt')",
1058
1326
  { rid: repositoryId, eid: entityId }
1059
1327
  );
1060
1328
  for (const item of relResult.items) {
@@ -1283,7 +1551,7 @@ async function runTaskWithUsage(fn) {
1283
1551
  }
1284
1552
  }
1285
1553
  }
1286
- function sleep2(ms) {
1554
+ function sleep3(ms) {
1287
1555
  return new Promise((resolve) => setTimeout(resolve, ms));
1288
1556
  }
1289
1557
  async function runAdaptive(items, controller, fn) {
@@ -1308,7 +1576,7 @@ async function runAdaptive(items, controller, fn) {
1308
1576
  const slotsAvailable = inFlight < controller.getConcurrency();
1309
1577
  if (cooldownRemaining <= 0 && slotsAvailable) break;
1310
1578
  if (cooldownRemaining > 0) {
1311
- await Promise.race([sleep2(cooldownRemaining), gate.promise]);
1579
+ await Promise.race([sleep3(cooldownRemaining), gate.promise]);
1312
1580
  } else {
1313
1581
  await gate.promise;
1314
1582
  }
@@ -1482,88 +1750,79 @@ async function importBulk(conn, repositoryId, data, options) {
1482
1750
  }
1483
1751
  return { entitiesImported, relationshipsImported, errors };
1484
1752
  }
1753
+ var ENTITY_LADDER_CHAIN = buildEntityPropertyLadder();
1754
+ var RELATIONSHIP_LADDER_CHAIN = buildRelationshipPropertyLadder();
1755
+ var INSERT_ENTITY_QUERY = `g.addV(vertexLabel).property('id', vid).property('repositoryId', rid)${ENTITY_LADDER_CHAIN}`;
1756
+ var INSERT_RELATIONSHIP_QUERY = `g.V().has('repositoryId', rid).hasId(srcId).has('entityType').addE(edgeLabel).to(g.V().has('repositoryId', rid).hasId(tgtId).has('entityType')).property('id', relId).property('repositoryId', rid)${RELATIONSHIP_LADDER_CHAIN}`;
1757
+ var UPSERT_ENTITY_QUERY = `g.V().has('repositoryId', rid).hasId(vid).has('entityType').fold().coalesce(unfold()${ENTITY_LADDER_CHAIN}, addV(vertexLabel).property('id', vid).property('repositoryId', rid)${ENTITY_LADDER_CHAIN})`;
1758
+ var UPSERT_RELATIONSHIP_QUERY = `g.E().has('repositoryId', rid).hasId(relId).fold().coalesce(unfold()${RELATIONSHIP_LADDER_CHAIN}, g.V().has('repositoryId', rid).hasId(srcId).has('entityType').addE(edgeLabel).to(g.V().has('repositoryId', rid).hasId(tgtId).has('entityType')).property('id', relId).property('repositoryId', rid)${RELATIONSHIP_LADDER_CHAIN})`;
1485
1759
  async function insertEntity(conn, repositoryId, entity) {
1486
- const props = entityToGremlinProps(repositoryId, entity);
1487
1760
  const bindings = {
1761
+ rid: repositoryId,
1488
1762
  vid: entity.id,
1489
- vertexLabel: entity.entityType
1763
+ vertexLabel: entity.entityType,
1764
+ ...entityToLadderBindings(entity)
1490
1765
  };
1491
- const propParts = [];
1492
- let idx = 0;
1493
- for (const [key, value] of Object.entries(props)) {
1494
- const paramName = `p${idx++}`;
1495
- bindings[paramName] = value;
1496
- propParts.push(`.property('${key}', ${paramName})`);
1497
- }
1498
- const query = `g.addV(vertexLabel).property('id', vid)${propParts.join("")}`;
1499
- await conn.submit(query, bindings);
1766
+ await conn.submit(INSERT_ENTITY_QUERY, bindings);
1500
1767
  }
1501
1768
  async function insertRelationship(conn, repositoryId, rel) {
1502
- const props = relationshipToGremlinProps(repositoryId, rel);
1503
1769
  const bindings = {
1770
+ rid: repositoryId,
1504
1771
  relId: rel.id,
1505
1772
  srcId: rel.sourceEntityId,
1506
1773
  tgtId: rel.targetEntityId,
1507
- rid: repositoryId,
1508
- edgeLabel: rel.relationshipType
1774
+ edgeLabel: rel.relationshipType,
1775
+ ...relationshipToLadderBindings(rel)
1509
1776
  };
1510
- const propParts = [];
1511
- let idx = 0;
1512
- for (const [key, value] of Object.entries(props)) {
1513
- const paramName = `p${idx++}`;
1514
- bindings[paramName] = value;
1515
- propParts.push(`.property('${key}', ${paramName})`);
1516
- }
1517
- const query = `g.V().has('repositoryId', rid).has('id', srcId).has('entityType').addE(edgeLabel).to(g.V().has('repositoryId', rid).has('id', tgtId).has('entityType')).property('id', relId)${propParts.join("")}`;
1518
- await conn.submit(query, bindings);
1777
+ await conn.submit(INSERT_RELATIONSHIP_QUERY, bindings);
1519
1778
  }
1520
1779
  async function upsertEntity(conn, repositoryId, entity) {
1521
- const props = entityToGremlinProps(repositoryId, entity);
1522
1780
  const bindings = {
1523
- vid: entity.id,
1524
1781
  rid: repositoryId,
1525
- vertexLabel: entity.entityType
1782
+ vid: entity.id,
1783
+ vertexLabel: entity.entityType,
1784
+ ...entityToLadderBindings(entity)
1526
1785
  };
1527
- const propParts = [];
1528
- let idx = 0;
1529
- for (const [key, value] of Object.entries(props)) {
1530
- const paramName = `p${idx++}`;
1531
- bindings[paramName] = value;
1532
- propParts.push(`.property('${key}', ${paramName})`);
1533
- }
1534
- const query = `g.V().has('repositoryId', rid).has('id', vid).has('entityType').fold().coalesce(unfold()${propParts.join("")}, addV(vertexLabel).property('id', vid)${propParts.join("")})`;
1535
- await conn.submit(query, bindings);
1786
+ await conn.submit(UPSERT_ENTITY_QUERY, bindings);
1536
1787
  }
1537
1788
  async function upsertRelationship(conn, repositoryId, rel) {
1538
- const props = relationshipToGremlinProps(repositoryId, rel);
1539
1789
  const bindings = {
1790
+ rid: repositoryId,
1540
1791
  relId: rel.id,
1541
1792
  srcId: rel.sourceEntityId,
1542
1793
  tgtId: rel.targetEntityId,
1543
- rid: repositoryId,
1544
- edgeLabel: rel.relationshipType
1794
+ edgeLabel: rel.relationshipType,
1795
+ ...relationshipToLadderBindings(rel)
1545
1796
  };
1546
- const propParts = [];
1547
- let idx = 0;
1548
- for (const [key, value] of Object.entries(props)) {
1549
- const paramName = `p${idx++}`;
1550
- bindings[paramName] = value;
1551
- propParts.push(`.property('${key}', ${paramName})`);
1552
- }
1553
- const createEdge = `g.V().has('repositoryId', rid).has('id', srcId).has('entityType').addE(edgeLabel).to(g.V().has('repositoryId', rid).has('id', tgtId).has('entityType')).property('id', relId)${propParts.join("")}`;
1554
- const query = `g.E().has('repositoryId', rid).has('id', relId).fold().coalesce(unfold()${propParts.join("")}, ${createEdge})`;
1555
- await conn.submit(query, bindings);
1797
+ await conn.submit(UPSERT_RELATIONSHIP_QUERY, bindings);
1556
1798
  }
1557
1799
 
1558
1800
  // src/CosmosDbProvider.ts
1559
1801
  var PROVIDER_NAME = "cosmosdb";
1560
1802
  var SCHEMA_VERSION = 1;
1561
1803
  var META_VERTEX_ID = "_meta:schema";
1804
+ var GUARDED_INDEX_PATHS = [
1805
+ "/entityLabel",
1806
+ "/slug",
1807
+ "/summary",
1808
+ "/entityType",
1809
+ "/properties",
1810
+ "/repositoryId"
1811
+ ];
1812
+ var VOCABULARY_CACHE_TTL_MS = 6e4;
1562
1813
  var CosmosDbProvider = class {
1563
1814
  conn;
1815
+ docClient;
1564
1816
  config;
1565
1817
  compiler = new GremlinCompiler();
1566
1818
  reportUsage;
1819
+ /**
1820
+ * Per-process vocabulary cache, keyed by repositoryId. Read lazily by
1821
+ * `traverseImpl` via `getVocabularyCached`; invalidated on `saveVocabulary`.
1822
+ * Each provider instance owns its own cache so isolated test providers do
1823
+ * not share state.
1824
+ */
1825
+ vocabularyCache = /* @__PURE__ */ new Map();
1567
1826
  constructor(config) {
1568
1827
  this.config = config;
1569
1828
  this.reportUsage = createSafeSink(config.reportUsage);
@@ -1576,6 +1835,15 @@ var CosmosDbProvider = class {
1576
1835
  defaultTimeoutMs: config.defaultTimeoutMs,
1577
1836
  rejectUnauthorized: config.rejectUnauthorized
1578
1837
  });
1838
+ this.docClient = new CosmosDocumentClient({
1839
+ restEndpoint: this.getRestEndpoint(),
1840
+ key: config.key,
1841
+ database: config.database,
1842
+ container: config.container,
1843
+ rejectUnauthorized: config.rejectUnauthorized ?? true,
1844
+ maxRetries: config.maxRetries,
1845
+ defaultTimeoutMs: config.defaultTimeoutMs
1846
+ });
1579
1847
  }
1580
1848
  /**
1581
1849
  * Run the given operation inside an async-local usage scope. While the
@@ -1669,24 +1937,18 @@ var CosmosDbProvider = class {
1669
1937
  await this.conn.close();
1670
1938
  await this.conn.connect();
1671
1939
  const existing = await this.conn.submit(
1672
- "g.V().has('id', metaId).hasLabel('_meta').valueMap(true)",
1940
+ "g.V().hasId(metaId).hasLabel('_meta').valueMap(true)",
1673
1941
  { metaId: META_VERTEX_ID }
1674
1942
  );
1675
1943
  if (existing.items.length > 0) {
1676
1944
  const props = existing.items[0];
1677
1945
  const version = Number(unwrapGremlinValue(props["schemaVersion"]) ?? 0);
1678
- if (version >= SCHEMA_VERSION && !databaseCreated && !schemaCreated) {
1679
- return {
1680
- databaseCreated: false,
1681
- schemaCreated: false,
1682
- alreadyUpToDate: true,
1683
- schemaVersion: SCHEMA_VERSION
1684
- };
1946
+ if (version < SCHEMA_VERSION || databaseCreated || schemaCreated) {
1947
+ await this.conn.submit(
1948
+ "g.V().hasId(metaId).hasLabel('_meta').property('schemaVersion', ver)",
1949
+ { metaId: META_VERTEX_ID, ver: SCHEMA_VERSION }
1950
+ );
1685
1951
  }
1686
- await this.conn.submit(
1687
- "g.V().has('id', metaId).hasLabel('_meta').property('schemaVersion', ver)",
1688
- { metaId: META_VERTEX_ID, ver: SCHEMA_VERSION }
1689
- );
1690
1952
  } else {
1691
1953
  await this.conn.submit(
1692
1954
  "g.addV('_meta').property('id', metaId).property('repositoryId', pk).property('schemaVersion', ver)",
@@ -1694,6 +1956,8 @@ var CosmosDbProvider = class {
1694
1956
  );
1695
1957
  schemaCreated = true;
1696
1958
  }
1959
+ await ensureRepositoryIndex(this.conn);
1960
+ await this.runIndexingPolicyDiagnostic();
1697
1961
  return {
1698
1962
  databaseCreated,
1699
1963
  schemaCreated,
@@ -1707,6 +1971,51 @@ var CosmosDbProvider = class {
1707
1971
  );
1708
1972
  }
1709
1973
  }
1974
+ /**
1975
+ * Step E — verify the container's indexing policy covers every path that
1976
+ * the SQL `findEntities` rewrite hits. The probe (2026-05-26) confirmed
1977
+ * code-managed containers get the Cosmos default policy (everything
1978
+ * indexed). This guard is for operator-facing protection: externally
1979
+ * provisioned containers (ARM/Bicep) can have `excludedPaths` set, which
1980
+ * would force `findEntities` to scan rather than index-lookup.
1981
+ *
1982
+ * Single GET against the colls resource, minimal RU. Never fails
1983
+ * `ensureSchema` — a diagnostic must not break provisioning.
1984
+ */
1985
+ async runIndexingPolicyDiagnostic() {
1986
+ let policy;
1987
+ try {
1988
+ const props = await this.docClient.getContainerProperties();
1989
+ policy = props.indexingPolicy;
1990
+ } catch (err) {
1991
+ console.warn(
1992
+ `[CosmosDbProvider] could not verify indexing policy on container ${this.config.container}: ${err instanceof Error ? err.message : String(err)}`
1993
+ );
1994
+ return;
1995
+ }
1996
+ const offending = [];
1997
+ for (const entry of policy.excludedPaths) {
1998
+ const prefix = entry.path.replace(/\/[?*]$/, "");
1999
+ if (prefix === "" || prefix === "/") {
2000
+ for (const guard of GUARDED_INDEX_PATHS) {
2001
+ offending.push(`${guard} (covered by ${entry.path})`);
2002
+ }
2003
+ break;
2004
+ }
2005
+ for (const guard of GUARDED_INDEX_PATHS) {
2006
+ if (prefix === guard || guard.startsWith(prefix + "/")) {
2007
+ offending.push(`${guard} (excluded by ${entry.path})`);
2008
+ }
2009
+ }
2010
+ }
2011
+ if (offending.length > 0) {
2012
+ console.warn(
2013
+ `[CosmosDbProvider] Container ${this.config.container} has indexing-policy excludedPaths that cover paths used by findEntities. The query will fall back to scan and may exceed RU budgets:
2014
+ ${offending.join("\n ")}
2015
+ Verify the ARM/Bicep template that provisioned the container.`
2016
+ );
2017
+ }
2018
+ }
1710
2019
  /** Derive the REST endpoint from config — either explicit or from the Gremlin endpoint host. */
1711
2020
  getRestEndpoint() {
1712
2021
  if (this.config.restEndpoint) return this.config.restEndpoint.replace(/\/+$/, "");
@@ -1778,13 +2087,38 @@ var CosmosDbProvider = class {
1778
2087
  () => getVocabulary(this.conn, repositoryId)
1779
2088
  );
1780
2089
  }
2090
+ /**
2091
+ * Cached vocabulary read used by traversal compilation. The vocabulary is
2092
+ * compile-time context for the GremlinCompiler — it changes on the order of
2093
+ * once per session, but the traversal hot path pays one round-trip per call
2094
+ * to fetch it. The cache flips that to one round-trip per TTL window.
2095
+ *
2096
+ * Reads inside an active usage scope are still recorded if a fetch happens
2097
+ * (cache miss); cache hits emit no round-trip and therefore no usage entry.
2098
+ */
2099
+ async getVocabularyCached(repositoryId) {
2100
+ const now = Date.now();
2101
+ const cached = this.vocabularyCache.get(repositoryId);
2102
+ if (cached && cached.expiresAt > now) {
2103
+ return cached.vocab;
2104
+ }
2105
+ const vocab = await getVocabulary(this.conn, repositoryId);
2106
+ this.vocabularyCache.set(repositoryId, {
2107
+ vocab,
2108
+ expiresAt: now + VOCABULARY_CACHE_TTL_MS
2109
+ });
2110
+ return vocab;
2111
+ }
2112
+ /** Drop the cache entry for a repository — call after any vocabulary write. */
2113
+ invalidateVocabularyCache(repositoryId) {
2114
+ this.vocabularyCache.delete(repositoryId);
2115
+ }
1781
2116
  async saveVocabulary(repositoryId, vocabulary) {
1782
2117
  this.assertValidRepositoryId(repositoryId);
1783
- return this.track(
1784
- "saveVocabulary",
1785
- repositoryId,
1786
- () => saveVocabulary(this.conn, repositoryId, vocabulary)
1787
- );
2118
+ return this.track("saveVocabulary", repositoryId, async () => {
2119
+ await saveVocabulary(this.conn, repositoryId, vocabulary);
2120
+ this.invalidateVocabularyCache(repositoryId);
2121
+ });
1788
2122
  }
1789
2123
  async getVocabularyChangeLog(repositoryId, options) {
1790
2124
  this.assertValidRepositoryId(repositoryId);
@@ -1803,28 +2137,28 @@ var CosmosDbProvider = class {
1803
2137
  () => createEntity(this.conn, repositoryId, entity)
1804
2138
  );
1805
2139
  }
1806
- async getEntity(repositoryId, entityId) {
2140
+ async getEntity(repositoryId, entityId, options) {
1807
2141
  this.assertValidRepositoryId(repositoryId);
1808
2142
  return this.track(
1809
2143
  "getEntity",
1810
2144
  repositoryId,
1811
- () => getEntity(this.conn, repositoryId, entityId)
2145
+ () => getEntity(this.conn, repositoryId, entityId, options)
1812
2146
  );
1813
2147
  }
1814
- async getEntityBySlug(repositoryId, slug) {
2148
+ async getEntityBySlug(repositoryId, slug, options) {
1815
2149
  this.assertValidRepositoryId(repositoryId);
1816
2150
  return this.track(
1817
2151
  "getEntityBySlug",
1818
2152
  repositoryId,
1819
- () => getEntityBySlug(this.conn, repositoryId, slug)
2153
+ () => getEntityBySlug(this.conn, repositoryId, slug, options)
1820
2154
  );
1821
2155
  }
1822
- async getEntities(repositoryId, entityIds) {
2156
+ async getEntities(repositoryId, entityIds, options) {
1823
2157
  this.assertValidRepositoryId(repositoryId);
1824
2158
  return this.track(
1825
2159
  "getEntities",
1826
2160
  repositoryId,
1827
- () => getEntities(this.conn, repositoryId, entityIds)
2161
+ () => getEntities(this.conn, repositoryId, entityIds, options)
1828
2162
  );
1829
2163
  }
1830
2164
  async updateEntity(repositoryId, entityId, updates) {
@@ -1848,6 +2182,19 @@ var CosmosDbProvider = class {
1848
2182
  this.assertValidRepositoryId(repositoryId);
1849
2183
  return this.track("deleteEntities", repositoryId, () => this.deleteEntitiesImpl(repositoryId, ids));
1850
2184
  }
2185
+ /**
2186
+ * Single round-trip bulk-delete via the aggregate-side-effect pattern:
2187
+ * collapses the per-chunk existence-check + drop into one Gremlin call.
2188
+ *
2189
+ * g.V()...hasId(within(...)).has('entityType')
2190
+ * .aggregate('found').by('id') // collects the ids that match
2191
+ * .drop() // drops the vertices (and cascaded edges)
2192
+ * .cap('found') // emits the bucket as the single result
2193
+ *
2194
+ * The bucket is always emitted as a single list item — empty when nothing
2195
+ * matched (probe-verified 2026-05-25, local-tests/phase7-shape-probe.mjs).
2196
+ * `notFound` is derived client-side as the set difference.
2197
+ */
1851
2198
  async deleteEntitiesImpl(repositoryId, ids) {
1852
2199
  const deleted = [];
1853
2200
  const CHUNK = 100;
@@ -1860,26 +2207,14 @@ var CosmosDbProvider = class {
1860
2207
  bindings[p] = chunk[j];
1861
2208
  idParams.push(p);
1862
2209
  }
1863
- const withinExpr = `P.within(${idParams.join(", ")})`;
1864
- const existResult = await this.conn.submit(
1865
- `g.V().has('repositoryId', rid).has('id', ${withinExpr}).has('entityType').values('id')`,
2210
+ const withinExpr = `within(${idParams.join(", ")})`;
2211
+ const result = await this.conn.submit(
2212
+ `g.V().has('repositoryId', rid).hasId(${withinExpr}).has('entityType').aggregate('found').by('id').drop().cap('found')`,
1866
2213
  bindings
1867
2214
  );
1868
- const found = existResult.items;
1869
- if (found.length > 0) {
1870
- const dropBindings = { rid: repositoryId };
1871
- const dropIdParams = [];
1872
- for (let j = 0; j < found.length; j++) {
1873
- const p = `did${j}`;
1874
- dropBindings[p] = found[j];
1875
- dropIdParams.push(p);
1876
- }
1877
- const dropWithinExpr = `P.within(${dropIdParams.join(", ")})`;
1878
- await this.conn.submit(
1879
- `g.V().has('repositoryId', rid).has('id', ${dropWithinExpr}).has('entityType').drop()`,
1880
- dropBindings
1881
- );
1882
- deleted.push(...found);
2215
+ const bucket = result.items[0];
2216
+ if (Array.isArray(bucket)) {
2217
+ deleted.push(...bucket);
1883
2218
  }
1884
2219
  }
1885
2220
  const deletedSet = new Set(deleted);
@@ -1893,12 +2228,12 @@ var CosmosDbProvider = class {
1893
2228
  () => deleteEntitiesByType(this.conn, repositoryId, entityType)
1894
2229
  );
1895
2230
  }
1896
- async findEntities(repositoryId, query) {
2231
+ async findEntities(repositoryId, query, options) {
1897
2232
  this.assertValidRepositoryId(repositoryId);
1898
2233
  return this.track(
1899
2234
  "findEntities",
1900
2235
  repositoryId,
1901
- () => findEntities(this.conn, repositoryId, query)
2236
+ () => findEntities(this.docClient, repositoryId, query, options)
1902
2237
  );
1903
2238
  }
1904
2239
  // ─── Relationships ─────────────────────────────────────────────────
@@ -1939,6 +2274,20 @@ var CosmosDbProvider = class {
1939
2274
  this.assertValidRepositoryId(repositoryId);
1940
2275
  return this.track("deleteRelationships", repositoryId, () => this.deleteRelationshipsImpl(repositoryId, ids));
1941
2276
  }
2277
+ /**
2278
+ * Single round-trip bulk relationship delete via the aggregate-side-effect
2279
+ * pattern: collapses the per-chunk existence-check + drop into one Gremlin
2280
+ * call. Gremlin drop on edges is routed by the engine and the bucket gives
2281
+ * back the exact ids that were actually dropped, so `notFound` can be
2282
+ * derived client-side.
2283
+ *
2284
+ * Source-id partition routing is not exposed on this method (the public
2285
+ * surface accepts only edge ids), so the lookup may fan out across
2286
+ * partitions — see [docs/cosmosdb-gremlin-compatibility.md §`g.E().has`
2287
+ * doesn't always push partition down]. Callers that already hold a
2288
+ * StoredRelationship and want partition-scoped routing should add a
2289
+ * dedicated method when the need is concrete.
2290
+ */
1942
2291
  async deleteRelationshipsImpl(repositoryId, ids) {
1943
2292
  const deleted = [];
1944
2293
  const CHUNK = 100;
@@ -1951,23 +2300,14 @@ var CosmosDbProvider = class {
1951
2300
  bindings[p] = chunk[j];
1952
2301
  idParams.push(p);
1953
2302
  }
1954
- const withinExpr = `P.within(${idParams.join(", ")})`;
1955
- const existResult = await this.conn.submit(
1956
- `g.E().has('repositoryId', rid).has('id', ${withinExpr}).values('id')`,
2303
+ const withinExpr = `within(${idParams.join(", ")})`;
2304
+ const result = await this.conn.submit(
2305
+ `g.E().has('repositoryId', rid).hasId(${withinExpr}).aggregate('found').by('id').drop().cap('found')`,
1957
2306
  bindings
1958
2307
  );
1959
- const found = existResult.items;
1960
- if (found.length > 0) {
1961
- await cosmosRestBatchDelete(
1962
- this.getRestEndpoint(),
1963
- this.config.key,
1964
- this.config.database,
1965
- this.config.container,
1966
- repositoryId,
1967
- found,
1968
- this.config.rejectUnauthorized ?? true
1969
- );
1970
- deleted.push(...found);
2308
+ const bucket = result.items[0];
2309
+ if (Array.isArray(bucket)) {
2310
+ deleted.push(...bucket);
1971
2311
  }
1972
2312
  }
1973
2313
  const deletedSet = new Set(deleted);
@@ -1987,17 +2327,213 @@ var CosmosDbProvider = class {
1987
2327
  return this.track(
1988
2328
  "exploreNeighborhood",
1989
2329
  repositoryId,
1990
- () => exploreNeighborhood(this.conn, repositoryId, entityId, options)
2330
+ () => this.exploreNeighborhoodImpl(repositoryId, entityId, options)
1991
2331
  );
1992
2332
  }
2333
+ /**
2334
+ * Compiler-model implementation of exploreNeighborhood.
2335
+ *
2336
+ * Strategy: for each depth `d` from 1 to `options.depth`, build a
2337
+ * cumulative `TraversalSpec` with `d` steps and `returnMode: 'all'`, run
2338
+ * it through {@link executeTraversal}, then walk one BFS layer client-side
2339
+ * from the previous frontier using the returned edges.
2340
+ *
2341
+ * The server-side step direction is fixed to `'both'` (catches every edge
2342
+ * in either direction). The directional + bidirectional filter is applied
2343
+ * client-side per hop — i.e. `direction: 'out'` includes inbound edges
2344
+ * where `bidirectional` is true. The CosmosDB Gremlin compiler does not
2345
+ * natively express the `union(outE, inE.has(bidirectional))` shape that
2346
+ * would push this filter server-side, so it stays client-side; the
2347
+ * observable contract (which edges count toward `'out'` given
2348
+ * bidirectionality) is preserved.
2349
+ *
2350
+ * Round-trips per call: `options.depth` (one per layer). The previous BFS
2351
+ * was `1 + fanout + fanout² + …` round-trips for the same depth.
2352
+ */
2353
+ async exploreNeighborhoodImpl(repositoryId, entityId, options) {
2354
+ const layers = [];
2355
+ const visited = /* @__PURE__ */ new Set([entityId]);
2356
+ let frontier = /* @__PURE__ */ new Set([entityId]);
2357
+ for (let d = 1; d <= options.depth; d++) {
2358
+ if (frontier.size === 0) break;
2359
+ const spec = {
2360
+ start: { entityId },
2361
+ steps: buildExploreSteps(d, options),
2362
+ returnMode: "all",
2363
+ // Each round-trip fetches the cumulative subgraph at depth `d` and
2364
+ // we reconstruct layers client-side. The result can include every
2365
+ // edge and vertex reachable in ≤d hops in either direction; size
2366
+ // the limit generously.
2367
+ limit: 1e4,
2368
+ // Use 'full' + includeProvenance so executeTraversal returns full
2369
+ // StoredEntity rows (StorageNeighborhood embeds StoredEntity).
2370
+ detailLevel: "full",
2371
+ includeProvenance: true
2372
+ };
2373
+ const raw = await this.executeTraversal(repositoryId, spec);
2374
+ const edgesByVertex = /* @__PURE__ */ new Map();
2375
+ for (const rel of raw.allRelationships) {
2376
+ const a = edgesByVertex.get(rel.sourceEntityId);
2377
+ if (a) a.push(rel);
2378
+ else edgesByVertex.set(rel.sourceEntityId, [rel]);
2379
+ const b = edgesByVertex.get(rel.targetEntityId);
2380
+ if (b) b.push(rel);
2381
+ else edgesByVertex.set(rel.targetEntityId, [rel]);
2382
+ }
2383
+ const layer = {};
2384
+ const nextFrontier = /* @__PURE__ */ new Set();
2385
+ const layerEdgeSeen = /* @__PURE__ */ new Set();
2386
+ for (const fv of frontier) {
2387
+ const incident = edgesByVertex.get(fv) ?? [];
2388
+ for (const rel of incident) {
2389
+ const isSource = rel.sourceEntityId === fv;
2390
+ const isTarget = rel.targetEntityId === fv;
2391
+ let matchesDirection = false;
2392
+ let connectedId;
2393
+ if (isSource && (options.direction === "out" || options.direction === "both")) {
2394
+ matchesDirection = true;
2395
+ connectedId = rel.targetEntityId;
2396
+ } else if (isTarget && (options.direction === "in" || options.direction === "both")) {
2397
+ matchesDirection = true;
2398
+ connectedId = rel.sourceEntityId;
2399
+ } else if (rel.bidirectional) {
2400
+ if (isSource && options.direction === "in") {
2401
+ matchesDirection = true;
2402
+ connectedId = rel.targetEntityId;
2403
+ } else if (isTarget && options.direction === "out") {
2404
+ matchesDirection = true;
2405
+ connectedId = rel.sourceEntityId;
2406
+ }
2407
+ }
2408
+ if (!matchesDirection || !connectedId) continue;
2409
+ if (visited.has(connectedId)) continue;
2410
+ if (options.relationshipPropertyFilters && options.relationshipPropertyFilters.length > 0) {
2411
+ if (!matchesPropertyFilters3(rel.properties, options.relationshipPropertyFilters)) continue;
2412
+ }
2413
+ const connectedEntity = raw.entityMap.get(connectedId);
2414
+ if (!connectedEntity) continue;
2415
+ if (options.entityTypes && options.entityTypes.length > 0 && !options.entityTypes.includes(connectedEntity.entityType)) {
2416
+ continue;
2417
+ }
2418
+ const edgeKey = `${rel.id}|${fv}->${connectedId}`;
2419
+ if (layerEdgeSeen.has(edgeKey)) continue;
2420
+ layerEdgeSeen.add(edgeKey);
2421
+ const relType = rel.relationshipType;
2422
+ if (!layer[relType]) {
2423
+ layer[relType] = { total: 0, entities: [], relationships: [] };
2424
+ }
2425
+ layer[relType].entities.push(connectedEntity);
2426
+ layer[relType].relationships.push(rel);
2427
+ layer[relType].total = layer[relType].entities.length;
2428
+ nextFrontier.add(connectedId);
2429
+ }
2430
+ }
2431
+ for (const relType of Object.keys(layer)) {
2432
+ const group = layer[relType];
2433
+ const start = options.offsetPerType;
2434
+ const end = start + options.limitPerType;
2435
+ group.entities = group.entities.slice(start, end);
2436
+ group.relationships = group.relationships.slice(start, end);
2437
+ }
2438
+ if (Object.keys(layer).length > 0) {
2439
+ layers.push(layer);
2440
+ }
2441
+ for (const id of nextFrontier) {
2442
+ visited.add(id);
2443
+ }
2444
+ frontier = nextFrontier;
2445
+ }
2446
+ return { centerId: entityId, layers };
2447
+ }
1993
2448
  async findPaths(repositoryId, sourceId, targetId, options) {
1994
2449
  this.assertValidRepositoryId(repositoryId);
1995
2450
  return this.track(
1996
2451
  "findPaths",
1997
2452
  repositoryId,
1998
- () => findPaths(this.conn, repositoryId, sourceId, targetId, options)
2453
+ () => this.findPathsImpl(repositoryId, sourceId, targetId, options)
1999
2454
  );
2000
2455
  }
2456
+ /**
2457
+ * Compiler-model implementation of findPaths.
2458
+ *
2459
+ * Strategy: build a `TraversalSpec` with a single `'both'` direction step
2460
+ * in `repeat()` mode with `emitIntermediates: true` and `returnMode:
2461
+ * 'path'`, run it once. The compiler always emits `.simplePath()` in path
2462
+ * mode for cycle prevention, producing
2463
+ * `.emit().repeat(bothE().otherV()).times(maxDepth).simplePath()
2464
+ * .range(...).path().by(<v>).by(<e>)`, which yields paths of every length
2465
+ * from 0 (the start vertex alone) to `maxDepth`. Live-probed against the
2466
+ * Cosmos emulator 2026-05-25 — see local-tests/phase4-repeat-emit-probe.mjs.
2467
+ *
2468
+ * The pre-Phase-4 Cosmos BFS in (now-deleted) `packages/storage-cosmosdb/
2469
+ * src/queries/traversal.ts` traversed edges with unconditional `bothE()`
2470
+ * regardless of the `bidirectional` flag (path discovery is reachability,
2471
+ * not semantic direction). Mirror that here by using step direction
2472
+ * `'both'` and not applying any direction filter. The plan's §6
2473
+ * observable-outputs rule requires preserving this.
2474
+ *
2475
+ * One round-trip total. The previous BFS was up to `1 + fanout + fanout² + …`.
2476
+ */
2477
+ async findPathsImpl(repositoryId, sourceId, targetId, options) {
2478
+ if (sourceId === targetId) {
2479
+ return { paths: [{ entityIds: [sourceId], relationshipIds: [] }], totalPaths: 1 };
2480
+ }
2481
+ const step = {
2482
+ direction: "both",
2483
+ repeat: { maxDepth: options.maxDepth, emitIntermediates: true }
2484
+ };
2485
+ if (options.relationshipTypes && options.relationshipTypes.length > 0) {
2486
+ step.relationshipTypes = options.relationshipTypes;
2487
+ }
2488
+ if (options.relationshipPropertyFilters && options.relationshipPropertyFilters.length > 0) {
2489
+ step.relationshipFilter = options.relationshipPropertyFilters;
2490
+ }
2491
+ const spec = {
2492
+ start: { entityId: sourceId },
2493
+ steps: [step],
2494
+ returnMode: "path",
2495
+ // Cycle prevention comes from the compiler unconditionally emitting
2496
+ // .simplePath() in path mode — replaces the explicit
2497
+ // `state.path.includes(nextId)` guard from the old BFS.
2498
+ // Pull the full pool of paths so we can post-filter to those ending at
2499
+ // targetId, then paginate. The emulator returns paths of every length
2500
+ // 0..maxDepth in one round-trip with the repeat+emit shape; cap
2501
+ // generously to ensure all candidates are inspected.
2502
+ limit: Math.max(options.limit + options.offset, options.limit) * 10,
2503
+ detailLevel: "full",
2504
+ includeProvenance: true
2505
+ };
2506
+ const raw = await this.executeTraversal(repositoryId, spec);
2507
+ const matchingPaths = [];
2508
+ for (const row of raw.pathRows) {
2509
+ const last = row.entityIds[row.entityIds.length - 1];
2510
+ if (last !== targetId) continue;
2511
+ if (options.entityTypes && options.entityTypes.length > 0) {
2512
+ let rejected = false;
2513
+ for (let i = 1; i < row.entityIds.length - 1; i++) {
2514
+ const intermediate = raw.entityMap.get(row.entityIds[i]);
2515
+ if (!intermediate) {
2516
+ rejected = true;
2517
+ break;
2518
+ }
2519
+ if (!options.entityTypes.includes(intermediate.entityType)) {
2520
+ rejected = true;
2521
+ break;
2522
+ }
2523
+ }
2524
+ if (rejected) continue;
2525
+ }
2526
+ matchingPaths.push({
2527
+ entityIds: [...row.entityIds],
2528
+ relationshipIds: [...row.relationshipIds]
2529
+ });
2530
+ }
2531
+ const paginated = matchingPaths.slice(options.offset, options.offset + options.limit);
2532
+ return {
2533
+ paths: paginated,
2534
+ totalPaths: matchingPaths.length
2535
+ };
2536
+ }
2001
2537
  // ─── Timeline ──────────────────────────────────────────────────────
2002
2538
  async getTimeline(repositoryId, entityId, options) {
2003
2539
  this.assertValidRepositoryId(repositoryId);
@@ -2085,79 +2621,224 @@ var CosmosDbProvider = class {
2085
2621
  }
2086
2622
  async traverse(repositoryId, spec) {
2087
2623
  this.assertValidRepositoryId(repositoryId);
2088
- return this.track("traverse", repositoryId, () => this.traverseImpl(repositoryId, spec));
2624
+ return this.track("traverse", repositoryId, () => this.traverseInternal(repositoryId, spec));
2625
+ }
2626
+ /**
2627
+ * Compile + execute a TraversalSpec against this repository's partition.
2628
+ *
2629
+ * Internal entrypoint shared by `traverse` (the public surface) and the
2630
+ * compiler-model rewrites of `exploreNeighborhood` / `findPaths`. Does NOT
2631
+ * wrap in `track()` — the outer public method owns its own usage scope, and
2632
+ * inner submits accumulate into that scope (the nested-scope guard in
2633
+ * `track()` keeps nested public calls from emitting duplicate records).
2634
+ */
2635
+ async traverseInternal(repositoryId, spec) {
2636
+ const raw = await this.executeTraversal(repositoryId, spec);
2637
+ const detailLevel = spec.detailLevel ?? "summary";
2638
+ const projectStoredEntity = (stored) => {
2639
+ const projected = projectEntity(stored, detailLevel);
2640
+ if (!spec.includeProvenance) {
2641
+ delete projected["provenance"];
2642
+ }
2643
+ return projected;
2644
+ };
2645
+ const projectStoredRelationship = (rel, direction = "out") => ({
2646
+ id: rel.id,
2647
+ type: rel.relationshipType,
2648
+ sourceEntityId: rel.sourceEntityId,
2649
+ targetEntityId: rel.targetEntityId,
2650
+ direction,
2651
+ properties: rel.properties
2652
+ });
2653
+ let entities = [];
2654
+ let relationships;
2655
+ let paths;
2656
+ const rangeRowCount = spec.returnMode === "all" ? raw.allEntities.length + raw.allRelationships.length : 0;
2657
+ if (spec.returnMode === "terminal") {
2658
+ entities = raw.terminalEntities.map(projectStoredEntity);
2659
+ relationships = void 0;
2660
+ } else if (spec.returnMode === "all") {
2661
+ const missingIds = /* @__PURE__ */ new Set();
2662
+ for (const rel of raw.allRelationships) {
2663
+ if (!raw.entityMap.has(rel.sourceEntityId)) {
2664
+ missingIds.add(rel.sourceEntityId);
2665
+ }
2666
+ if (!raw.entityMap.has(rel.targetEntityId)) {
2667
+ missingIds.add(rel.targetEntityId);
2668
+ }
2669
+ }
2670
+ if (missingIds.size > 0) {
2671
+ const fetched = await this.getEntities(repositoryId, [...missingIds]);
2672
+ for (const stored of fetched.values()) {
2673
+ raw.allEntities.push(stored);
2674
+ raw.entityMap.set(stored.id, stored);
2675
+ }
2676
+ }
2677
+ entities = raw.allEntities.map(projectStoredEntity);
2678
+ relationships = raw.allRelationships.map((r) => projectStoredRelationship(r));
2679
+ } else {
2680
+ paths = raw.pathRows.map((row) => ({
2681
+ length: Math.max(row.entityIds.length - 1, 0),
2682
+ entities: row.entityIds.map((id) => {
2683
+ const stored = raw.entityMap.get(id);
2684
+ if (!stored) {
2685
+ throw new ProviderError(
2686
+ "Unpacking Gremlin path: entity referenced by path is missing from the result",
2687
+ "This indicates a Gremlin response shape mismatch \u2014 inspect compiledQuery."
2688
+ );
2689
+ }
2690
+ return projectStoredEntity(stored);
2691
+ }),
2692
+ relationships: row.relationshipIds.map((id, i) => {
2693
+ const stored = raw.relationshipMap.get(id);
2694
+ if (!stored) {
2695
+ throw new ProviderError(
2696
+ "Unpacking Gremlin path: relationship referenced by path is missing from the result",
2697
+ "This indicates a Gremlin response shape mismatch \u2014 inspect compiledQuery."
2698
+ );
2699
+ }
2700
+ return projectStoredRelationship(stored, row.relationshipDirections[i]);
2701
+ })
2702
+ }));
2703
+ relationships = Array.from(raw.relationshipMap.values()).map(
2704
+ (rel) => projectStoredRelationship(rel, raw.pathRelFirstDirection.get(rel.id) ?? "out")
2705
+ );
2706
+ }
2707
+ const limit = spec.limit ?? 50;
2708
+ let total;
2709
+ if (spec.returnMode === "path") {
2710
+ total = paths?.length ?? 0;
2711
+ } else if (spec.returnMode === "all") {
2712
+ total = entities.length + (relationships?.length ?? 0);
2713
+ } else {
2714
+ total = entities.length;
2715
+ }
2716
+ const paginationSignal = spec.returnMode === "all" ? rangeRowCount : total;
2717
+ const truncated = paginationSignal >= limit;
2718
+ const queryMetadata = {
2719
+ executionTimeMs: raw.executionTimeMs,
2720
+ resourceCost: raw.requestCharge != null ? { units: "RU", value: raw.requestCharge } : void 0,
2721
+ compiledQuery: raw.compiledQuery,
2722
+ compiledQueryLanguage: "gremlin",
2723
+ appliedLimits: {
2724
+ maxResults: limit,
2725
+ maxDepth: spec.steps?.length
2726
+ },
2727
+ truncated,
2728
+ truncationReason: truncated ? "result_limit" : void 0
2729
+ };
2730
+ return {
2731
+ entities,
2732
+ relationships,
2733
+ paths,
2734
+ total,
2735
+ returned: total,
2736
+ hasMore: truncated,
2737
+ queryMetadata
2738
+ };
2089
2739
  }
2090
- async traverseImpl(repositoryId, spec) {
2740
+ /**
2741
+ * Lower-level traversal helper: compiles a spec, submits to Gremlin, and
2742
+ * parses the rows into raw {@link StoredEntity} / {@link StoredRelationship}
2743
+ * objects (no detail-level projection, no provenance stripping). Used by
2744
+ * `traverseInternal` and by the storage-layer rewrites of
2745
+ * `exploreNeighborhood` / `findPaths` that need the full stored shape to
2746
+ * rebuild `StorageNeighborhood` / `StoragePathResult`.
2747
+ *
2748
+ * Returns a discriminated bag — only the fields relevant to `spec.returnMode`
2749
+ * are populated:
2750
+ * - `'terminal'`: `terminalEntities` (in row order, no dedup beyond what the
2751
+ * server emitted).
2752
+ * - `'all'`: `allEntities` and `allRelationships`, already server-deduped.
2753
+ * - `'path'`: `pathRows` plus the `entityMap` / `relationshipMap` lookup
2754
+ * tables and `pathRelFirstDirection` for the first-seen direction per
2755
+ * deduped edge id.
2756
+ */
2757
+ async executeTraversal(repositoryId, spec) {
2091
2758
  const startTime = Date.now();
2092
- const vocabulary = await this.getVocabulary(repositoryId);
2759
+ const vocabulary = await this.getVocabularyCached(repositoryId);
2093
2760
  const compiled = this.compiler.compile(spec, vocabulary);
2094
2761
  const scopedQuery = compiled.query.replace(
2095
2762
  "g.V()",
2096
2763
  "g.V().has('repositoryId', pRid)"
2097
2764
  );
2098
2765
  const scopedParams = { ...compiled.params, pRid: repositoryId };
2766
+ let result;
2099
2767
  try {
2100
- const result = await this.conn.submit(scopedQuery, scopedParams);
2101
- const executionTimeMs = Date.now() - startTime;
2102
- const detailLevel = spec.detailLevel ?? "summary";
2103
- const entities = [];
2104
- const relationships = [];
2105
- const paths = [];
2106
- if (spec.returnMode === "path") {
2107
- for (const item of result.items) {
2108
- const pathData = item;
2109
- if (pathData.objects) {
2110
- const pathEntities = [];
2111
- for (const obj of pathData.objects) {
2112
- const props = obj;
2113
- if (props["entityType"]) {
2114
- const stored = entityFromGremlin(props);
2115
- pathEntities.push(projectEntity(stored, detailLevel));
2116
- }
2117
- }
2118
- if (pathEntities.length > 0) {
2119
- paths.push({
2120
- length: pathEntities.length - 1,
2121
- entities: pathEntities,
2122
- relationships: []
2123
- });
2124
- }
2125
- }
2126
- }
2127
- } else {
2128
- for (const item of result.items) {
2129
- const stored = entityFromGremlin(item);
2130
- entities.push(projectEntity(stored, detailLevel));
2131
- }
2132
- }
2133
- const limit = spec.limit ?? 50;
2134
- const queryMetadata = {
2135
- executionTimeMs,
2136
- resourceCost: result.requestCharge != null ? { units: "RU", value: result.requestCharge } : void 0,
2137
- compiledQuery: scopedQuery,
2138
- compiledQueryLanguage: "gremlin",
2139
- appliedLimits: {
2140
- maxResults: limit,
2141
- maxDepth: spec.steps?.length
2142
- },
2143
- truncated: entities.length >= limit,
2144
- truncationReason: entities.length >= limit ? "result_limit" : void 0
2145
- };
2146
- return {
2147
- entities: spec.returnMode === "path" ? [] : entities,
2148
- relationships: spec.returnMode === "path" || spec.returnMode === "all" ? relationships : void 0,
2149
- paths: spec.returnMode === "path" ? paths : void 0,
2150
- total: entities.length + paths.length,
2151
- returned: entities.length + paths.length,
2152
- hasMore: entities.length >= limit || paths.length >= limit,
2153
- queryMetadata
2154
- };
2768
+ result = await this.conn.submit(scopedQuery, scopedParams);
2155
2769
  } catch (err) {
2156
2770
  throw new ProviderError(
2157
2771
  `Gremlin traversal failed: ${err instanceof Error ? err.message : String(err)}`,
2158
2772
  "Check the traversal spec and ensure the CosmosDB connection is healthy."
2159
2773
  );
2160
2774
  }
2775
+ const executionTimeMs = Date.now() - startTime;
2776
+ const raw = {
2777
+ terminalEntities: [],
2778
+ allEntities: [],
2779
+ allRelationships: [],
2780
+ pathRows: [],
2781
+ entityMap: /* @__PURE__ */ new Map(),
2782
+ relationshipMap: /* @__PURE__ */ new Map(),
2783
+ pathRelFirstDirection: /* @__PURE__ */ new Map(),
2784
+ executionTimeMs,
2785
+ requestCharge: result.requestCharge,
2786
+ compiledQuery: scopedQuery
2787
+ };
2788
+ if (spec.returnMode === "terminal") {
2789
+ for (const item of result.items) {
2790
+ const stored = entityFromGremlin(item);
2791
+ raw.terminalEntities.push(stored);
2792
+ }
2793
+ } else if (spec.returnMode === "all") {
2794
+ for (const item of result.items) {
2795
+ const props = item;
2796
+ const kind = props["__kind"];
2797
+ if (kind === "v") {
2798
+ const stored = entityFromGremlin(props);
2799
+ raw.allEntities.push(stored);
2800
+ raw.entityMap.set(stored.id, stored);
2801
+ } else if (kind === "e") {
2802
+ const stored = relationshipFromGremlin(props);
2803
+ raw.allRelationships.push(stored);
2804
+ raw.relationshipMap.set(stored.id, stored);
2805
+ }
2806
+ }
2807
+ } else {
2808
+ for (const item of result.items) {
2809
+ const pathData = item;
2810
+ if (!pathData.objects) continue;
2811
+ const pathEntityIds = [];
2812
+ const pathRelIds = [];
2813
+ const pathRelDirections = [];
2814
+ let lastVertexId = null;
2815
+ for (const obj of pathData.objects) {
2816
+ const props = obj;
2817
+ const kind = props["__kind"];
2818
+ if (kind === "v") {
2819
+ const stored = entityFromGremlin(props);
2820
+ raw.entityMap.set(stored.id, stored);
2821
+ pathEntityIds.push(stored.id);
2822
+ lastVertexId = stored.id;
2823
+ } else if (kind === "e") {
2824
+ const stored = relationshipFromGremlin(props);
2825
+ raw.relationshipMap.set(stored.id, stored);
2826
+ pathRelIds.push(stored.id);
2827
+ const direction = lastVertexId === stored.sourceEntityId ? "out" : "in";
2828
+ pathRelDirections.push(direction);
2829
+ if (!raw.pathRelFirstDirection.has(stored.id)) {
2830
+ raw.pathRelFirstDirection.set(stored.id, direction);
2831
+ }
2832
+ }
2833
+ }
2834
+ raw.pathRows.push({
2835
+ entityIds: pathEntityIds,
2836
+ relationshipIds: pathRelIds,
2837
+ relationshipDirections: pathRelDirections
2838
+ });
2839
+ }
2840
+ }
2841
+ return raw;
2161
2842
  }
2162
2843
  /**
2163
2844
  * Execute a raw Gremlin query with caller-supplied bindings.
@@ -2192,67 +2873,16 @@ function unwrapGremlinValue(val) {
2192
2873
  if (Array.isArray(val) && val.length > 0) return val[0];
2193
2874
  return val;
2194
2875
  }
2195
- function cosmosAuthToken(verb, resourceType, resourceLink, date, key) {
2196
- const payload = `${verb.toLowerCase()}
2197
- ${resourceType.toLowerCase()}
2198
- ${resourceLink}
2199
- ${date.toLowerCase()}
2200
-
2201
- `;
2202
- const keyBuffer = Buffer.from(key, "base64");
2203
- const hmac = crypto.createHmac("sha256", keyBuffer);
2204
- hmac.update(payload);
2205
- const signature = hmac.digest("base64");
2206
- return encodeURIComponent(`type=master&ver=1.0&sig=${signature}`);
2207
- }
2208
- async function cosmosRestPut(restBase, key, urlPath, resourceLink, resourceType, body, rejectUnauthorized) {
2209
- const date = (/* @__PURE__ */ new Date()).toUTCString();
2210
- const token = cosmosAuthToken("post", resourceType, resourceLink, date, key);
2211
- const url = `${restBase}/${urlPath}`;
2212
- const options = {
2213
- method: "POST",
2214
- headers: {
2215
- "Authorization": token,
2216
- "x-ms-version": "2018-12-31",
2217
- "x-ms-date": date,
2218
- "Content-Type": "application/json"
2219
- },
2220
- body: JSON.stringify(body)
2221
- };
2222
- if (!rejectUnauthorized) {
2223
- process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
2224
- }
2225
- const response = await fetch(url, options);
2226
- if (response.status === 201) return true;
2227
- if (response.status === 409) return false;
2228
- const text = await response.text();
2229
- throw new Error(`CosmosDB REST ${response.status}: ${text}`);
2230
- }
2231
- async function cosmosRestBatchDelete(restBase, key, database, container, partitionKeyValue, ids, rejectUnauthorized) {
2232
- const resourceLink = `dbs/${database}/colls/${container}`;
2233
- const date = (/* @__PURE__ */ new Date()).toUTCString();
2234
- const token = cosmosAuthToken("post", "docs", resourceLink, date, key);
2235
- if (!rejectUnauthorized) {
2236
- process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
2876
+ function buildExploreSteps(depth, options) {
2877
+ const base = { direction: "both" };
2878
+ if (options.relationshipTypes && options.relationshipTypes.length > 0) {
2879
+ base.relationshipTypes = options.relationshipTypes;
2237
2880
  }
2238
- const ops = ids.map((id) => ({ operationType: "Delete", id }));
2239
- const response = await fetch(`${restBase}/${resourceLink}/docs`, {
2240
- method: "POST",
2241
- headers: {
2242
- "Authorization": token,
2243
- "x-ms-version": "2020-07-15",
2244
- "x-ms-date": date,
2245
- "Content-Type": "application/json",
2246
- "x-ms-documentdb-partitionkey": JSON.stringify([partitionKeyValue]),
2247
- "x-ms-cosmos-is-batch-request": "true",
2248
- "x-ms-cosmos-batch-atomic": "true"
2249
- },
2250
- body: JSON.stringify(ops)
2251
- });
2252
- if (response.status !== 200 && response.status !== 207) {
2253
- const text = await response.text();
2254
- throw new ProviderError(`CosmosDB batch delete failed (${response.status}): ${text}`);
2881
+ const steps = [];
2882
+ for (let i = 0; i < depth; i++) {
2883
+ steps.push({ ...base });
2255
2884
  }
2885
+ return steps;
2256
2886
  }
2257
2887
  export {
2258
2888
  CosmosDbConnection,