@grafema/rfdb-client 0.2.12-beta → 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.
package/dist/client.js CHANGED
@@ -6,27 +6,24 @@
6
6
  */
7
7
  import { createConnection } from 'net';
8
8
  import { encode, decode } from '@msgpack/msgpack';
9
- import { EventEmitter } from 'events';
10
9
  import { StreamQueue } from './stream-queue.js';
11
- export class RFDBClient extends EventEmitter {
10
+ import { BaseRFDBClient } from './base-client.js';
11
+ export class RFDBClient extends BaseRFDBClient {
12
12
  socketPath;
13
+ clientName;
13
14
  socket;
14
15
  connected;
15
16
  pending;
16
17
  reqId;
17
18
  buffer;
18
- // Batch state
19
- _batching = false;
20
- _batchNodes = [];
21
- _batchEdges = [];
22
- _batchFiles = new Set();
23
19
  // Streaming state
24
20
  _supportsStreaming = false;
25
21
  _pendingStreams = new Map();
26
22
  _streamTimers = new Map();
27
- constructor(socketPath = '/tmp/rfdb.sock') {
23
+ constructor(socketPath = '/tmp/rfdb.sock', clientName = 'unknown') {
28
24
  super();
29
25
  this.socketPath = socketPath;
26
+ this.clientName = clientName;
30
27
  this.socket = null;
31
28
  this.connected = false;
32
29
  this.pending = new Map();
@@ -134,7 +131,7 @@ export class RFDBClient extends EventEmitter {
134
131
  }
135
132
  }
136
133
  /**
137
- * Handle decoded response match by requestId, route streaming chunks
134
+ * Handle decoded response -- match by requestId, route streaming chunks
138
135
  * to StreamQueue or resolve single-response Promise.
139
136
  */
140
137
  _handleResponse(response) {
@@ -167,7 +164,7 @@ export class RFDBClient extends EventEmitter {
167
164
  this._handleStreamingResponse(id, response, streamQueue);
168
165
  return;
169
166
  }
170
- // Non-streaming response existing behavior
167
+ // Non-streaming response -- existing behavior
171
168
  if (!this.pending.has(id)) {
172
169
  this.emit('error', new Error(`Received response for unknown requestId: ${response.requestId}`));
173
170
  return;
@@ -183,17 +180,13 @@ export class RFDBClient extends EventEmitter {
183
180
  }
184
181
  /**
185
182
  * Handle a response for a streaming request.
186
- * Routes chunk data to StreamQueue and manages stream lifecycle.
187
- * Resets per-chunk timeout on each successful chunk arrival.
188
183
  */
189
184
  _handleStreamingResponse(id, response, streamQueue) {
190
- // Error response — fail the stream
191
185
  if (response.error) {
192
186
  this._cleanupStream(id);
193
187
  streamQueue.fail(new Error(response.error));
194
188
  return;
195
189
  }
196
- // Streaming chunk (has `done` field)
197
190
  if ('done' in response) {
198
191
  const chunk = response;
199
192
  const nodes = chunk.nodes || [];
@@ -205,13 +198,11 @@ export class RFDBClient extends EventEmitter {
205
198
  streamQueue.end();
206
199
  }
207
200
  else {
208
- // Reset per-chunk timeout
209
201
  this._resetStreamTimer(id, streamQueue);
210
202
  }
211
203
  return;
212
204
  }
213
205
  // Auto-fallback: server sent a non-streaming Nodes response
214
- // (server doesn't support streaming or result was below threshold)
215
206
  const nodesResponse = response;
216
207
  const nodes = nodesResponse.nodes || [];
217
208
  for (const node of nodes) {
@@ -220,9 +211,6 @@ export class RFDBClient extends EventEmitter {
220
211
  this._cleanupStream(id);
221
212
  streamQueue.end();
222
213
  }
223
- /**
224
- * Reset the per-chunk timeout for a streaming request.
225
- */
226
214
  _resetStreamTimer(id, streamQueue) {
227
215
  const existing = this._streamTimers.get(id);
228
216
  if (existing)
@@ -233,9 +221,6 @@ export class RFDBClient extends EventEmitter {
233
221
  }, RFDBClient.DEFAULT_TIMEOUT_MS);
234
222
  this._streamTimers.set(id, timer);
235
223
  }
236
- /**
237
- * Clean up all state for a completed/failed streaming request.
238
- */
239
224
  _cleanupStream(id) {
240
225
  this._pendingStreams.delete(id);
241
226
  this.pending.delete(id);
@@ -251,10 +236,6 @@ export class RFDBClient extends EventEmitter {
251
236
  const num = parseInt(requestId.slice(1), 10);
252
237
  return Number.isNaN(num) ? null : num;
253
238
  }
254
- /**
255
- * Default timeout for operations (60 seconds)
256
- * Flush/compact may take time for large graphs, but should not hang indefinitely
257
- */
258
239
  static DEFAULT_TIMEOUT_MS = 60_000;
259
240
  /**
260
241
  * Send a request and wait for response with timeout
@@ -267,12 +248,10 @@ export class RFDBClient extends EventEmitter {
267
248
  const id = this.reqId++;
268
249
  const request = { requestId: `r${id}`, cmd, ...payload };
269
250
  const msgBytes = encode(request);
270
- // Setup timeout
271
251
  const timer = setTimeout(() => {
272
252
  this.pending.delete(id);
273
253
  reject(new Error(`RFDB ${cmd} timed out after ${timeoutMs}ms. Server may be unresponsive or dbPath may be invalid.`));
274
254
  }, timeoutMs);
275
- // Handle socket errors during this request
276
255
  const errorHandler = (err) => {
277
256
  this.pending.delete(id);
278
257
  clearTimeout(timer);
@@ -289,7 +268,7 @@ export class RFDBClient extends EventEmitter {
289
268
  clearTimeout(timer);
290
269
  this.socket?.removeListener('error', errorHandler);
291
270
  reject(error);
292
- }
271
+ },
293
272
  });
294
273
  // Write length prefix + message
295
274
  const header = Buffer.alloc(4);
@@ -298,271 +277,23 @@ export class RFDBClient extends EventEmitter {
298
277
  });
299
278
  }
300
279
  // ===========================================================================
301
- // Write Operations
302
- // ===========================================================================
303
- /**
304
- * Add nodes to the graph
305
- * Extra properties beyond id/type/name/file/exported/metadata are merged into metadata
306
- */
307
- async addNodes(nodes) {
308
- const wireNodes = nodes.map(n => {
309
- // Cast to Record to allow iteration over extra properties
310
- const nodeRecord = n;
311
- // Extract known wire format fields, rest goes to metadata
312
- const { id, type, node_type, nodeType, name, file, exported, metadata, semanticId, semantic_id, ...rest } = nodeRecord;
313
- // Merge explicit metadata with extra properties
314
- const existingMeta = typeof metadata === 'string' ? JSON.parse(metadata) : (metadata || {});
315
- const combinedMeta = { ...existingMeta, ...rest };
316
- const wire = {
317
- id: String(id),
318
- nodeType: (node_type || nodeType || type || 'UNKNOWN'),
319
- name: name || '',
320
- file: file || '',
321
- exported: exported || false,
322
- metadata: JSON.stringify(combinedMeta),
323
- };
324
- // Preserve semanticId as top-level field for v3 protocol
325
- const sid = semanticId || semantic_id;
326
- if (sid) {
327
- wire.semanticId = String(sid);
328
- }
329
- return wire;
330
- });
331
- if (this._batching) {
332
- this._batchNodes.push(...wireNodes);
333
- for (const node of wireNodes) {
334
- if (node.file)
335
- this._batchFiles.add(node.file);
336
- }
337
- return { ok: true };
338
- }
339
- return this._send('addNodes', { nodes: wireNodes });
340
- }
341
- /**
342
- * Add edges to the graph
343
- * Extra properties beyond src/dst/type are merged into metadata
344
- */
345
- async addEdges(edges, skipValidation = false) {
346
- const wireEdges = edges.map(e => {
347
- // Cast to unknown first then to Record to allow extra properties
348
- const edge = e;
349
- // Extract known fields, rest goes to metadata
350
- const { src, dst, type, edge_type, edgeType, metadata, ...rest } = edge;
351
- // Merge explicit metadata with extra properties
352
- const existingMeta = typeof metadata === 'string' ? JSON.parse(metadata) : (metadata || {});
353
- const combinedMeta = { ...existingMeta, ...rest };
354
- return {
355
- src: String(src),
356
- dst: String(dst),
357
- edgeType: (edge_type || edgeType || type || e.edgeType || 'UNKNOWN'),
358
- metadata: JSON.stringify(combinedMeta),
359
- };
360
- });
361
- if (this._batching) {
362
- this._batchEdges.push(...wireEdges);
363
- return { ok: true };
364
- }
365
- return this._send('addEdges', { edges: wireEdges, skipValidation });
366
- }
367
- /**
368
- * Delete a node
369
- */
370
- async deleteNode(id) {
371
- return this._send('deleteNode', { id: String(id) });
372
- }
373
- /**
374
- * Delete an edge
375
- */
376
- async deleteEdge(src, dst, edgeType) {
377
- return this._send('deleteEdge', {
378
- src: String(src),
379
- dst: String(dst),
380
- edgeType
381
- });
382
- }
383
- // ===========================================================================
384
- // Read Operations
280
+ // Streaming Overrides (Unix socket supports streaming)
385
281
  // ===========================================================================
386
282
  /**
387
- * Get a node by ID
283
+ * Negotiate protocol version with server.
284
+ * Overrides base to set streaming flag.
388
285
  */
389
- async getNode(id) {
390
- const response = await this._send('getNode', { id: String(id) });
391
- return response.node || null;
392
- }
393
- /**
394
- * Check if node exists
395
- */
396
- async nodeExists(id) {
397
- const response = await this._send('nodeExists', { id: String(id) });
398
- return response.value;
399
- }
400
- /**
401
- * Find nodes by type
402
- */
403
- async findByType(nodeType) {
404
- const response = await this._send('findByType', { nodeType });
405
- return response.ids || [];
406
- }
407
- /**
408
- * Find nodes by attributes
409
- */
410
- async findByAttr(query) {
411
- const response = await this._send('findByAttr', { query });
412
- return response.ids || [];
413
- }
414
- // ===========================================================================
415
- // Graph Traversal
416
- // ===========================================================================
417
- /**
418
- * Get neighbors of a node
419
- */
420
- async neighbors(id, edgeTypes = []) {
421
- const response = await this._send('neighbors', {
422
- id: String(id),
423
- edgeTypes
424
- });
425
- return response.ids || [];
426
- }
427
- /**
428
- * Breadth-first search
429
- */
430
- async bfs(startIds, maxDepth, edgeTypes = []) {
431
- const response = await this._send('bfs', {
432
- startIds: startIds.map(String),
433
- maxDepth,
434
- edgeTypes
435
- });
436
- return response.ids || [];
437
- }
438
- /**
439
- * Depth-first search
440
- */
441
- async dfs(startIds, maxDepth, edgeTypes = []) {
442
- const response = await this._send('dfs', {
443
- startIds: startIds.map(String),
444
- maxDepth,
445
- edgeTypes
446
- });
447
- return response.ids || [];
448
- }
449
- /**
450
- * Reachability query - find all nodes reachable from start nodes
451
- */
452
- async reachability(startIds, maxDepth, edgeTypes = [], backward = false) {
453
- const response = await this._send('reachability', {
454
- startIds: startIds.map(String),
455
- maxDepth,
456
- edgeTypes,
457
- backward
458
- });
459
- return response.ids || [];
460
- }
461
- /**
462
- * Get outgoing edges from a node
463
- * Parses metadata JSON and spreads it onto the edge object for convenience
464
- */
465
- async getOutgoingEdges(id, edgeTypes = null) {
466
- const response = await this._send('getOutgoingEdges', {
467
- id: String(id),
468
- edgeTypes
469
- });
470
- const edges = response.edges || [];
471
- // Parse metadata and spread onto edge for convenience
472
- return edges.map(e => {
473
- let meta = {};
474
- try {
475
- meta = e.metadata ? JSON.parse(e.metadata) : {};
476
- }
477
- catch {
478
- // Keep empty metadata on parse error
479
- }
480
- return { ...e, type: e.edgeType, ...meta };
481
- });
482
- }
483
- /**
484
- * Get incoming edges to a node
485
- * Parses metadata JSON and spreads it onto the edge object for convenience
486
- */
487
- async getIncomingEdges(id, edgeTypes = null) {
488
- const response = await this._send('getIncomingEdges', {
489
- id: String(id),
490
- edgeTypes
491
- });
492
- const edges = response.edges || [];
493
- // Parse metadata and spread onto edge for convenience
494
- return edges.map(e => {
495
- let meta = {};
496
- try {
497
- meta = e.metadata ? JSON.parse(e.metadata) : {};
498
- }
499
- catch {
500
- // Keep empty metadata on parse error
501
- }
502
- return { ...e, type: e.edgeType, ...meta };
503
- });
504
- }
505
- // ===========================================================================
506
- // Stats
507
- // ===========================================================================
508
- /**
509
- * Get node count
510
- */
511
- async nodeCount() {
512
- const response = await this._send('nodeCount');
513
- return response.count;
514
- }
515
- /**
516
- * Get edge count
517
- */
518
- async edgeCount() {
519
- const response = await this._send('edgeCount');
520
- return response.count;
521
- }
522
- /**
523
- * Count nodes by type
524
- */
525
- async countNodesByType(types = null) {
526
- const response = await this._send('countNodesByType', { types });
527
- return response.counts || {};
528
- }
529
- /**
530
- * Count edges by type
531
- */
532
- async countEdgesByType(edgeTypes = null) {
533
- const response = await this._send('countEdgesByType', { edgeTypes });
534
- return response.counts || {};
535
- }
536
- // ===========================================================================
537
- // Control
538
- // ===========================================================================
539
- /**
540
- * Flush data to disk
541
- */
542
- async flush() {
543
- return this._send('flush');
544
- }
545
- /**
546
- * Compact the database
547
- */
548
- async compact() {
549
- return this._send('compact');
550
- }
551
- /**
552
- * Clear the database
553
- */
554
- async clear() {
555
- return this._send('clear');
286
+ async hello(protocolVersion = 3) {
287
+ const response = await this._send('hello', { protocolVersion });
288
+ const hello = response;
289
+ this._supportsStreaming = hello.features?.includes('streaming') ?? false;
290
+ return hello;
556
291
  }
557
- // ===========================================================================
558
- // Bulk Read Operations
559
- // ===========================================================================
560
292
  /**
561
- * Query nodes (async generator)
293
+ * Query nodes (async generator).
294
+ * Overrides base to support streaming for protocol v3+.
562
295
  */
563
296
  async *queryNodes(query) {
564
- // When server supports streaming (protocol v3+), delegate to streaming handler
565
- // to correctly handle chunked NodesChunk responses for large result sets.
566
297
  if (this._supportsStreaming) {
567
298
  yield* this.queryNodesStream(query);
568
299
  return;
@@ -574,37 +305,13 @@ export class RFDBClient extends EventEmitter {
574
305
  yield node;
575
306
  }
576
307
  }
577
- /**
578
- * Build a server query object from an AttrQuery.
579
- */
580
- _buildServerQuery(query) {
581
- const serverQuery = {};
582
- if (query.nodeType)
583
- serverQuery.nodeType = query.nodeType;
584
- if (query.type)
585
- serverQuery.nodeType = query.type;
586
- if (query.name)
587
- serverQuery.name = query.name;
588
- if (query.file)
589
- serverQuery.file = query.file;
590
- if (query.exported !== undefined)
591
- serverQuery.exported = query.exported;
592
- return serverQuery;
593
- }
594
308
  /**
595
309
  * Stream nodes matching query with true streaming support.
596
- *
597
- * Behavior depends on server capabilities:
598
- * - Server supports streaming (protocol v3): receives chunked NodesChunk
599
- * responses via StreamQueue. Nodes are yielded as they arrive.
600
- * - Server does NOT support streaming (fallback): delegates to queryNodes()
601
- * which yields nodes one by one from bulk response.
602
- *
603
- * The generator can be aborted by breaking out of the loop or calling .return().
310
+ * Overrides base to use StreamQueue for protocol v3+.
604
311
  */
605
312
  async *queryNodesStream(query) {
606
313
  if (!this._supportsStreaming) {
607
- yield* this.queryNodes(query);
314
+ yield* super.queryNodes(query);
608
315
  return;
609
316
  }
610
317
  if (!this.connected || !this.socket) {
@@ -614,12 +321,10 @@ export class RFDBClient extends EventEmitter {
614
321
  const id = this.reqId++;
615
322
  const streamQueue = new StreamQueue();
616
323
  this._pendingStreams.set(id, streamQueue);
617
- // Build and send request manually (can't use _send which expects single response)
618
324
  const request = { requestId: `r${id}`, cmd: 'queryNodes', query: serverQuery };
619
325
  const msgBytes = encode(request);
620
326
  const header = Buffer.alloc(4);
621
327
  header.writeUInt32BE(msgBytes.length);
622
- // Register in pending map for error routing
623
328
  this.pending.set(id, {
624
329
  resolve: () => { this._cleanupStream(id); },
625
330
  reject: (error) => {
@@ -627,7 +332,6 @@ export class RFDBClient extends EventEmitter {
627
332
  streamQueue.fail(error);
628
333
  },
629
334
  });
630
- // Start per-chunk timeout (resets on each chunk in _handleStreamingResponse)
631
335
  this._resetStreamTimer(id, streamQueue);
632
336
  this.socket.write(Buffer.concat([header, Buffer.from(msgBytes)]));
633
337
  try {
@@ -639,421 +343,14 @@ export class RFDBClient extends EventEmitter {
639
343
  this._cleanupStream(id);
640
344
  }
641
345
  }
642
- /**
643
- * Get all nodes matching query
644
- */
645
- async getAllNodes(query = {}) {
646
- const nodes = [];
647
- for await (const node of this.queryNodes(query)) {
648
- nodes.push(node);
649
- }
650
- return nodes;
651
- }
652
- /**
653
- * Get all edges
654
- * Parses metadata JSON and spreads it onto the edge object for convenience
655
- */
656
- async getAllEdges() {
657
- const response = await this._send('getAllEdges');
658
- const edges = response.edges || [];
659
- // Parse metadata and spread onto edge for convenience
660
- return edges.map(e => {
661
- let meta = {};
662
- try {
663
- meta = e.metadata ? JSON.parse(e.metadata) : {};
664
- }
665
- catch {
666
- // Keep empty metadata on parse error
667
- }
668
- return { ...e, type: e.edgeType, ...meta };
669
- });
670
- }
671
- // ===========================================================================
672
- // Node Utility Methods
673
- // ===========================================================================
674
- /**
675
- * Check if node is an endpoint (has no outgoing edges)
676
- */
677
- async isEndpoint(id) {
678
- const response = await this._send('isEndpoint', { id: String(id) });
679
- return response.value;
680
- }
681
- /**
682
- * Get node identifier string
683
- */
684
- async getNodeIdentifier(id) {
685
- const response = await this._send('getNodeIdentifier', { id: String(id) });
686
- return response.identifier || null;
687
- }
688
- /**
689
- * Update node version
690
- */
691
- async updateNodeVersion(id, version) {
692
- return this._send('updateNodeVersion', { id: String(id), version });
693
- }
694
- /**
695
- * Declare metadata fields for server-side indexing.
696
- * Call before adding nodes so the server builds indexes on flush.
697
- * Returns the number of declared fields.
698
- */
699
- async declareFields(fields) {
700
- const response = await this._send('declareFields', { fields });
701
- return response.count || 0;
702
- }
703
- // ===========================================================================
704
- // Datalog API
705
- // ===========================================================================
706
- /**
707
- * Load Datalog rules
708
- */
709
- async datalogLoadRules(source) {
710
- const response = await this._send('datalogLoadRules', { source });
711
- return response.count;
712
- }
713
- /**
714
- * Clear Datalog rules
715
- */
716
- async datalogClearRules() {
717
- return this._send('datalogClearRules');
718
- }
719
- /**
720
- * Execute Datalog query
721
- */
722
- async datalogQuery(query) {
723
- const response = await this._send('datalogQuery', { query });
724
- return response.results || [];
725
- }
726
- /**
727
- * Check a guarantee (Datalog rule) and return violations
728
- */
729
- async checkGuarantee(ruleSource) {
730
- const response = await this._send('checkGuarantee', { ruleSource });
731
- return response.violations || [];
732
- }
733
- /**
734
- * Execute unified Datalog — handles both direct queries and rule-based programs.
735
- * Auto-detects the head predicate instead of hardcoding violation(X).
736
- */
737
- async executeDatalog(source) {
738
- const response = await this._send('executeDatalog', { source });
739
- return response.results || [];
740
- }
741
- /**
742
- * Ping the server
743
- */
744
- async ping() {
745
- const response = await this._send('ping');
746
- return response.pong && response.version ? response.version : false;
747
- }
748
- // ===========================================================================
749
- // Protocol v2 - Multi-Database Commands
750
- // ===========================================================================
751
- /**
752
- * Negotiate protocol version with server
753
- * @param protocolVersion - Protocol version to negotiate (default: 2)
754
- * @returns Server capabilities including protocolVersion, serverVersion, features
755
- */
756
- async hello(protocolVersion = 3) {
757
- const response = await this._send('hello', { protocolVersion });
758
- const hello = response;
759
- this._supportsStreaming = hello.features?.includes('streaming') ?? false;
760
- return hello;
761
- }
762
- /**
763
- * Create a new database
764
- * @param name - Database name (alphanumeric, _, -)
765
- * @param ephemeral - If true, database is in-memory and auto-cleaned on disconnect
766
- */
767
- async createDatabase(name, ephemeral = false) {
768
- const response = await this._send('createDatabase', { name, ephemeral });
769
- return response;
770
- }
771
- /**
772
- * Open a database and set as current for this session
773
- * @param name - Database name
774
- * @param mode - 'rw' (read-write) or 'ro' (read-only)
775
- */
776
- async openDatabase(name, mode = 'rw') {
777
- const response = await this._send('openDatabase', { name, mode });
778
- return response;
779
- }
780
- /**
781
- * Close current database
782
- */
783
- async closeDatabase() {
784
- return this._send('closeDatabase');
785
- }
786
- /**
787
- * Drop (delete) a database - must not be in use
788
- * @param name - Database name
789
- */
790
- async dropDatabase(name) {
791
- return this._send('dropDatabase', { name });
792
- }
793
- /**
794
- * List all databases
795
- */
796
- async listDatabases() {
797
- const response = await this._send('listDatabases');
798
- return response;
799
- }
800
- /**
801
- * Get current database for this session
802
- */
803
- async currentDatabase() {
804
- const response = await this._send('currentDatabase');
805
- return response;
806
- }
807
- // ===========================================================================
808
- // Snapshot Operations
809
- // ===========================================================================
810
- /**
811
- * Convert a SnapshotRef to wire format payload fields.
812
- *
813
- * - number -> { version: N }
814
- * - { tag, value } -> { tagKey, tagValue }
815
- */
816
- _resolveSnapshotRef(ref) {
817
- if (typeof ref === 'number')
818
- return { version: ref };
819
- return { tagKey: ref.tag, tagValue: ref.value };
820
- }
821
- /**
822
- * Compute diff between two snapshots.
823
- * @param from - Source snapshot (version number or tag reference)
824
- * @param to - Target snapshot (version number or tag reference)
825
- * @returns SnapshotDiff with added/removed segments and stats
826
- */
827
- async diffSnapshots(from, to) {
828
- const response = await this._send('diffSnapshots', {
829
- from: this._resolveSnapshotRef(from),
830
- to: this._resolveSnapshotRef(to),
831
- });
832
- return response.diff;
833
- }
834
- /**
835
- * Tag a snapshot with key-value metadata.
836
- * @param version - Snapshot version to tag
837
- * @param tags - Key-value pairs to apply (e.g. { "release": "v1.0" })
838
- */
839
- async tagSnapshot(version, tags) {
840
- await this._send('tagSnapshot', { version, tags });
841
- }
842
- /**
843
- * Find a snapshot by tag key/value pair.
844
- * @param tagKey - Tag key to search for
845
- * @param tagValue - Tag value to match
846
- * @returns Snapshot version number, or null if not found
847
- */
848
- async findSnapshot(tagKey, tagValue) {
849
- const response = await this._send('findSnapshot', { tagKey, tagValue });
850
- return response.version;
851
- }
852
- /**
853
- * List snapshots, optionally filtered by tag key.
854
- * @param filterTag - Optional tag key to filter by (only snapshots with this tag)
855
- * @returns Array of SnapshotInfo objects
856
- */
857
- async listSnapshots(filterTag) {
858
- const payload = {};
859
- if (filterTag !== undefined)
860
- payload.filterTag = filterTag;
861
- const response = await this._send('listSnapshots', payload);
862
- return response.snapshots;
863
- }
864
- // ===========================================================================
865
- // Batch Operations
866
- // ===========================================================================
867
- /**
868
- * Begin a batch operation.
869
- * While batching, addNodes/addEdges buffer locally instead of sending to server.
870
- * Call commitBatch() to send all buffered data atomically.
871
- */
872
- beginBatch() {
873
- if (this._batching)
874
- throw new Error('Batch already in progress');
875
- this._batching = true;
876
- this._batchNodes = [];
877
- this._batchEdges = [];
878
- this._batchFiles = new Set();
879
- }
880
- /**
881
- * Synchronously batch a single node. Must be inside beginBatch/commitBatch.
882
- * Skips async wrapper — pushes directly to batch array.
883
- */
884
- batchNode(node) {
885
- if (!this._batching)
886
- throw new Error('No batch in progress');
887
- const nodeRecord = node;
888
- const { id, type, node_type, nodeType, name, file, exported, metadata, semanticId, semantic_id, ...rest } = nodeRecord;
889
- const existingMeta = typeof metadata === 'string' ? JSON.parse(metadata) : (metadata || {});
890
- const combinedMeta = { ...existingMeta, ...rest };
891
- const wire = {
892
- id: String(id),
893
- nodeType: (node_type || nodeType || type || 'UNKNOWN'),
894
- name: name || '',
895
- file: file || '',
896
- exported: exported || false,
897
- metadata: JSON.stringify(combinedMeta),
898
- };
899
- const sid = semanticId || semantic_id;
900
- if (sid) {
901
- wire.semanticId = String(sid);
902
- }
903
- this._batchNodes.push(wire);
904
- if (wire.file)
905
- this._batchFiles.add(wire.file);
906
- }
907
- /**
908
- * Synchronously batch a single edge. Must be inside beginBatch/commitBatch.
909
- */
910
- batchEdge(edge) {
911
- if (!this._batching)
912
- throw new Error('No batch in progress');
913
- const edgeRecord = edge;
914
- const { src, dst, type, edge_type, edgeType, metadata, ...rest } = edgeRecord;
915
- const existingMeta = typeof metadata === 'string' ? JSON.parse(metadata) : (metadata || {});
916
- const combinedMeta = { ...existingMeta, ...rest };
917
- this._batchEdges.push({
918
- src: String(src),
919
- dst: String(dst),
920
- edgeType: (edge_type || edgeType || type || edge.edgeType || 'UNKNOWN'),
921
- metadata: JSON.stringify(combinedMeta),
922
- });
923
- }
924
- /**
925
- * Commit the current batch to the server.
926
- * Sends all buffered nodes/edges with the list of changed files.
927
- * Server atomically replaces old data for changed files with new data.
928
- *
929
- * @param tags - Optional tags for the commit (e.g., plugin name, phase)
930
- * @param deferIndex - When true, server writes data but skips index rebuild.
931
- * Caller must send rebuildIndexes() after all deferred commits complete.
932
- */
933
- async commitBatch(tags, deferIndex, protectedTypes) {
934
- if (!this._batching)
935
- throw new Error('No batch in progress');
936
- const allNodes = this._batchNodes;
937
- const allEdges = this._batchEdges;
938
- const changedFiles = [...this._batchFiles];
939
- this._batching = false;
940
- this._batchNodes = [];
941
- this._batchEdges = [];
942
- this._batchFiles = new Set();
943
- return this._sendCommitBatch(changedFiles, allNodes, allEdges, tags, deferIndex, protectedTypes);
944
- }
945
- /**
946
- * Internal helper: send a commitBatch with chunking for large payloads.
947
- * Used by both commitBatch() and BatchHandle.commit().
948
- * @internal
949
- */
950
- async _sendCommitBatch(changedFiles, allNodes, allEdges, tags, deferIndex, protectedTypes) {
951
- // Chunk large batches to stay under server's 100MB message limit.
952
- // First chunk includes changedFiles (triggers old data deletion),
953
- // subsequent chunks use empty changedFiles (additive only).
954
- const CHUNK = 10_000;
955
- if (allNodes.length <= CHUNK && allEdges.length <= CHUNK) {
956
- const response = await this._send('commitBatch', {
957
- changedFiles, nodes: allNodes, edges: allEdges, tags,
958
- ...(deferIndex ? { deferIndex: true } : {}),
959
- ...(protectedTypes?.length ? { protectedTypes } : {}),
960
- });
961
- return response.delta;
962
- }
963
- const merged = {
964
- changedFiles,
965
- nodesAdded: 0, nodesRemoved: 0,
966
- edgesAdded: 0, edgesRemoved: 0,
967
- changedNodeTypes: [], changedEdgeTypes: [],
968
- };
969
- const nodeTypes = new Set();
970
- const edgeTypes = new Set();
971
- const maxI = Math.max(Math.ceil(allNodes.length / CHUNK), Math.ceil(allEdges.length / CHUNK), 1);
972
- for (let i = 0; i < maxI; i++) {
973
- const nodes = allNodes.slice(i * CHUNK, (i + 1) * CHUNK);
974
- const edges = allEdges.slice(i * CHUNK, (i + 1) * CHUNK);
975
- const response = await this._send('commitBatch', {
976
- changedFiles: i === 0 ? changedFiles : [],
977
- nodes, edges, tags,
978
- ...(deferIndex ? { deferIndex: true } : {}),
979
- ...(i === 0 && protectedTypes?.length ? { protectedTypes } : {}),
980
- });
981
- const d = response.delta;
982
- merged.nodesAdded += d.nodesAdded;
983
- merged.nodesRemoved += d.nodesRemoved;
984
- merged.edgesAdded += d.edgesAdded;
985
- merged.edgesRemoved += d.edgesRemoved;
986
- for (const t of d.changedNodeTypes)
987
- nodeTypes.add(t);
988
- for (const t of d.changedEdgeTypes)
989
- edgeTypes.add(t);
990
- }
991
- merged.changedNodeTypes = [...nodeTypes];
992
- merged.changedEdgeTypes = [...edgeTypes];
993
- return merged;
994
- }
995
- /**
996
- * Rebuild all secondary indexes after a series of deferred-index commits.
997
- * Call this once after bulk loading data with commitBatch(tags, true).
998
- */
999
- async rebuildIndexes() {
1000
- await this._send('rebuildIndexes', {});
1001
- }
1002
346
  /**
1003
347
  * Create an isolated batch handle for concurrent-safe batching.
1004
- * Each BatchHandle has its own node/edge buffers, avoiding the shared
1005
- * instance-level _batching state race condition with multiple workers.
1006
348
  */
1007
349
  createBatch() {
1008
350
  return new BatchHandle(this);
1009
351
  }
1010
- /**
1011
- * Abort the current batch, discarding all buffered data.
1012
- */
1013
- abortBatch() {
1014
- this._batching = false;
1015
- this._batchNodes = [];
1016
- this._batchEdges = [];
1017
- this._batchFiles = new Set();
1018
- }
1019
- /**
1020
- * Check if a batch is currently in progress.
1021
- */
1022
- isBatching() {
1023
- return this._batching;
1024
- }
1025
- /**
1026
- * Find files that depend on the given changed files.
1027
- * Uses backward reachability to find dependent modules.
1028
- *
1029
- * Note: For large result sets, each reachable node requires a separate
1030
- * getNode RPC. A future server-side optimization could return file paths
1031
- * directly from the reachability query.
1032
- */
1033
- async findDependentFiles(changedFiles) {
1034
- const nodeIds = [];
1035
- for (const file of changedFiles) {
1036
- const ids = await this.findByAttr({ file });
1037
- nodeIds.push(...ids);
1038
- }
1039
- if (nodeIds.length === 0)
1040
- return [];
1041
- const reachable = await this.reachability(nodeIds, 2, ['IMPORTS_FROM', 'DEPENDS_ON', 'CALLS'], true);
1042
- const changedSet = new Set(changedFiles);
1043
- const files = new Set();
1044
- for (const id of reachable) {
1045
- const node = await this.getNode(id);
1046
- if (node?.file && !changedSet.has(node.file)) {
1047
- files.add(node.file);
1048
- }
1049
- }
1050
- return [...files];
1051
- }
1052
352
  /**
1053
353
  * Unref the socket so it doesn't keep the process alive.
1054
- *
1055
- * Call this in test environments to allow process to exit
1056
- * even if connections remain open.
1057
354
  */
1058
355
  unref() {
1059
356
  if (this.socket) {
@@ -1070,26 +367,9 @@ export class RFDBClient extends EventEmitter {
1070
367
  this.connected = false;
1071
368
  }
1072
369
  }
1073
- /**
1074
- * Shutdown the server
1075
- */
1076
- async shutdown() {
1077
- try {
1078
- await this._send('shutdown');
1079
- }
1080
- catch {
1081
- // Expected - server closes connection
1082
- }
1083
- await this.close();
1084
- }
1085
370
  }
1086
371
  /**
1087
372
  * Isolated batch handle for concurrent-safe batching (REG-487).
1088
- *
1089
- * Each BatchHandle maintains its own node/edge/file buffers, completely
1090
- * independent of the RFDBClient's instance-level _batching state.
1091
- * Multiple workers can each create their own BatchHandle and commit
1092
- * independently without race conditions.
1093
373
  */
1094
374
  export class BatchHandle {
1095
375
  client;