grafio-mongo 1.0.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.
Files changed (38) hide show
  1. package/.gitattributes +2 -0
  2. package/LICENSE +674 -0
  3. package/README.md +220 -0
  4. package/dist/MongoGraphFactory.d.ts +13 -0
  5. package/dist/MongoGraphFactory.d.ts.map +1 -0
  6. package/dist/MongoGraphFactory.js +33 -0
  7. package/dist/MongoGraphFactory.js.map +1 -0
  8. package/dist/MongoStorageProvider.d.ts +48 -0
  9. package/dist/MongoStorageProvider.d.ts.map +1 -0
  10. package/dist/MongoStorageProvider.js +417 -0
  11. package/dist/MongoStorageProvider.js.map +1 -0
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +7 -0
  15. package/dist/index.js.map +1 -0
  16. package/jest.config.js +27 -0
  17. package/package.json +41 -0
  18. package/src/MongoGraphFactory.ts +93 -0
  19. package/src/MongoStorageProvider.ts +719 -0
  20. package/src/index.ts +4 -0
  21. package/tests/EducationGraph.mongo.test.ts +33 -0
  22. package/tests/SocialGraph.mongo.test.ts +30 -0
  23. package/tests/graph/Graph.clear.mongo.test.ts +24 -0
  24. package/tests/graph/Graph.edge.mongo.test.ts +24 -0
  25. package/tests/graph/Graph.fromJSON.mongo.test.ts +24 -0
  26. package/tests/graph/Graph.index.mongo.test.ts +24 -0
  27. package/tests/graph/Graph.isDAG.mongo.test.ts +24 -0
  28. package/tests/graph/Graph.node.mongo.test.ts +24 -0
  29. package/tests/graph/Graph.properties.mongo.test.ts +24 -0
  30. package/tests/graph/Graph.serialization.mongo.test.ts +24 -0
  31. package/tests/graph/Graph.topologicalSort.mongo.test.ts +24 -0
  32. package/tests/graph/Graph.transaction.mongo.test.ts +24 -0
  33. package/tests/graph/Graph.traverse.mongo.test.ts +24 -0
  34. package/tests/graph/GraphToMermaid.mongo.test.ts +24 -0
  35. package/tests/storage/MongoGraphFactory.test.ts +102 -0
  36. package/tsconfig.json +21 -0
  37. package/tsconfig.perf.json +18 -0
  38. package/tsconfig.prod.json +11 -0
@@ -0,0 +1,719 @@
1
+ import { MongoClient } from 'mongodb';
2
+ import type {
3
+ ClientSession,
4
+ Collection,
5
+ Db,
6
+ Document,
7
+ Filter,
8
+ WithId,
9
+ } from 'mongodb';
10
+
11
+ import {
12
+ IStorageProvider,
13
+ NodeData,
14
+ ITransactionHandle,
15
+ EdgeData,
16
+ GraphData,
17
+ isPrimitive
18
+ } from 'grafio';
19
+
20
+ import {
21
+ NodeAlreadyExistsError,
22
+ EdgeAlreadyExistsError,
23
+ NodeNotFoundError,
24
+ EdgeNotFoundError,
25
+ InvalidPropertyError,
26
+ PropertyAlreadyExistsError,
27
+ PropertyNotFoundError
28
+ } from 'grafio/errors';
29
+
30
+ /**
31
+ * MongoDB document shape for nodes.
32
+ * `id` — the node's own element id
33
+ * `graphId` — the graph partition key this node belongs to
34
+ */
35
+ interface NodeDoc extends Document {
36
+ id: string;
37
+ graphId: string;
38
+ type: string;
39
+ properties: Record<string, unknown>;
40
+ }
41
+
42
+ /**
43
+ * MongoDB document shape for edges.
44
+ * `id` — the edge's own element id
45
+ * `graphId` — the graph partition key this edge belongs to
46
+ */
47
+ interface EdgeDoc extends Document {
48
+ id: string;
49
+ graphId: string;
50
+ sourceId: string;
51
+ targetId: string;
52
+ type: string;
53
+ properties: Record<string, unknown>;
54
+ }
55
+
56
+ /**
57
+ * Configuration options for MongoStorageProvider.
58
+ */
59
+ export interface MongoStorageProviderOptions {
60
+ /**
61
+ * Graph partition key. All nodes/edges stored by this provider belong to this graph.
62
+ * @default 'default'
63
+ */
64
+ graphId?: string;
65
+
66
+ /**
67
+ * Name of the MongoDB collection for nodes.
68
+ * @default 'sgdb_nodes'
69
+ */
70
+ nodesCollection?: string;
71
+
72
+ /**
73
+ * Name of the MongoDB collection for edges.
74
+ * @default 'sgdb_edges'
75
+ */
76
+ edgesCollection?: string;
77
+
78
+ /**
79
+ * Batch size for cursor-based iteration in getAllNodes() / getAllEdges().
80
+ * Controls how many documents are fetched per MongoDB round-trip.
81
+ * @default 1000
82
+ */
83
+ batchSize?: number;
84
+ }
85
+
86
+ /**
87
+ * MongoDB-backed storage provider for `grafio`.
88
+ *
89
+ * ## Setup
90
+ * ```typescript
91
+ * import { MongoClient } from 'mongodb';
92
+ * import { Graph } from 'grafio';
93
+ * import { MongoStorageProvider } from 'grafio/storage/MongoStorageProvider';
94
+ *
95
+ * const client = new MongoClient('mongodb://localhost:27017');
96
+ * await client.connect();
97
+ *
98
+ * const provider = new MongoStorageProvider(client.db('mydb'));
99
+ * await provider.ensureIndexes();
100
+ *
101
+ * const graph = new Graph(provider);
102
+ * ```
103
+ *
104
+ * ## Collections
105
+ * Two collections are used (default names):
106
+ * - `sgdb_nodes` — one document per node
107
+ * - `sgdb_edges` — one document per edge
108
+ *
109
+ * ## Indexes
110
+ * Call `ensureIndexes()` once on startup. It creates:
111
+ * - Unique compound index on `(graphId, id)` for both collections (fast id lookups)
112
+ * - Index on `(graphId, type)` for both collections (type filter queries)
113
+ * - Compound index on `(graphId, sourceId, type)` for outgoing adjacency queries
114
+ * - Compound index on `(graphId, targetId, type)` for incoming adjacency queries
115
+ *
116
+ * ## Thread safety
117
+ * MongoDB operations are inherently safe for concurrent use.
118
+ * However, a single `MongoStorageProvider` instance should not be shared
119
+ * across multiple `Graph` instances simultaneously if you rely on
120
+ * `importJSON()` atomicity — wrap it in a MongoDB session/transaction if needed.
121
+ */
122
+ export class MongoStorageProvider implements IStorageProvider {
123
+ private readonly _nodes: Collection<NodeDoc>;
124
+ private readonly _edges: Collection<EdgeDoc>;
125
+ private readonly _graphId: string;
126
+ private readonly _batchSize: number;
127
+ private readonly _client: MongoClient | null = null;
128
+
129
+ /**
130
+ * @param db - An already-connected Mongo `Db` instance.
131
+ * @param opts - Optional configuration including graphId (partition key).
132
+ */
133
+ constructor(db: Db, opts: MongoStorageProviderOptions = {}) {
134
+ const nodesColl = opts.nodesCollection ?? 'sgdb_nodes';
135
+ const edgesColl = opts.edgesCollection ?? 'sgdb_edges';
136
+
137
+ this._nodes = db.collection<NodeDoc>(nodesColl);
138
+ this._edges = db.collection<EdgeDoc>(edgesColl);
139
+ this._graphId = opts.graphId ?? 'default';
140
+ if (opts.batchSize !== undefined && opts.batchSize <= 0) {
141
+ throw new Error(`batchSize must be a positive integer, got: ${opts.batchSize}`);
142
+ }
143
+ this._batchSize = opts.batchSize ?? 1000;
144
+
145
+ // Try to get the client from the db
146
+ // MongoClient is needed to create sessions for transactions
147
+ if ('client' in db && db.client instanceof MongoClient) {
148
+ this._client = db.client;
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Index management
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Creates all required MongoDB indexes.
158
+ * Safe to call multiple times (uses `{ background: true }` equivalent and
159
+ * MongoDB's idempotent `createIndex` semantics).
160
+ *
161
+ * Call once on application startup before performing any graph operations.
162
+ */
163
+ async ensureIndexes(): Promise<void> {
164
+ // Node indexes — compound unique index on (graphId, id) ensures element id uniqueness per graph
165
+ await this._nodes.createIndex({ graphId: 1, id: 1 }, { unique: true, name: 'node_graph_id_unique' });
166
+ await this._nodes.createIndex({ graphId: 1, type: 1 }, { name: 'node_graph_type' });
167
+
168
+ // Edge indexes
169
+ await this._edges.createIndex({ graphId: 1, id: 1 }, { unique: true, name: 'edge_graph_id_unique' });
170
+ await this._edges.createIndex({ graphId: 1, type: 1 }, { name: 'edge_graph_type' });
171
+ await this._edges.createIndex({ graphId: 1, sourceId: 1, type: 1 }, { name: 'edge_graph_source_type' });
172
+ await this._edges.createIndex({ graphId: 1, targetId: 1, type: 1 }, { name: 'edge_graph_target_type' });
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Lifecycle
177
+ // ---------------------------------------------------------------------------
178
+
179
+ async clear(): Promise<void> {
180
+ await Promise.all([
181
+ this._nodes.deleteMany({ graphId: this._graphId }),
182
+ this._edges.deleteMany({ graphId: this._graphId }),
183
+ ]);
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Node mutations
188
+ // ---------------------------------------------------------------------------
189
+
190
+ async insertNode(node: NodeData, transaction?: ITransactionHandle): Promise<void> {
191
+ const session = transaction?.context as ClientSession | undefined;
192
+ try {
193
+ await this._nodes.insertOne({
194
+ id: node.id,
195
+ graphId: this._graphId,
196
+ type: node.type,
197
+ properties: node.properties,
198
+ } as NodeDoc, { session });
199
+ } catch (e: unknown) {
200
+ if (this._isDuplicateKeyError(e)) throw new NodeAlreadyExistsError(node.id);
201
+ throw e;
202
+ }
203
+ }
204
+
205
+ async deleteNode(id: string, transaction?: ITransactionHandle): Promise<void> {
206
+ const session = transaction?.context as ClientSession | undefined;
207
+ await this._nodes.deleteOne({ graphId: this._graphId, id }, { session });
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Node queries
212
+ // ---------------------------------------------------------------------------
213
+
214
+ async hasNode(id: string, transaction?: ITransactionHandle): Promise<boolean> {
215
+ const session = transaction?.context as ClientSession | undefined;
216
+ const doc = await this._nodes.findOne(
217
+ { graphId: this._graphId, id },
218
+ { projection: { _id: 1 }, session },
219
+ );
220
+ return doc !== null;
221
+ }
222
+
223
+ async getNode(id: string, transaction?: ITransactionHandle): Promise<NodeData | undefined> {
224
+ const session = transaction?.context as ClientSession | undefined;
225
+ const doc = await this._nodes.findOne({ graphId: this._graphId, id }, { session });
226
+ return doc ? this._docToNode(doc) : undefined;
227
+ }
228
+
229
+ async getAllNodes(limit?: number, transaction?: ITransactionHandle): Promise<NodeData[]> {
230
+ const session = transaction?.context as ClientSession | undefined;
231
+ const nodes: NodeData[] = [];
232
+ const cursor = this._nodes.find({ graphId: this._graphId }, { session }).batchSize(this._batchSize);
233
+ if (limit) cursor.limit(limit);
234
+
235
+ for await (const doc of cursor) {
236
+ nodes.push(this._docToNode(doc));
237
+ }
238
+ return nodes;
239
+ }
240
+
241
+ async getNodesByType(type: string, transaction?: ITransactionHandle): Promise<NodeData[]> {
242
+ const session = transaction?.context as ClientSession | undefined;
243
+ const docs = await this._nodes.find({ graphId: this._graphId, type }, { session }).toArray();
244
+ return docs.map(d => this._docToNode(d));
245
+ }
246
+
247
+ async getNodesByProperty(key: string, value: unknown, nodeType?: string, transaction?: ITransactionHandle): Promise<NodeData[]> {
248
+ const session = transaction?.context as ClientSession | undefined;
249
+ const filter: Filter<NodeDoc> = {
250
+ graphId: this._graphId,
251
+ [`properties.${key}`]: value as unknown as WithId<NodeDoc>[keyof WithId<NodeDoc>],
252
+ };
253
+ if (nodeType !== undefined) {
254
+ filter.type = nodeType;
255
+ }
256
+ const docs = await this._nodes.find(filter, { session }).toArray();
257
+ return docs.map(d => this._docToNode(d));
258
+ }
259
+
260
+ async getEdgesByProperty(key: string, value: unknown, edgeType?: string, transaction?: ITransactionHandle): Promise<EdgeData[]> {
261
+ const session = transaction?.context as ClientSession | undefined;
262
+ const filter: Filter<EdgeDoc> = {
263
+ graphId: this._graphId,
264
+ [`properties.${key}`]: value as unknown as WithId<EdgeDoc>[keyof WithId<EdgeDoc>],
265
+ };
266
+ if (edgeType !== undefined) {
267
+ filter.type = edgeType;
268
+ }
269
+ const docs = await this._edges.find(filter, { session }).toArray();
270
+ return docs.map(d => this._docToEdge(d));
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Edge mutations
275
+ // ---------------------------------------------------------------------------
276
+
277
+ async insertEdge(edge: EdgeData, transaction?: ITransactionHandle): Promise<void> {
278
+ const session = transaction?.context as ClientSession | undefined;
279
+ try {
280
+ await this._edges.insertOne({
281
+ id: edge.id,
282
+ graphId: this._graphId,
283
+ sourceId: edge.sourceId,
284
+ targetId: edge.targetId,
285
+ type: edge.type,
286
+ properties: edge.properties,
287
+ } as EdgeDoc, { session });
288
+ } catch (e: unknown) {
289
+ if (this._isDuplicateKeyError(e)) throw new EdgeAlreadyExistsError(edge.id);
290
+ throw e;
291
+ }
292
+ }
293
+
294
+ async deleteEdge(id: string, transaction?: ITransactionHandle): Promise<void> {
295
+ const session = transaction?.context as ClientSession | undefined;
296
+ await this._edges.deleteOne({ graphId: this._graphId, id }, { session });
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Edge queries
301
+ // ---------------------------------------------------------------------------
302
+
303
+ async hasEdge(id: string, transaction?: ITransactionHandle): Promise<boolean> {
304
+ const session = transaction?.context as ClientSession | undefined;
305
+ const doc = await this._edges.findOne(
306
+ { graphId: this._graphId, id },
307
+ { projection: { _id: 1 }, session },
308
+ );
309
+ return doc !== null;
310
+ }
311
+
312
+ async getEdge(id: string, transaction?: ITransactionHandle): Promise<EdgeData | undefined> {
313
+ const session = transaction?.context as ClientSession | undefined;
314
+ const doc = await this._edges.findOne({ graphId: this._graphId, id }, { session });
315
+ return doc ? this._docToEdge(doc) : undefined;
316
+ }
317
+
318
+ async getAllEdges(transaction?: ITransactionHandle): Promise<EdgeData[]> {
319
+ const session = transaction?.context as ClientSession | undefined;
320
+ const edges: EdgeData[] = [];
321
+ const cursor = this._edges.find({ graphId: this._graphId }, { session }).batchSize(this._batchSize);
322
+
323
+ for await (const doc of cursor) {
324
+ edges.push(this._docToEdge(doc));
325
+ }
326
+ return edges;
327
+ }
328
+
329
+ async getEdgesByType(type: string, transaction?: ITransactionHandle): Promise<EdgeData[]> {
330
+ const session = transaction?.context as ClientSession | undefined;
331
+ const docs = await this._edges.find({ graphId: this._graphId, type }, { session }).toArray();
332
+ return docs.map(d => this._docToEdge(d));
333
+ }
334
+
335
+ async getEdgesBySource(nodeId: string, type?: string, transaction?: ITransactionHandle): Promise<EdgeData[]> {
336
+ const session = transaction?.context as ClientSession | undefined;
337
+ const filter: Filter<EdgeDoc> = { graphId: this._graphId, sourceId: nodeId };
338
+ if (type) filter.type = type;
339
+ const docs = await this._edges.find(filter, { session }).toArray();
340
+ return docs.map(d => this._docToEdge(d));
341
+ }
342
+
343
+ async getEdgesByTarget(nodeId: string, type?: string, transaction?: ITransactionHandle): Promise<EdgeData[]> {
344
+ const session = transaction?.context as ClientSession | undefined;
345
+ const filter: Filter<EdgeDoc> = { graphId: this._graphId, targetId: nodeId };
346
+ if (type) filter.type = type;
347
+ const docs = await this._edges.find(filter, { session }).toArray();
348
+ return docs.map(d => this._docToEdge(d));
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Data portability
353
+ // ---------------------------------------------------------------------------
354
+
355
+ async exportJSON(): Promise<GraphData> {
356
+ const nodes: NodeData[] = [];
357
+ const edges: EdgeData[] = [];
358
+
359
+ const nodesCursor = this._nodes.find({ graphId: this._graphId }).batchSize(this._batchSize);
360
+ for await (const doc of nodesCursor) {
361
+ nodes.push(this._docToNode(doc));
362
+ }
363
+
364
+ const edgesCursor = this._edges.find({ graphId: this._graphId }).batchSize(this._batchSize);
365
+ for await (const doc of edgesCursor) {
366
+ edges.push(this._docToEdge(doc));
367
+ }
368
+
369
+ return {
370
+ graphId: this._graphId,
371
+ nodes,
372
+ edges,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Imports graph data using MongoDB `insertMany` for efficiency.
378
+ *
379
+ * Validates referential integrity before writing:
380
+ * - Duplicate node ids → NodeAlreadyExistsError
381
+ * - Duplicate edge ids → EdgeAlreadyExistsError
382
+ * - Edge referencing missing node → NodeNotFoundError
383
+ */
384
+ async importJSON(data: GraphData): Promise<void> {
385
+ // ---- Validate duplicate ids in the payload itself ----
386
+ const nodeIdSet = new Set<string>();
387
+ for (const n of data.nodes) {
388
+ if (nodeIdSet.has(n.id)) throw new NodeAlreadyExistsError(n.id);
389
+ nodeIdSet.add(n.id);
390
+ }
391
+ const edgeIdSet = new Set<string>();
392
+ for (const e of data.edges) {
393
+ if (edgeIdSet.has(e.id)) throw new EdgeAlreadyExistsError(e.id);
394
+ edgeIdSet.add(e.id);
395
+ }
396
+
397
+ // ---- Check for existing ids in the database under this graphId (parallel) ----
398
+ const nodeIds = data.nodes.map(n => n.id);
399
+ const edgeIds = data.edges.map(e => e.id);
400
+
401
+ const [conflictNode, conflictEdge] = await Promise.all([
402
+ data.nodes.length > 0
403
+ ? this._nodes.findOne({ graphId: this._graphId, id: { $in: nodeIds } })
404
+ : Promise.resolve(null),
405
+ data.edges.length > 0
406
+ ? this._edges.findOne({ graphId: this._graphId, id: { $in: edgeIds } })
407
+ : Promise.resolve(null),
408
+ ]);
409
+ if (conflictNode) throw new NodeAlreadyExistsError(conflictNode.id);
410
+ if (conflictEdge) throw new EdgeAlreadyExistsError(conflictEdge.id);
411
+
412
+ // ---- Validate edge source/target references ----
413
+ // Only load the node ids actually referenced by incoming edges (avoids loading all nodes)
414
+ const referencedIds = [...nodeIdSet]; // ids from incoming nodes already added above
415
+ for (const e of data.edges) {
416
+ referencedIds.push(e.sourceId, e.targetId);
417
+ }
418
+ const uniqueReferencedIds = [...new Set(referencedIds)];
419
+ const existingIdSet = new Set(
420
+ await this._nodes
421
+ .find({ graphId: this._graphId, id: { $in: uniqueReferencedIds } }, { projection: { id: 1 } })
422
+ .toArray()
423
+ .then(docs => docs.map(d => d.id))
424
+ );
425
+
426
+ for (const id of nodeIdSet) existingIdSet.add(id);
427
+
428
+ for (const e of data.edges) {
429
+ if (!existingIdSet.has(e.sourceId)) throw new NodeNotFoundError(e.sourceId);
430
+ if (!existingIdSet.has(e.targetId)) throw new NodeNotFoundError(e.targetId);
431
+ }
432
+
433
+ // ---- Bulk insert (batched) ----
434
+ if (data.nodes.length > 0) {
435
+ const nodeDocs = data.nodes.map(n => ({
436
+ id: n.id,
437
+ graphId: this._graphId,
438
+ type: n.type,
439
+ properties: n.properties,
440
+ } as NodeDoc));
441
+ for (let i = 0; i < nodeDocs.length; i += this._batchSize) {
442
+ await this._nodes.insertMany(nodeDocs.slice(i, i + this._batchSize));
443
+ }
444
+ }
445
+ if (data.edges.length > 0) {
446
+ const edgeDocs = data.edges.map(e => ({
447
+ id: e.id,
448
+ graphId: this._graphId,
449
+ sourceId: e.sourceId,
450
+ targetId: e.targetId,
451
+ type: e.type,
452
+ properties: e.properties,
453
+ } as EdgeDoc));
454
+ for (let i = 0; i < edgeDocs.length; i += this._batchSize) {
455
+ await this._edges.insertMany(edgeDocs.slice(i, i + this._batchSize));
456
+ }
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Creates an index on a node or edge property.
462
+ *
463
+ * @param target - Either 'node' or 'edge'
464
+ * @param propertyKey - The property name to index
465
+ * @param type - Optional type filter. If provided (not '*' or undefined), creates a compound index on (type, propertyKey)
466
+ */
467
+ async createIndex(target: 'node' | 'edge', propertyKey: string, type?: string): Promise<void> {
468
+ if (target === 'node') {
469
+ // Always lead with graphId to support partitioned queries efficiently
470
+ const indexFields: Record<string, 1> = { graphId: 1, [`properties.${propertyKey}`]: 1 };
471
+
472
+ // If type is specified, create compound index on (graphId, type, propertyKey)
473
+ if (type && type !== '*') {
474
+ indexFields['type'] = 1;
475
+ await this._nodes.createIndex(indexFields, {
476
+ name: `node_graphId_type_${propertyKey}`,
477
+ background: true
478
+ });
479
+ } else {
480
+ await this._nodes.createIndex(indexFields, {
481
+ name: `node_graphId_${propertyKey}`,
482
+ background: true
483
+ });
484
+ }
485
+ } else {
486
+ // Always lead with graphId to support partitioned queries efficiently
487
+ const indexFields: Record<string, 1> = { graphId: 1, [`properties.${propertyKey}`]: 1 };
488
+
489
+ // If type is specified, create compound index on (graphId, type, propertyKey)
490
+ if (type && type !== '*') {
491
+ indexFields['type'] = 1;
492
+ await this._edges.createIndex(indexFields, {
493
+ name: `edge_graphId_type_${propertyKey}`,
494
+ background: true
495
+ });
496
+ } else {
497
+ await this._edges.createIndex(indexFields, {
498
+ name: `edge_graphId_${propertyKey}`,
499
+ background: true
500
+ });
501
+ }
502
+ }
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Property mutations
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /**
510
+ * Adds a property to a node or edge. Fails if the property key already exists.
511
+ * @throws NodeNotFoundError/EdgeNotFoundError if the target doesn't exist
512
+ * @throws PropertyAlreadyExistsError if the property key already exists
513
+ * @throws InvalidPropertyError if the value is not a primitive
514
+ */
515
+ async addProperty(target: 'node' | 'edge', id: string, key: string, value: unknown, transaction?: ITransactionHandle): Promise<void> {
516
+ if (!isPrimitive(value)) {
517
+ throw new InvalidPropertyError(key, value);
518
+ }
519
+
520
+ const session = transaction?.context as ClientSession | undefined;
521
+ const collection = target === 'node' ? this._nodes : this._edges;
522
+
523
+ // Atomic: only succeeds if the property does NOT already exist
524
+ const result = await collection.updateOne(
525
+ { graphId: this._graphId, id, [`properties.${key}`]: { $exists: false } },
526
+ { $set: { [`properties.${key}`]: value } },
527
+ { session }
528
+ );
529
+
530
+ if (result.matchedCount === 0) {
531
+ // Check if record exists to differentiate between "record missing" vs "property exists"
532
+ const record = target === 'node' ? await this.getNode(id, transaction) : await this.getEdge(id, transaction);
533
+ if (!record) {
534
+ throw target === 'node' ? new NodeNotFoundError(id) : new EdgeNotFoundError(id);
535
+ }
536
+ throw new PropertyAlreadyExistsError(target, id, key);
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Updates an existing property on a node or edge. Fails if the property doesn't exist.
542
+ * @throws NodeNotFoundError/EdgeNotFoundError if the target doesn't exist
543
+ * @throws PropertyNotFoundError if the property key doesn't exist
544
+ * @throws InvalidPropertyError if the value is not a primitive
545
+ */
546
+ async updateProperty(target: 'node' | 'edge', id: string, key: string, value: unknown, transaction?: ITransactionHandle): Promise<void> {
547
+ if (!isPrimitive(value)) {
548
+ throw new InvalidPropertyError(key, value);
549
+ }
550
+
551
+ const session = transaction?.context as ClientSession | undefined;
552
+ const collection = target === 'node' ? this._nodes : this._edges;
553
+
554
+ // Atomic update: only succeeds if the property already exists
555
+ const result = await collection.updateOne(
556
+ { graphId: this._graphId, id, [`properties.${key}`]: { $exists: true } },
557
+ { $set: { [`properties.${key}`]: value } },
558
+ { session }
559
+ );
560
+
561
+ if (result.matchedCount === 0) {
562
+ // Determine whether it was the record or the property that didn't exist
563
+ const record = target === 'node' ? await this.getNode(id, transaction) : await this.getEdge(id, transaction);
564
+ if (!record) {
565
+ throw target === 'node' ? new NodeNotFoundError(id) : new EdgeNotFoundError(id);
566
+ }
567
+ throw new PropertyNotFoundError(target, id, key);
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Deletes a property from a node or edge.
573
+ * @throws NodeNotFoundError/EdgeNotFoundError if the target doesn't exist
574
+ */
575
+ async deleteProperty(target: 'node' | 'edge', id: string, key: string, transaction?: ITransactionHandle): Promise<void> {
576
+ const session = transaction?.context as ClientSession | undefined;
577
+ const collection = target === 'node' ? this._nodes : this._edges;
578
+ const record = target === 'node' ? await this.getNode(id, transaction) : await this.getEdge(id, transaction);
579
+
580
+ if (!record) {
581
+ if (target === 'node') {
582
+ throw new NodeNotFoundError(id);
583
+ } else {
584
+ throw new EdgeNotFoundError(id);
585
+ }
586
+ }
587
+
588
+ await collection.updateOne(
589
+ { graphId: this._graphId, id },
590
+ { $unset: { [`properties.${key}`]: '' } },
591
+ { session }
592
+ );
593
+ }
594
+
595
+ /**
596
+ * Clears all properties from a node or edge.
597
+ * @throws NodeNotFoundError/EdgeNotFoundError if the target doesn't exist
598
+ */
599
+ async clearProperties(target: 'node' | 'edge', id: string, transaction?: ITransactionHandle): Promise<void> {
600
+ const session = transaction?.context as ClientSession | undefined;
601
+ const collection = target === 'node' ? this._nodes : this._edges;
602
+ const record = target === 'node' ? await this.getNode(id, transaction) : await this.getEdge(id, transaction);
603
+
604
+ if (!record) {
605
+ if (target === 'node') {
606
+ throw new NodeNotFoundError(id);
607
+ } else {
608
+ throw new EdgeNotFoundError(id);
609
+ }
610
+ }
611
+
612
+ const updateObj: Record<string, unknown> = {};
613
+ for (const key of Object.keys(record.properties)) {
614
+ updateObj[`properties.${key}`] = '';
615
+ }
616
+
617
+ if (Object.keys(updateObj).length > 0) {
618
+ await collection.updateOne(
619
+ { graphId: this._graphId, id },
620
+ { $unset: updateObj },
621
+ { session }
622
+ );
623
+ }
624
+ }
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // Private helpers
628
+ // ---------------------------------------------------------------------------
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Transaction support
632
+ // ---------------------------------------------------------------------------
633
+
634
+ /**
635
+ * Returns true if the MongoDB client supports transactions.
636
+ * Requires MongoDB 4.0+ and a replica set or sharded cluster.
637
+ */
638
+ supportsTransactions(): boolean {
639
+ return this._client !== null;
640
+ }
641
+
642
+ /**
643
+ * Starts a new MongoDB transaction using a ClientSession.
644
+ * Note: MongoDB transactions require a replica set. This will start a transaction
645
+ * on the session, but actual atomicity requires passing the session to all operations.
646
+ */
647
+ async beginTransaction(): Promise<ITransactionHandle> {
648
+ if (!this._client) {
649
+ throw new Error('MongoDB client not available for transactions. Ensure the Db instance has access to a MongoClient.');
650
+ }
651
+
652
+ const session = this._client.startSession();
653
+ session.startTransaction(); // Start the transaction on the session
654
+
655
+ return {
656
+ id: `txn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
657
+ context: session,
658
+ };
659
+ }
660
+
661
+ /**
662
+ * Commits the MongoDB transaction.
663
+ * @throws Error if no transaction is active
664
+ */
665
+ async commitTransaction(handle: ITransactionHandle): Promise<void> {
666
+ const session = handle.context as ClientSession;
667
+ if (!session.inTransaction()) {
668
+ throw new Error('No active transaction to commit');
669
+ }
670
+ try {
671
+ await session.commitTransaction();
672
+ } finally {
673
+ session.endSession();
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Aborts the MongoDB transaction.
679
+ * This is safe to call even if there's no active transaction (will be a no-op after session ends).
680
+ */
681
+ async rollbackTransaction(handle: ITransactionHandle): Promise<void> {
682
+ const session = handle.context as ClientSession;
683
+ if (session.inTransaction()) {
684
+ try {
685
+ await session.abortTransaction();
686
+ } finally {
687
+ session.endSession();
688
+ }
689
+ } else {
690
+ session.endSession();
691
+ }
692
+ }
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // Private helpers
696
+ // ---------------------------------------------------------------------------
697
+
698
+ private _docToNode(doc: WithId<NodeDoc>): NodeData {
699
+ return {
700
+ id: doc.id,
701
+ type: doc.type,
702
+ properties: doc.properties,
703
+ };
704
+ }
705
+
706
+ private _docToEdge(doc: WithId<EdgeDoc>): EdgeData {
707
+ return {
708
+ id: doc.id,
709
+ sourceId: doc.sourceId,
710
+ targetId: doc.targetId,
711
+ type: doc.type,
712
+ properties: doc.properties,
713
+ };
714
+ }
715
+
716
+ private _isDuplicateKeyError(e: unknown): boolean {
717
+ return e instanceof Error && 'code' in e && (e as { code: number }).code === 11000;
718
+ }
719
+ }