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.
- package/.gitattributes +2 -0
- package/LICENSE +674 -0
- package/README.md +220 -0
- package/dist/MongoGraphFactory.d.ts +13 -0
- package/dist/MongoGraphFactory.d.ts.map +1 -0
- package/dist/MongoGraphFactory.js +33 -0
- package/dist/MongoGraphFactory.js.map +1 -0
- package/dist/MongoStorageProvider.d.ts +48 -0
- package/dist/MongoStorageProvider.d.ts.map +1 -0
- package/dist/MongoStorageProvider.js +417 -0
- package/dist/MongoStorageProvider.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/jest.config.js +27 -0
- package/package.json +41 -0
- package/src/MongoGraphFactory.ts +93 -0
- package/src/MongoStorageProvider.ts +719 -0
- package/src/index.ts +4 -0
- package/tests/EducationGraph.mongo.test.ts +33 -0
- package/tests/SocialGraph.mongo.test.ts +30 -0
- package/tests/graph/Graph.clear.mongo.test.ts +24 -0
- package/tests/graph/Graph.edge.mongo.test.ts +24 -0
- package/tests/graph/Graph.fromJSON.mongo.test.ts +24 -0
- package/tests/graph/Graph.index.mongo.test.ts +24 -0
- package/tests/graph/Graph.isDAG.mongo.test.ts +24 -0
- package/tests/graph/Graph.node.mongo.test.ts +24 -0
- package/tests/graph/Graph.properties.mongo.test.ts +24 -0
- package/tests/graph/Graph.serialization.mongo.test.ts +24 -0
- package/tests/graph/Graph.topologicalSort.mongo.test.ts +24 -0
- package/tests/graph/Graph.transaction.mongo.test.ts +24 -0
- package/tests/graph/Graph.traverse.mongo.test.ts +24 -0
- package/tests/graph/GraphToMermaid.mongo.test.ts +24 -0
- package/tests/storage/MongoGraphFactory.test.ts +102 -0
- package/tsconfig.json +21 -0
- package/tsconfig.perf.json +18 -0
- 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
|
+
}
|