@utaba/deep-memory-storage-cosmosdb 0.16.0 → 0.17.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/LICENSE +15 -4
- package/README.md +262 -0
- package/dist/index.cjs +1269 -671
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +136 -7
- package/dist/index.d.ts +136 -7
- package/dist/index.js +1256 -645
- package/dist/index.js.map +1 -1
- package/package.json +4 -5
package/dist/index.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
// src/CosmosDbProvider.ts
|
|
2
|
-
import
|
|
3
|
-
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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('
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
344
|
-
|
|
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
|
-
|
|
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 +
|
|
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('
|
|
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('
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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('
|
|
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('
|
|
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
|
|
559
|
-
|
|
560
|
-
|
|
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 +
|
|
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 {
|
|
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
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
|
667
|
-
|
|
668
|
-
|
|
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).
|
|
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
|
|
678
|
-
"g.V().has('repositoryId', rid).has('entityType', etype).
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
|
1126
|
+
const typeParamNames = [];
|
|
700
1127
|
query.entityTypes.forEach((t, i) => {
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
1128
|
+
const name = `@etype${i}`;
|
|
1129
|
+
params.push({ name, value: t });
|
|
1130
|
+
typeParamNames.push(name);
|
|
704
1131
|
});
|
|
705
|
-
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
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
|
-
|
|
776
|
-
|
|
1201
|
+
edgeLabel: relationship.relationshipType,
|
|
1202
|
+
...relationshipToLadderBindings(relationship)
|
|
777
1203
|
};
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
|
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
1230
|
case "outbound":
|
|
808
|
-
edgeTraversal = "g.V().has('repositoryId', rid).
|
|
1231
|
+
edgeTraversal = "g.V().has('repositoryId', rid).hasId(eid).has('entityType').outE()";
|
|
809
1232
|
break;
|
|
810
1233
|
case "inbound":
|
|
811
|
-
edgeTraversal = "g.V().has('repositoryId', rid).
|
|
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).
|
|
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
|
-
|
|
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
1252
|
if (direction === "outbound") {
|
|
830
|
-
unionQuery = `g.V().has('repositoryId', rid).
|
|
1253
|
+
unionQuery = `g.V().has('repositoryId', rid).hasId(eid).has('entityType').union(outE()${typeFilter}, inE()${typeFilter}.has('bidirectional', true))`;
|
|
831
1254
|
} else if (direction === "inbound") {
|
|
832
|
-
unionQuery = `g.V().has('repositoryId', rid).
|
|
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
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
|
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().
|
|
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
|
|
863
|
-
"g.E().has('repositoryId', rid).hasLabel(rtype).
|
|
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
|
|
867
|
-
|
|
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).
|
|
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).
|
|
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
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1774
|
+
edgeLabel: rel.relationshipType,
|
|
1775
|
+
...relationshipToLadderBindings(rel)
|
|
1509
1776
|
};
|
|
1510
|
-
|
|
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
|
-
|
|
1782
|
+
vid: entity.id,
|
|
1783
|
+
vertexLabel: entity.entityType,
|
|
1784
|
+
...entityToLadderBindings(entity)
|
|
1526
1785
|
};
|
|
1527
|
-
|
|
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
|
-
|
|
1544
|
-
|
|
1794
|
+
edgeLabel: rel.relationshipType,
|
|
1795
|
+
...relationshipToLadderBindings(rel)
|
|
1545
1796
|
};
|
|
1546
|
-
|
|
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().
|
|
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
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
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
|
-
|
|
1785
|
-
repositoryId
|
|
1786
|
-
|
|
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 = `
|
|
1864
|
-
const
|
|
1865
|
-
`g.V().has('repositoryId', rid).
|
|
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
|
|
1869
|
-
if (
|
|
1870
|
-
|
|
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.
|
|
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 = `
|
|
1955
|
-
const
|
|
1956
|
-
`g.E().has('repositoryId', rid).
|
|
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
|
|
1960
|
-
if (
|
|
1961
|
-
|
|
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
|
-
() =>
|
|
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: 'outbound'` 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 "outbound" 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 === "outbound" || options.direction === "both")) {
|
|
2394
|
+
matchesDirection = true;
|
|
2395
|
+
connectedId = rel.targetEntityId;
|
|
2396
|
+
} else if (isTarget && (options.direction === "inbound" || options.direction === "both")) {
|
|
2397
|
+
matchesDirection = true;
|
|
2398
|
+
connectedId = rel.sourceEntityId;
|
|
2399
|
+
} else if (rel.bidirectional) {
|
|
2400
|
+
if (isSource && options.direction === "inbound") {
|
|
2401
|
+
matchesDirection = true;
|
|
2402
|
+
connectedId = rel.targetEntityId;
|
|
2403
|
+
} else if (isTarget && options.direction === "outbound") {
|
|
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
|
-
() =>
|
|
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,205 @@ var CosmosDbProvider = class {
|
|
|
2085
2621
|
}
|
|
2086
2622
|
async traverse(repositoryId, spec) {
|
|
2087
2623
|
this.assertValidRepositoryId(repositoryId);
|
|
2088
|
-
return this.track("traverse", repositoryId, () => this.
|
|
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 = "outbound") => ({
|
|
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
|
+
if (spec.returnMode === "terminal") {
|
|
2657
|
+
entities = raw.terminalEntities.map(projectStoredEntity);
|
|
2658
|
+
relationships = void 0;
|
|
2659
|
+
} else if (spec.returnMode === "all") {
|
|
2660
|
+
entities = raw.allEntities.map(projectStoredEntity);
|
|
2661
|
+
relationships = raw.allRelationships.map((r) => projectStoredRelationship(r));
|
|
2662
|
+
} else {
|
|
2663
|
+
paths = raw.pathRows.map((row) => ({
|
|
2664
|
+
length: Math.max(row.entityIds.length - 1, 0),
|
|
2665
|
+
entities: row.entityIds.map((id) => {
|
|
2666
|
+
const stored = raw.entityMap.get(id);
|
|
2667
|
+
if (!stored) {
|
|
2668
|
+
throw new ProviderError(
|
|
2669
|
+
"Unpacking Gremlin path: entity referenced by path is missing from the result",
|
|
2670
|
+
"This indicates a Gremlin response shape mismatch \u2014 inspect compiledQuery."
|
|
2671
|
+
);
|
|
2672
|
+
}
|
|
2673
|
+
return projectStoredEntity(stored);
|
|
2674
|
+
}),
|
|
2675
|
+
relationships: row.relationshipIds.map((id, i) => {
|
|
2676
|
+
const stored = raw.relationshipMap.get(id);
|
|
2677
|
+
if (!stored) {
|
|
2678
|
+
throw new ProviderError(
|
|
2679
|
+
"Unpacking Gremlin path: relationship referenced by path is missing from the result",
|
|
2680
|
+
"This indicates a Gremlin response shape mismatch \u2014 inspect compiledQuery."
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
return projectStoredRelationship(stored, row.relationshipDirections[i]);
|
|
2684
|
+
})
|
|
2685
|
+
}));
|
|
2686
|
+
relationships = Array.from(raw.relationshipMap.values()).map(
|
|
2687
|
+
(rel) => projectStoredRelationship(rel, raw.pathRelFirstDirection.get(rel.id) ?? "outbound")
|
|
2688
|
+
);
|
|
2689
|
+
}
|
|
2690
|
+
const limit = spec.limit ?? 50;
|
|
2691
|
+
let total;
|
|
2692
|
+
if (spec.returnMode === "path") {
|
|
2693
|
+
total = paths?.length ?? 0;
|
|
2694
|
+
} else if (spec.returnMode === "all") {
|
|
2695
|
+
total = entities.length + (relationships?.length ?? 0);
|
|
2696
|
+
} else {
|
|
2697
|
+
total = entities.length;
|
|
2698
|
+
}
|
|
2699
|
+
const queryMetadata = {
|
|
2700
|
+
executionTimeMs: raw.executionTimeMs,
|
|
2701
|
+
resourceCost: raw.requestCharge != null ? { units: "RU", value: raw.requestCharge } : void 0,
|
|
2702
|
+
compiledQuery: raw.compiledQuery,
|
|
2703
|
+
compiledQueryLanguage: "gremlin",
|
|
2704
|
+
appliedLimits: {
|
|
2705
|
+
maxResults: limit,
|
|
2706
|
+
maxDepth: spec.steps?.length
|
|
2707
|
+
},
|
|
2708
|
+
truncated: total >= limit,
|
|
2709
|
+
truncationReason: total >= limit ? "result_limit" : void 0
|
|
2710
|
+
};
|
|
2711
|
+
return {
|
|
2712
|
+
entities,
|
|
2713
|
+
relationships,
|
|
2714
|
+
paths,
|
|
2715
|
+
total,
|
|
2716
|
+
returned: total,
|
|
2717
|
+
hasMore: total >= limit,
|
|
2718
|
+
queryMetadata
|
|
2719
|
+
};
|
|
2089
2720
|
}
|
|
2090
|
-
|
|
2721
|
+
/**
|
|
2722
|
+
* Lower-level traversal helper: compiles a spec, submits to Gremlin, and
|
|
2723
|
+
* parses the rows into raw {@link StoredEntity} / {@link StoredRelationship}
|
|
2724
|
+
* objects (no detail-level projection, no provenance stripping). Used by
|
|
2725
|
+
* `traverseInternal` and by the storage-layer rewrites of
|
|
2726
|
+
* `exploreNeighborhood` / `findPaths` that need the full stored shape to
|
|
2727
|
+
* rebuild `StorageNeighborhood` / `StoragePathResult`.
|
|
2728
|
+
*
|
|
2729
|
+
* Returns a discriminated bag — only the fields relevant to `spec.returnMode`
|
|
2730
|
+
* are populated:
|
|
2731
|
+
* - `'terminal'`: `terminalEntities` (in row order, no dedup beyond what the
|
|
2732
|
+
* server emitted).
|
|
2733
|
+
* - `'all'`: `allEntities` and `allRelationships`, already server-deduped.
|
|
2734
|
+
* - `'path'`: `pathRows` plus the `entityMap` / `relationshipMap` lookup
|
|
2735
|
+
* tables and `pathRelFirstDirection` for the first-seen direction per
|
|
2736
|
+
* deduped edge id.
|
|
2737
|
+
*/
|
|
2738
|
+
async executeTraversal(repositoryId, spec) {
|
|
2091
2739
|
const startTime = Date.now();
|
|
2092
|
-
const vocabulary = await this.
|
|
2740
|
+
const vocabulary = await this.getVocabularyCached(repositoryId);
|
|
2093
2741
|
const compiled = this.compiler.compile(spec, vocabulary);
|
|
2094
2742
|
const scopedQuery = compiled.query.replace(
|
|
2095
2743
|
"g.V()",
|
|
2096
2744
|
"g.V().has('repositoryId', pRid)"
|
|
2097
2745
|
);
|
|
2098
2746
|
const scopedParams = { ...compiled.params, pRid: repositoryId };
|
|
2747
|
+
let result;
|
|
2099
2748
|
try {
|
|
2100
|
-
|
|
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
|
-
};
|
|
2749
|
+
result = await this.conn.submit(scopedQuery, scopedParams);
|
|
2155
2750
|
} catch (err) {
|
|
2156
2751
|
throw new ProviderError(
|
|
2157
2752
|
`Gremlin traversal failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2158
2753
|
"Check the traversal spec and ensure the CosmosDB connection is healthy."
|
|
2159
2754
|
);
|
|
2160
2755
|
}
|
|
2756
|
+
const executionTimeMs = Date.now() - startTime;
|
|
2757
|
+
const raw = {
|
|
2758
|
+
terminalEntities: [],
|
|
2759
|
+
allEntities: [],
|
|
2760
|
+
allRelationships: [],
|
|
2761
|
+
pathRows: [],
|
|
2762
|
+
entityMap: /* @__PURE__ */ new Map(),
|
|
2763
|
+
relationshipMap: /* @__PURE__ */ new Map(),
|
|
2764
|
+
pathRelFirstDirection: /* @__PURE__ */ new Map(),
|
|
2765
|
+
executionTimeMs,
|
|
2766
|
+
requestCharge: result.requestCharge,
|
|
2767
|
+
compiledQuery: scopedQuery
|
|
2768
|
+
};
|
|
2769
|
+
if (spec.returnMode === "terminal") {
|
|
2770
|
+
for (const item of result.items) {
|
|
2771
|
+
const stored = entityFromGremlin(item);
|
|
2772
|
+
raw.terminalEntities.push(stored);
|
|
2773
|
+
}
|
|
2774
|
+
} else if (spec.returnMode === "all") {
|
|
2775
|
+
for (const item of result.items) {
|
|
2776
|
+
const props = item;
|
|
2777
|
+
const kind = props["__kind"];
|
|
2778
|
+
if (kind === "v") {
|
|
2779
|
+
const stored = entityFromGremlin(props);
|
|
2780
|
+
raw.allEntities.push(stored);
|
|
2781
|
+
raw.entityMap.set(stored.id, stored);
|
|
2782
|
+
} else if (kind === "e") {
|
|
2783
|
+
const stored = relationshipFromGremlin(props);
|
|
2784
|
+
raw.allRelationships.push(stored);
|
|
2785
|
+
raw.relationshipMap.set(stored.id, stored);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
} else {
|
|
2789
|
+
for (const item of result.items) {
|
|
2790
|
+
const pathData = item;
|
|
2791
|
+
if (!pathData.objects) continue;
|
|
2792
|
+
const pathEntityIds = [];
|
|
2793
|
+
const pathRelIds = [];
|
|
2794
|
+
const pathRelDirections = [];
|
|
2795
|
+
let lastVertexId = null;
|
|
2796
|
+
for (const obj of pathData.objects) {
|
|
2797
|
+
const props = obj;
|
|
2798
|
+
const kind = props["__kind"];
|
|
2799
|
+
if (kind === "v") {
|
|
2800
|
+
const stored = entityFromGremlin(props);
|
|
2801
|
+
raw.entityMap.set(stored.id, stored);
|
|
2802
|
+
pathEntityIds.push(stored.id);
|
|
2803
|
+
lastVertexId = stored.id;
|
|
2804
|
+
} else if (kind === "e") {
|
|
2805
|
+
const stored = relationshipFromGremlin(props);
|
|
2806
|
+
raw.relationshipMap.set(stored.id, stored);
|
|
2807
|
+
pathRelIds.push(stored.id);
|
|
2808
|
+
const direction = lastVertexId === stored.sourceEntityId ? "outbound" : "inbound";
|
|
2809
|
+
pathRelDirections.push(direction);
|
|
2810
|
+
if (!raw.pathRelFirstDirection.has(stored.id)) {
|
|
2811
|
+
raw.pathRelFirstDirection.set(stored.id, direction);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
raw.pathRows.push({
|
|
2816
|
+
entityIds: pathEntityIds,
|
|
2817
|
+
relationshipIds: pathRelIds,
|
|
2818
|
+
relationshipDirections: pathRelDirections
|
|
2819
|
+
});
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
return raw;
|
|
2161
2823
|
}
|
|
2162
2824
|
/**
|
|
2163
2825
|
* Execute a raw Gremlin query with caller-supplied bindings.
|
|
@@ -2192,67 +2854,16 @@ function unwrapGremlinValue(val) {
|
|
|
2192
2854
|
if (Array.isArray(val) && val.length > 0) return val[0];
|
|
2193
2855
|
return val;
|
|
2194
2856
|
}
|
|
2195
|
-
function
|
|
2196
|
-
const
|
|
2197
|
-
|
|
2198
|
-
|
|
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";
|
|
2857
|
+
function buildExploreSteps(depth, options) {
|
|
2858
|
+
const base = { direction: "both" };
|
|
2859
|
+
if (options.relationshipTypes && options.relationshipTypes.length > 0) {
|
|
2860
|
+
base.relationshipTypes = options.relationshipTypes;
|
|
2237
2861
|
}
|
|
2238
|
-
const
|
|
2239
|
-
|
|
2240
|
-
|
|
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}`);
|
|
2862
|
+
const steps = [];
|
|
2863
|
+
for (let i = 0; i < depth; i++) {
|
|
2864
|
+
steps.push({ ...base });
|
|
2255
2865
|
}
|
|
2866
|
+
return steps;
|
|
2256
2867
|
}
|
|
2257
2868
|
export {
|
|
2258
2869
|
CosmosDbConnection,
|