@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.
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
385
- // ===========================================================================
386
- /**
387
- * Get a node by ID
388
- */
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
280
+ // Streaming Overrides (Unix socket supports streaming)
416
281
  // ===========================================================================
417
282
  /**
418
- * Get neighbors of a node
283
+ * Negotiate protocol version with server.
284
+ * Overrides base to set streaming flag.
419
285
  */
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');
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;
550
291
  }
551
292
  /**
552
- * Clear the database
553
- */
554
- async clear() {
555
- return this._send('clear');
556
- }
557
- // ===========================================================================
558
- // Bulk Read Operations
559
- // ===========================================================================
560
- /**
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 {
@@ -640,389 +344,13 @@ export class RFDBClient extends EventEmitter {
640
344
  }
641
345
  }
642
346
  /**
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
347
+ * Create an isolated batch handle for concurrent-safe batching.
655
348
  */
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
- async commitBatch(tags) {
930
- if (!this._batching)
931
- throw new Error('No batch in progress');
932
- const allNodes = this._batchNodes;
933
- const allEdges = this._batchEdges;
934
- const changedFiles = [...this._batchFiles];
935
- this._batching = false;
936
- this._batchNodes = [];
937
- this._batchEdges = [];
938
- this._batchFiles = new Set();
939
- // Chunk large batches to stay under server's 100MB message limit.
940
- // First chunk includes changedFiles (triggers old data deletion),
941
- // subsequent chunks use empty changedFiles (additive only).
942
- const CHUNK = 10_000;
943
- if (allNodes.length <= CHUNK && allEdges.length <= CHUNK) {
944
- const response = await this._send('commitBatch', {
945
- changedFiles, nodes: allNodes, edges: allEdges, tags,
946
- });
947
- return response.delta;
948
- }
949
- const merged = {
950
- changedFiles,
951
- nodesAdded: 0, nodesRemoved: 0,
952
- edgesAdded: 0, edgesRemoved: 0,
953
- changedNodeTypes: [], changedEdgeTypes: [],
954
- };
955
- const nodeTypes = new Set();
956
- const edgeTypes = new Set();
957
- const maxI = Math.max(Math.ceil(allNodes.length / CHUNK), Math.ceil(allEdges.length / CHUNK), 1);
958
- for (let i = 0; i < maxI; i++) {
959
- const nodes = allNodes.slice(i * CHUNK, (i + 1) * CHUNK);
960
- const edges = allEdges.slice(i * CHUNK, (i + 1) * CHUNK);
961
- const response = await this._send('commitBatch', {
962
- changedFiles: i === 0 ? changedFiles : [],
963
- nodes, edges, tags,
964
- });
965
- const d = response.delta;
966
- merged.nodesAdded += d.nodesAdded;
967
- merged.nodesRemoved += d.nodesRemoved;
968
- merged.edgesAdded += d.edgesAdded;
969
- merged.edgesRemoved += d.edgesRemoved;
970
- for (const t of d.changedNodeTypes)
971
- nodeTypes.add(t);
972
- for (const t of d.changedEdgeTypes)
973
- edgeTypes.add(t);
974
- }
975
- merged.changedNodeTypes = [...nodeTypes];
976
- merged.changedEdgeTypes = [...edgeTypes];
977
- return merged;
978
- }
979
- /**
980
- * Abort the current batch, discarding all buffered data.
981
- */
982
- abortBatch() {
983
- this._batching = false;
984
- this._batchNodes = [];
985
- this._batchEdges = [];
986
- this._batchFiles = new Set();
987
- }
988
- /**
989
- * Check if a batch is currently in progress.
990
- */
991
- isBatching() {
992
- return this._batching;
993
- }
994
- /**
995
- * Find files that depend on the given changed files.
996
- * Uses backward reachability to find dependent modules.
997
- *
998
- * Note: For large result sets, each reachable node requires a separate
999
- * getNode RPC. A future server-side optimization could return file paths
1000
- * directly from the reachability query.
1001
- */
1002
- async findDependentFiles(changedFiles) {
1003
- const nodeIds = [];
1004
- for (const file of changedFiles) {
1005
- const ids = await this.findByAttr({ file });
1006
- nodeIds.push(...ids);
1007
- }
1008
- if (nodeIds.length === 0)
1009
- return [];
1010
- const reachable = await this.reachability(nodeIds, 2, ['IMPORTS_FROM', 'DEPENDS_ON', 'CALLS'], true);
1011
- const changedSet = new Set(changedFiles);
1012
- const files = new Set();
1013
- for (const id of reachable) {
1014
- const node = await this.getNode(id);
1015
- if (node?.file && !changedSet.has(node.file)) {
1016
- files.add(node.file);
1017
- }
1018
- }
1019
- return [...files];
349
+ createBatch() {
350
+ return new BatchHandle(this);
1020
351
  }
1021
352
  /**
1022
353
  * Unref the socket so it doesn't keep the process alive.
1023
- *
1024
- * Call this in test environments to allow process to exit
1025
- * even if connections remain open.
1026
354
  */
1027
355
  unref() {
1028
356
  if (this.socket) {
@@ -1039,17 +367,44 @@ export class RFDBClient extends EventEmitter {
1039
367
  this.connected = false;
1040
368
  }
1041
369
  }
1042
- /**
1043
- * Shutdown the server
1044
- */
1045
- async shutdown() {
1046
- try {
1047
- await this._send('shutdown');
1048
- }
1049
- catch {
1050
- // Expected - server closes connection
1051
- }
1052
- await this.close();
370
+ }
371
+ /**
372
+ * Isolated batch handle for concurrent-safe batching (REG-487).
373
+ */
374
+ export class BatchHandle {
375
+ client;
376
+ _nodes = [];
377
+ _edges = [];
378
+ _files = new Set();
379
+ constructor(client) {
380
+ this.client = client;
381
+ }
382
+ addNode(node, file) {
383
+ this._nodes.push(node);
384
+ if (file)
385
+ this._files.add(file);
386
+ else if (node.file)
387
+ this._files.add(node.file);
388
+ }
389
+ addEdge(edge) {
390
+ this._edges.push(edge);
391
+ }
392
+ addFile(file) {
393
+ this._files.add(file);
394
+ }
395
+ async commit(tags, deferIndex, protectedTypes) {
396
+ const nodes = this._nodes;
397
+ const edges = this._edges;
398
+ const changedFiles = [...this._files];
399
+ this._nodes = [];
400
+ this._edges = [];
401
+ this._files = new Set();
402
+ return this.client._sendCommitBatch(changedFiles, nodes, edges, tags, deferIndex, protectedTypes);
403
+ }
404
+ abort() {
405
+ this._nodes = [];
406
+ this._edges = [];
407
+ this._files = new Set();
1053
408
  }
1054
409
  }
1055
410
  export default RFDBClient;