@grafema/rfdb-client 0.2.11 → 0.3.0-beta

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.
@@ -0,0 +1,737 @@
1
+ /**
2
+ * BaseRFDBClient - Abstract base class for RFDB transport clients
3
+ *
4
+ * Contains all graph operation methods that delegate to abstract _send().
5
+ * Subclasses provide the transport layer (Unix socket, WebSocket, etc.)
6
+ */
7
+
8
+ import { EventEmitter } from 'events';
9
+
10
+ import type {
11
+ RFDBCommand,
12
+ WireNode,
13
+ WireEdge,
14
+ RFDBResponse,
15
+ IRFDBClient,
16
+ AttrQuery,
17
+ FieldDeclaration,
18
+ DatalogResult,
19
+ DatalogExplainResult,
20
+ CypherResult,
21
+ NodeType,
22
+ EdgeType,
23
+ HelloResponse,
24
+ CreateDatabaseResponse,
25
+ OpenDatabaseResponse,
26
+ ListDatabasesResponse,
27
+ CurrentDatabaseResponse,
28
+ ServerStats,
29
+ SnapshotRef,
30
+ SnapshotDiff,
31
+ SnapshotInfo,
32
+ DiffSnapshotsResponse,
33
+ FindSnapshotResponse,
34
+ ListSnapshotsResponse,
35
+ CommitDelta,
36
+ CommitBatchResponse,
37
+ } from '@grafema/types';
38
+
39
+ /**
40
+ * Default timeout for operations (60 seconds).
41
+ * Flush/compact may take time for large graphs, but should not hang indefinitely.
42
+ */
43
+ const DEFAULT_TIMEOUT_MS = 60_000;
44
+
45
+ export abstract class BaseRFDBClient extends EventEmitter implements IRFDBClient {
46
+ abstract readonly socketPath: string;
47
+ abstract readonly clientName: string;
48
+ abstract connected: boolean;
49
+
50
+ /**
51
+ * Whether the connected server supports streaming responses.
52
+ * Defaults to false. Unix socket subclass may set this after hello().
53
+ */
54
+ get supportsStreaming(): boolean {
55
+ return false;
56
+ }
57
+
58
+ // Batch state
59
+ protected _batching: boolean = false;
60
+ protected _batchNodes: WireNode[] = [];
61
+ protected _batchEdges: WireEdge[] = [];
62
+ protected _batchFiles: Set<string> = new Set();
63
+
64
+ abstract connect(): Promise<void>;
65
+ abstract close(): Promise<void>;
66
+
67
+ /**
68
+ * Send a request to RFDB server and wait for response.
69
+ * Subclasses implement transport-specific send logic.
70
+ */
71
+ protected abstract _send(
72
+ cmd: RFDBCommand,
73
+ payload?: Record<string, unknown>,
74
+ timeoutMs?: number,
75
+ ): Promise<RFDBResponse>;
76
+
77
+ // ===========================================================================
78
+ // Write Operations
79
+ // ===========================================================================
80
+
81
+ /**
82
+ * Add nodes to the graph.
83
+ * Extra properties beyond id/type/name/file/exported/metadata are merged into metadata.
84
+ */
85
+ async addNodes(nodes: Array<Partial<WireNode> & { id: string; type?: string; node_type?: string; nodeType?: string }>): Promise<RFDBResponse> {
86
+ const wireNodes: WireNode[] = nodes.map(n => {
87
+ const nodeRecord = n as Record<string, unknown>;
88
+ const { id, type, node_type, nodeType, name, file, exported, metadata, semanticId, semantic_id, ...rest } = nodeRecord;
89
+ const existingMeta = typeof metadata === 'string' ? JSON.parse(metadata as string) : (metadata || {});
90
+ const combinedMeta = { ...existingMeta, ...rest };
91
+
92
+ const wire: WireNode = {
93
+ id: String(id),
94
+ nodeType: (node_type || nodeType || type || 'UNKNOWN') as NodeType,
95
+ name: (name as string) || '',
96
+ file: (file as string) || '',
97
+ exported: (exported as boolean) || false,
98
+ metadata: JSON.stringify(combinedMeta),
99
+ };
100
+
101
+ const sid = semanticId || semantic_id;
102
+ if (sid) {
103
+ (wire as WireNode & { semanticId: string }).semanticId = String(sid);
104
+ }
105
+
106
+ return wire;
107
+ });
108
+
109
+ if (this._batching) {
110
+ this._batchNodes.push(...wireNodes);
111
+ for (const node of wireNodes) {
112
+ if (node.file) this._batchFiles.add(node.file);
113
+ }
114
+ return { ok: true } as RFDBResponse;
115
+ }
116
+
117
+ return this._send('addNodes', { nodes: wireNodes });
118
+ }
119
+
120
+ /**
121
+ * Add edges to the graph.
122
+ * Extra properties beyond src/dst/type are merged into metadata.
123
+ */
124
+ async addEdges(
125
+ edges: WireEdge[],
126
+ skipValidation: boolean = false,
127
+ ): Promise<RFDBResponse> {
128
+ const wireEdges: WireEdge[] = edges.map(e => {
129
+ const edge = e as unknown as Record<string, unknown>;
130
+ const { src, dst, type, edge_type, edgeType, metadata, ...rest } = edge;
131
+ const existingMeta = typeof metadata === 'string' ? JSON.parse(metadata as string) : (metadata || {});
132
+ const combinedMeta = { ...existingMeta, ...rest };
133
+
134
+ return {
135
+ src: String(src),
136
+ dst: String(dst),
137
+ edgeType: (edge_type || edgeType || type || e.edgeType || 'UNKNOWN') as EdgeType,
138
+ metadata: JSON.stringify(combinedMeta),
139
+ };
140
+ });
141
+
142
+ if (this._batching) {
143
+ this._batchEdges.push(...wireEdges);
144
+ return { ok: true } as RFDBResponse;
145
+ }
146
+
147
+ return this._send('addEdges', { edges: wireEdges, skipValidation });
148
+ }
149
+
150
+ async deleteNode(id: string): Promise<RFDBResponse> {
151
+ return this._send('deleteNode', { id: String(id) });
152
+ }
153
+
154
+ async deleteEdge(src: string, dst: string, edgeType: EdgeType): Promise<RFDBResponse> {
155
+ return this._send('deleteEdge', {
156
+ src: String(src),
157
+ dst: String(dst),
158
+ edgeType,
159
+ });
160
+ }
161
+
162
+ // ===========================================================================
163
+ // Read Operations
164
+ // ===========================================================================
165
+
166
+ async getNode(id: string): Promise<WireNode | null> {
167
+ const response = await this._send('getNode', { id: String(id) });
168
+ return (response as { node?: WireNode }).node || null;
169
+ }
170
+
171
+ async nodeExists(id: string): Promise<boolean> {
172
+ const response = await this._send('nodeExists', { id: String(id) });
173
+ return (response as { value: boolean }).value;
174
+ }
175
+
176
+ async findByType(nodeType: NodeType): Promise<string[]> {
177
+ const response = await this._send('findByType', { nodeType });
178
+ return (response as { ids?: string[] }).ids || [];
179
+ }
180
+
181
+ async findByAttr(query: Record<string, unknown>): Promise<string[]> {
182
+ const response = await this._send('findByAttr', { query });
183
+ return (response as { ids?: string[] }).ids || [];
184
+ }
185
+
186
+ // ===========================================================================
187
+ // Graph Traversal
188
+ // ===========================================================================
189
+
190
+ async neighbors(id: string, edgeTypes: EdgeType[] = []): Promise<string[]> {
191
+ const response = await this._send('neighbors', {
192
+ id: String(id),
193
+ edgeTypes,
194
+ });
195
+ return (response as { ids?: string[] }).ids || [];
196
+ }
197
+
198
+ async bfs(startIds: string[], maxDepth: number, edgeTypes: EdgeType[] = []): Promise<string[]> {
199
+ const response = await this._send('bfs', {
200
+ startIds: startIds.map(String),
201
+ maxDepth,
202
+ edgeTypes,
203
+ });
204
+ return (response as { ids?: string[] }).ids || [];
205
+ }
206
+
207
+ async dfs(startIds: string[], maxDepth: number, edgeTypes: EdgeType[] = []): Promise<string[]> {
208
+ const response = await this._send('dfs', {
209
+ startIds: startIds.map(String),
210
+ maxDepth,
211
+ edgeTypes,
212
+ });
213
+ return (response as { ids?: string[] }).ids || [];
214
+ }
215
+
216
+ async reachability(
217
+ startIds: string[],
218
+ maxDepth: number,
219
+ edgeTypes: EdgeType[] = [],
220
+ backward: boolean = false,
221
+ ): Promise<string[]> {
222
+ const response = await this._send('reachability', {
223
+ startIds: startIds.map(String),
224
+ maxDepth,
225
+ edgeTypes,
226
+ backward,
227
+ });
228
+ return (response as { ids?: string[] }).ids || [];
229
+ }
230
+
231
+ /**
232
+ * Get outgoing edges from a node.
233
+ * Parses metadata JSON and spreads it onto the edge object for convenience.
234
+ */
235
+ async getOutgoingEdges(id: string, edgeTypes: EdgeType[] | null = null): Promise<(WireEdge & Record<string, unknown>)[]> {
236
+ const response = await this._send('getOutgoingEdges', {
237
+ id: String(id),
238
+ edgeTypes,
239
+ });
240
+ const edges = (response as { edges?: WireEdge[] }).edges || [];
241
+ return edges.map(e => {
242
+ let meta = {};
243
+ try {
244
+ meta = e.metadata ? JSON.parse(e.metadata) : {};
245
+ } catch {
246
+ // Keep empty metadata on parse error
247
+ }
248
+ return { ...e, type: e.edgeType, ...meta };
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Get incoming edges to a node.
254
+ * Parses metadata JSON and spreads it onto the edge object for convenience.
255
+ */
256
+ async getIncomingEdges(id: string, edgeTypes: EdgeType[] | null = null): Promise<(WireEdge & Record<string, unknown>)[]> {
257
+ const response = await this._send('getIncomingEdges', {
258
+ id: String(id),
259
+ edgeTypes,
260
+ });
261
+ const edges = (response as { edges?: WireEdge[] }).edges || [];
262
+ return edges.map(e => {
263
+ let meta = {};
264
+ try {
265
+ meta = e.metadata ? JSON.parse(e.metadata) : {};
266
+ } catch {
267
+ // Keep empty metadata on parse error
268
+ }
269
+ return { ...e, type: e.edgeType, ...meta };
270
+ });
271
+ }
272
+
273
+ // ===========================================================================
274
+ // Stats
275
+ // ===========================================================================
276
+
277
+ async nodeCount(): Promise<number> {
278
+ const response = await this._send('nodeCount');
279
+ return (response as { count: number }).count;
280
+ }
281
+
282
+ async edgeCount(): Promise<number> {
283
+ const response = await this._send('edgeCount');
284
+ return (response as { count: number }).count;
285
+ }
286
+
287
+ async countNodesByType(types: NodeType[] | null = null): Promise<Record<string, number>> {
288
+ const response = await this._send('countNodesByType', { types });
289
+ return (response as { counts?: Record<string, number> }).counts || {};
290
+ }
291
+
292
+ async countEdgesByType(edgeTypes: EdgeType[] | null = null): Promise<Record<string, number>> {
293
+ const response = await this._send('countEdgesByType', { edgeTypes });
294
+ return (response as { counts?: Record<string, number> }).counts || {};
295
+ }
296
+
297
+ async getStats(): Promise<ServerStats> {
298
+ const response = await this._send('getStats');
299
+ return response as unknown as ServerStats;
300
+ }
301
+
302
+ // ===========================================================================
303
+ // Control
304
+ // ===========================================================================
305
+
306
+ async flush(): Promise<RFDBResponse> {
307
+ return this._send('flush');
308
+ }
309
+
310
+ async compact(): Promise<RFDBResponse> {
311
+ return this._send('compact');
312
+ }
313
+
314
+ async clear(): Promise<RFDBResponse> {
315
+ return this._send('clear');
316
+ }
317
+
318
+ // ===========================================================================
319
+ // Bulk Read Operations
320
+ // ===========================================================================
321
+
322
+ /**
323
+ * Build a server query object from an AttrQuery.
324
+ */
325
+ protected _buildServerQuery(query: AttrQuery): Record<string, unknown> {
326
+ const serverQuery: Record<string, unknown> = {};
327
+ if (query.nodeType) serverQuery.nodeType = query.nodeType;
328
+ if (query.type) serverQuery.nodeType = query.type;
329
+ if (query.name) serverQuery.name = query.name;
330
+ if (query.file) serverQuery.file = query.file;
331
+ if (query.exported !== undefined) serverQuery.exported = query.exported;
332
+ if (query.substringMatch) serverQuery.substringMatch = query.substringMatch;
333
+ return serverQuery;
334
+ }
335
+
336
+ /**
337
+ * Query nodes (async generator).
338
+ * Default implementation fetches all matching nodes in a single request.
339
+ * Subclasses with streaming support can override.
340
+ */
341
+ async *queryNodes(query: AttrQuery): AsyncGenerator<WireNode, void, unknown> {
342
+ const serverQuery = this._buildServerQuery(query);
343
+ const response = await this._send('queryNodes', { query: serverQuery });
344
+ const nodes = (response as { nodes?: WireNode[] }).nodes || [];
345
+ for (const node of nodes) {
346
+ yield node;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Stream nodes matching query.
352
+ * Default implementation delegates to queryNodes().
353
+ * Subclasses with streaming support can override.
354
+ */
355
+ async *queryNodesStream(query: AttrQuery): AsyncGenerator<WireNode, void, unknown> {
356
+ yield* this.queryNodes(query);
357
+ }
358
+
359
+ /**
360
+ * Get all nodes matching query.
361
+ */
362
+ async getAllNodes(query: AttrQuery = {}): Promise<WireNode[]> {
363
+ const nodes: WireNode[] = [];
364
+ for await (const node of this.queryNodes(query)) {
365
+ nodes.push(node);
366
+ }
367
+ return nodes;
368
+ }
369
+
370
+ /**
371
+ * Get all edges.
372
+ * Parses metadata JSON and spreads it onto the edge object for convenience.
373
+ */
374
+ async getAllEdges(): Promise<(WireEdge & Record<string, unknown>)[]> {
375
+ const response = await this._send('getAllEdges');
376
+ const edges = (response as { edges?: WireEdge[] }).edges || [];
377
+ return edges.map(e => {
378
+ let meta = {};
379
+ try {
380
+ meta = e.metadata ? JSON.parse(e.metadata) : {};
381
+ } catch {
382
+ // Keep empty metadata on parse error
383
+ }
384
+ return { ...e, type: e.edgeType, ...meta };
385
+ });
386
+ }
387
+
388
+ // ===========================================================================
389
+ // Node Utility Methods
390
+ // ===========================================================================
391
+
392
+ async isEndpoint(id: string): Promise<boolean> {
393
+ const response = await this._send('isEndpoint', { id: String(id) });
394
+ return (response as { value: boolean }).value;
395
+ }
396
+
397
+ async getNodeIdentifier(id: string): Promise<string | null> {
398
+ const response = await this._send('getNodeIdentifier', { id: String(id) });
399
+ return (response as { identifier?: string | null }).identifier || null;
400
+ }
401
+
402
+ async updateNodeVersion(id: string, version: string): Promise<RFDBResponse> {
403
+ return this._send('updateNodeVersion', { id: String(id), version });
404
+ }
405
+
406
+ async declareFields(fields: FieldDeclaration[]): Promise<number> {
407
+ const response = await this._send('declareFields', { fields });
408
+ return (response as { count?: number }).count || 0;
409
+ }
410
+
411
+ // ===========================================================================
412
+ // Datalog API
413
+ // ===========================================================================
414
+
415
+ async datalogLoadRules(source: string): Promise<number> {
416
+ const response = await this._send('datalogLoadRules', { source });
417
+ return (response as { count: number }).count;
418
+ }
419
+
420
+ async datalogClearRules(): Promise<RFDBResponse> {
421
+ return this._send('datalogClearRules');
422
+ }
423
+
424
+ protected _parseExplainResponse(response: RFDBResponse): DatalogExplainResult {
425
+ const r = response as unknown as DatalogExplainResult & { requestId?: string };
426
+ return {
427
+ bindings: r.bindings || [],
428
+ stats: r.stats,
429
+ profile: r.profile,
430
+ explainSteps: r.explainSteps || [],
431
+ warnings: r.warnings || [],
432
+ };
433
+ }
434
+
435
+ async datalogQuery(query: string): Promise<DatalogResult[]>;
436
+ async datalogQuery(query: string, explain: true): Promise<DatalogExplainResult>;
437
+ async datalogQuery(query: string, explain?: boolean): Promise<DatalogResult[] | DatalogExplainResult> {
438
+ const payload: Record<string, unknown> = { query };
439
+ if (explain) payload.explain = true;
440
+ const response = await this._send('datalogQuery', payload);
441
+ if (explain) {
442
+ return this._parseExplainResponse(response);
443
+ }
444
+ return (response as { results?: DatalogResult[] }).results || [];
445
+ }
446
+
447
+ async checkGuarantee(ruleSource: string): Promise<DatalogResult[]>;
448
+ async checkGuarantee(ruleSource: string, explain: true): Promise<DatalogExplainResult>;
449
+ async checkGuarantee(ruleSource: string, explain?: boolean): Promise<DatalogResult[] | DatalogExplainResult> {
450
+ const payload: Record<string, unknown> = { ruleSource };
451
+ if (explain) payload.explain = true;
452
+ const response = await this._send('checkGuarantee', payload);
453
+ if (explain) {
454
+ return this._parseExplainResponse(response);
455
+ }
456
+ return (response as { violations?: DatalogResult[] }).violations || [];
457
+ }
458
+
459
+ async executeDatalog(source: string): Promise<DatalogResult[]>;
460
+ async executeDatalog(source: string, explain: true): Promise<DatalogExplainResult>;
461
+ async executeDatalog(source: string, explain?: boolean): Promise<DatalogResult[] | DatalogExplainResult> {
462
+ const payload: Record<string, unknown> = { source };
463
+ if (explain) payload.explain = true;
464
+ const response = await this._send('executeDatalog', payload);
465
+ if (explain) {
466
+ return this._parseExplainResponse(response);
467
+ }
468
+ return (response as { results?: DatalogResult[] }).results || [];
469
+ }
470
+
471
+ async cypherQuery(query: string): Promise<CypherResult> {
472
+ const response = await this._send('cypherQuery', { query });
473
+ return response as unknown as CypherResult;
474
+ }
475
+
476
+ async ping(): Promise<string | false> {
477
+ const response = await this._send('ping') as { pong?: boolean; version?: string };
478
+ return response.pong && response.version ? response.version : false;
479
+ }
480
+
481
+ // ===========================================================================
482
+ // Protocol v2 - Multi-Database Commands
483
+ // ===========================================================================
484
+
485
+ async hello(protocolVersion: number = 3): Promise<HelloResponse> {
486
+ const response = await this._send('hello' as RFDBCommand, { protocolVersion });
487
+ return response as HelloResponse;
488
+ }
489
+
490
+ async createDatabase(name: string, ephemeral: boolean = false): Promise<CreateDatabaseResponse> {
491
+ const response = await this._send('createDatabase' as RFDBCommand, { name, ephemeral });
492
+ return response as CreateDatabaseResponse;
493
+ }
494
+
495
+ async openDatabase(name: string, mode: 'rw' | 'ro' = 'rw'): Promise<OpenDatabaseResponse> {
496
+ const response = await this._send('openDatabase' as RFDBCommand, { name, mode });
497
+ return response as OpenDatabaseResponse;
498
+ }
499
+
500
+ async closeDatabase(): Promise<RFDBResponse> {
501
+ return this._send('closeDatabase' as RFDBCommand);
502
+ }
503
+
504
+ async dropDatabase(name: string): Promise<RFDBResponse> {
505
+ return this._send('dropDatabase' as RFDBCommand, { name });
506
+ }
507
+
508
+ async listDatabases(): Promise<ListDatabasesResponse> {
509
+ const response = await this._send('listDatabases' as RFDBCommand);
510
+ return response as ListDatabasesResponse;
511
+ }
512
+
513
+ async currentDatabase(): Promise<CurrentDatabaseResponse> {
514
+ const response = await this._send('currentDatabase' as RFDBCommand);
515
+ return response as CurrentDatabaseResponse;
516
+ }
517
+
518
+ // ===========================================================================
519
+ // Snapshot Operations
520
+ // ===========================================================================
521
+
522
+ /**
523
+ * Convert a SnapshotRef to wire format payload fields.
524
+ */
525
+ protected _resolveSnapshotRef(ref: SnapshotRef): Record<string, unknown> {
526
+ if (typeof ref === 'number') return { version: ref };
527
+ return { tagKey: ref.tag, tagValue: ref.value };
528
+ }
529
+
530
+ async diffSnapshots(from: SnapshotRef, to: SnapshotRef): Promise<SnapshotDiff> {
531
+ const response = await this._send('diffSnapshots', {
532
+ from: this._resolveSnapshotRef(from),
533
+ to: this._resolveSnapshotRef(to),
534
+ });
535
+ return (response as DiffSnapshotsResponse).diff;
536
+ }
537
+
538
+ async tagSnapshot(version: number, tags: Record<string, string>): Promise<void> {
539
+ await this._send('tagSnapshot', { version, tags });
540
+ }
541
+
542
+ async findSnapshot(tagKey: string, tagValue: string): Promise<number | null> {
543
+ const response = await this._send('findSnapshot', { tagKey, tagValue });
544
+ return (response as FindSnapshotResponse).version;
545
+ }
546
+
547
+ async listSnapshots(filterTag?: string): Promise<SnapshotInfo[]> {
548
+ const payload: Record<string, unknown> = {};
549
+ if (filterTag !== undefined) payload.filterTag = filterTag;
550
+ const response = await this._send('listSnapshots', payload);
551
+ return (response as ListSnapshotsResponse).snapshots;
552
+ }
553
+
554
+ // ===========================================================================
555
+ // Batch Operations
556
+ // ===========================================================================
557
+
558
+ beginBatch(): void {
559
+ if (this._batching) throw new Error('Batch already in progress');
560
+ this._batching = true;
561
+ this._batchNodes = [];
562
+ this._batchEdges = [];
563
+ this._batchFiles = new Set();
564
+ }
565
+
566
+ /**
567
+ * Synchronously batch a single node.
568
+ */
569
+ batchNode(node: Partial<WireNode> & { id: string; type?: string; node_type?: string; nodeType?: string }): void {
570
+ if (!this._batching) throw new Error('No batch in progress');
571
+ const nodeRecord = node as Record<string, unknown>;
572
+ const { id, type, node_type, nodeType, name, file, exported, metadata, semanticId, semantic_id, ...rest } = nodeRecord;
573
+ const existingMeta = typeof metadata === 'string' ? JSON.parse(metadata as string) : (metadata || {});
574
+ const combinedMeta = { ...existingMeta, ...rest };
575
+ const wire: WireNode = {
576
+ id: String(id),
577
+ nodeType: (node_type || nodeType || type || 'UNKNOWN') as NodeType,
578
+ name: (name as string) || '',
579
+ file: (file as string) || '',
580
+ exported: (exported as boolean) || false,
581
+ metadata: JSON.stringify(combinedMeta),
582
+ };
583
+ const sid = semanticId || semantic_id;
584
+ if (sid) {
585
+ (wire as WireNode & { semanticId: string }).semanticId = String(sid);
586
+ }
587
+ this._batchNodes.push(wire);
588
+ if (wire.file) this._batchFiles.add(wire.file);
589
+ }
590
+
591
+ /**
592
+ * Synchronously batch a single edge.
593
+ */
594
+ batchEdge(edge: WireEdge | Record<string, unknown>): void {
595
+ if (!this._batching) throw new Error('No batch in progress');
596
+ const edgeRecord = edge as Record<string, unknown>;
597
+ const { src, dst, type, edge_type, edgeType, metadata, ...rest } = edgeRecord;
598
+ const existingMeta = typeof metadata === 'string' ? JSON.parse(metadata as string) : (metadata || {});
599
+ const combinedMeta = { ...existingMeta, ...rest };
600
+ this._batchEdges.push({
601
+ src: String(src),
602
+ dst: String(dst),
603
+ edgeType: (edge_type || edgeType || type || (edge as WireEdge).edgeType || 'UNKNOWN') as EdgeType,
604
+ metadata: JSON.stringify(combinedMeta),
605
+ });
606
+ }
607
+
608
+ async commitBatch(tags?: string[], deferIndex?: boolean, protectedTypes?: string[], overrideChangedFiles?: string[]): Promise<CommitDelta> {
609
+ if (!this._batching) throw new Error('No batch in progress');
610
+
611
+ const allNodes = this._batchNodes;
612
+ const allEdges = this._batchEdges;
613
+ const changedFiles = overrideChangedFiles ?? [...this._batchFiles];
614
+
615
+ this._batching = false;
616
+ this._batchNodes = [];
617
+ this._batchEdges = [];
618
+ this._batchFiles = new Set();
619
+
620
+ return this._sendCommitBatch(changedFiles, allNodes, allEdges, tags, deferIndex, protectedTypes);
621
+ }
622
+
623
+ /**
624
+ * Internal helper: send a commitBatch with chunking for large payloads.
625
+ * @internal
626
+ */
627
+ async _sendCommitBatch(
628
+ changedFiles: string[],
629
+ allNodes: WireNode[],
630
+ allEdges: WireEdge[],
631
+ tags?: string[],
632
+ deferIndex?: boolean,
633
+ protectedTypes?: string[],
634
+ ): Promise<CommitDelta> {
635
+ const CHUNK = 10_000;
636
+ if (allNodes.length <= CHUNK && allEdges.length <= CHUNK) {
637
+ const response = await this._send('commitBatch', {
638
+ changedFiles, nodes: allNodes, edges: allEdges, tags,
639
+ ...(deferIndex ? { deferIndex: true } : {}),
640
+ ...(protectedTypes?.length ? { protectedTypes } : {}),
641
+ });
642
+ return (response as CommitBatchResponse).delta;
643
+ }
644
+
645
+ const merged: CommitDelta = {
646
+ changedFiles,
647
+ nodesAdded: 0, nodesRemoved: 0,
648
+ edgesAdded: 0, edgesRemoved: 0,
649
+ changedNodeTypes: [], changedEdgeTypes: [],
650
+ };
651
+ const nodeTypes = new Set<string>();
652
+ const edgeTypes = new Set<string>();
653
+
654
+ const maxI = Math.max(
655
+ Math.ceil(allNodes.length / CHUNK),
656
+ Math.ceil(allEdges.length / CHUNK),
657
+ 1,
658
+ );
659
+
660
+ for (let i = 0; i < maxI; i++) {
661
+ const nodes = allNodes.slice(i * CHUNK, (i + 1) * CHUNK);
662
+ const edges = allEdges.slice(i * CHUNK, (i + 1) * CHUNK);
663
+ const response = await this._send('commitBatch', {
664
+ changedFiles: i === 0 ? changedFiles : [],
665
+ nodes, edges, tags,
666
+ ...(deferIndex ? { deferIndex: true } : {}),
667
+ ...(i === 0 && protectedTypes?.length ? { protectedTypes } : {}),
668
+ });
669
+ const d = (response as CommitBatchResponse).delta;
670
+ merged.nodesAdded += d.nodesAdded;
671
+ merged.nodesRemoved += d.nodesRemoved;
672
+ merged.edgesAdded += d.edgesAdded;
673
+ merged.edgesRemoved += d.edgesRemoved;
674
+ for (const t of d.changedNodeTypes) nodeTypes.add(t);
675
+ for (const t of d.changedEdgeTypes) edgeTypes.add(t);
676
+ }
677
+
678
+ merged.changedNodeTypes = [...nodeTypes];
679
+ merged.changedEdgeTypes = [...edgeTypes];
680
+ return merged;
681
+ }
682
+
683
+ async rebuildIndexes(): Promise<void> {
684
+ await this._send('rebuildIndexes', {});
685
+ }
686
+
687
+ abortBatch(): void {
688
+ this._batching = false;
689
+ this._batchNodes = [];
690
+ this._batchEdges = [];
691
+ this._batchFiles = new Set();
692
+ }
693
+
694
+ isBatching(): boolean {
695
+ return this._batching;
696
+ }
697
+
698
+ /**
699
+ * Find files that depend on the given changed files.
700
+ */
701
+ async findDependentFiles(changedFiles: string[]): Promise<string[]> {
702
+ const nodeIds: string[] = [];
703
+ for (const file of changedFiles) {
704
+ const ids = await this.findByAttr({ file });
705
+ nodeIds.push(...ids);
706
+ }
707
+
708
+ if (nodeIds.length === 0) return [];
709
+
710
+ const reachable = await this.reachability(
711
+ nodeIds,
712
+ 2,
713
+ ['IMPORTS_FROM', 'DEPENDS_ON', 'CALLS'] as EdgeType[],
714
+ true,
715
+ );
716
+
717
+ const changedSet = new Set(changedFiles);
718
+ const files = new Set<string>();
719
+ for (const id of reachable) {
720
+ const node = await this.getNode(id);
721
+ if (node?.file && !changedSet.has(node.file)) {
722
+ files.add(node.file);
723
+ }
724
+ }
725
+
726
+ return [...files];
727
+ }
728
+
729
+ async shutdown(): Promise<void> {
730
+ try {
731
+ await this._send('shutdown');
732
+ } catch {
733
+ // Expected - server closes connection
734
+ }
735
+ await this.close();
736
+ }
737
+ }