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