@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,897 @@
1
+ /**
2
+ * RFDBClient Locking Tests (REG-523 STEP 2.5)
3
+ *
4
+ * These tests lock the existing behavior of RFDBClient BEFORE the refactoring
5
+ * that extracts BaseRFDBClient. If any test breaks during refactoring, it means
6
+ * existing behavior was altered — the refactoring is wrong.
7
+ *
8
+ * Tested areas:
9
+ * - Constructor and initial state
10
+ * - _send() framing: length-prefix + msgpack encoding
11
+ * - All key methods call _send() with correct command names
12
+ * - Batch operations (client-side only, no server needed)
13
+ * - Error handling for disconnected state
14
+ * - _handleData() length-prefix parsing and msgpack decoding
15
+ * - _parseRequestId() parsing logic
16
+ * - Event emission patterns
17
+ *
18
+ * NOTE: Tests run against dist/ (build first with pnpm build).
19
+ * Uses node:test and node:assert (project standard).
20
+ */
21
+
22
+ import { describe, it, beforeEach } from 'node:test';
23
+ import assert from 'node:assert';
24
+ import { EventEmitter } from 'node:events';
25
+ import { encode, decode } from '@msgpack/msgpack';
26
+ import { RFDBClient, BatchHandle } from '../dist/client.js';
27
+
28
+ // =============================================================================
29
+ // Part 1: Constructor and Initial State
30
+ // =============================================================================
31
+
32
+ describe('RFDBClient — Constructor and Initial State (Locking)', () => {
33
+ it('should create instance with default socket path', () => {
34
+ const client = new RFDBClient();
35
+ assert.strictEqual(client.socketPath, '/tmp/rfdb.sock');
36
+ assert.strictEqual(client.connected, false);
37
+ });
38
+
39
+ it('should create instance with custom socket path', () => {
40
+ const client = new RFDBClient('/custom/path.sock');
41
+ assert.strictEqual(client.socketPath, '/custom/path.sock');
42
+ });
43
+
44
+ it('should not be connected initially', () => {
45
+ const client = new RFDBClient('/tmp/test.sock');
46
+ assert.strictEqual(client.connected, false);
47
+ });
48
+
49
+ it('should be an EventEmitter', () => {
50
+ const client = new RFDBClient('/tmp/test.sock');
51
+ assert.ok(client instanceof EventEmitter);
52
+ });
53
+
54
+ it('should not be batching initially', () => {
55
+ const client = new RFDBClient('/tmp/test.sock');
56
+ assert.strictEqual(client.isBatching(), false);
57
+ });
58
+
59
+ it('should not support streaming initially', () => {
60
+ const client = new RFDBClient('/tmp/test.sock');
61
+ assert.strictEqual(client.supportsStreaming, false);
62
+ });
63
+ });
64
+
65
+ // =============================================================================
66
+ // Part 2: _send() requires connection — all methods should throw when disconnected
67
+ // =============================================================================
68
+
69
+ describe('RFDBClient — Methods Throw When Not Connected (Locking)', () => {
70
+ let client: InstanceType<typeof RFDBClient>;
71
+
72
+ beforeEach(() => {
73
+ client = new RFDBClient('/tmp/nonexistent.sock');
74
+ });
75
+
76
+ it('ping() throws when not connected', async () => {
77
+ await assert.rejects(
78
+ () => client.ping(),
79
+ { message: 'Not connected to RFDB server' }
80
+ );
81
+ });
82
+
83
+ it('hello() throws when not connected', async () => {
84
+ await assert.rejects(
85
+ () => client.hello(),
86
+ { message: 'Not connected to RFDB server' }
87
+ );
88
+ });
89
+
90
+ it('addNodes() throws when not connected and not batching', async () => {
91
+ await assert.rejects(
92
+ () => client.addNodes([{ id: 'n1', type: 'FUNCTION', name: 'foo', file: 'a.js' }]),
93
+ { message: 'Not connected to RFDB server' }
94
+ );
95
+ });
96
+
97
+ it('addEdges() throws when not connected and not batching', async () => {
98
+ await assert.rejects(
99
+ () => client.addEdges([{ src: 'n1', dst: 'n2', edgeType: 'CALLS' as any, metadata: '{}' }]),
100
+ { message: 'Not connected to RFDB server' }
101
+ );
102
+ });
103
+
104
+ it('getNode() throws when not connected', async () => {
105
+ await assert.rejects(
106
+ () => client.getNode('n1'),
107
+ { message: 'Not connected to RFDB server' }
108
+ );
109
+ });
110
+
111
+ it('nodeExists() throws when not connected', async () => {
112
+ await assert.rejects(
113
+ () => client.nodeExists('n1'),
114
+ { message: 'Not connected to RFDB server' }
115
+ );
116
+ });
117
+
118
+ it('findByType() throws when not connected', async () => {
119
+ await assert.rejects(
120
+ () => client.findByType('FUNCTION' as any),
121
+ { message: 'Not connected to RFDB server' }
122
+ );
123
+ });
124
+
125
+ it('findByAttr() throws when not connected', async () => {
126
+ await assert.rejects(
127
+ () => client.findByAttr({ file: 'test.js' }),
128
+ { message: 'Not connected to RFDB server' }
129
+ );
130
+ });
131
+
132
+ it('neighbors() throws when not connected', async () => {
133
+ await assert.rejects(
134
+ () => client.neighbors('n1'),
135
+ { message: 'Not connected to RFDB server' }
136
+ );
137
+ });
138
+
139
+ it('bfs() throws when not connected', async () => {
140
+ await assert.rejects(
141
+ () => client.bfs(['n1'], 3),
142
+ { message: 'Not connected to RFDB server' }
143
+ );
144
+ });
145
+
146
+ it('dfs() throws when not connected', async () => {
147
+ await assert.rejects(
148
+ () => client.dfs(['n1'], 3),
149
+ { message: 'Not connected to RFDB server' }
150
+ );
151
+ });
152
+
153
+ it('reachability() throws when not connected', async () => {
154
+ await assert.rejects(
155
+ () => client.reachability(['n1'], 3),
156
+ { message: 'Not connected to RFDB server' }
157
+ );
158
+ });
159
+
160
+ it('getOutgoingEdges() throws when not connected', async () => {
161
+ await assert.rejects(
162
+ () => client.getOutgoingEdges('n1'),
163
+ { message: 'Not connected to RFDB server' }
164
+ );
165
+ });
166
+
167
+ it('getIncomingEdges() throws when not connected', async () => {
168
+ await assert.rejects(
169
+ () => client.getIncomingEdges('n1'),
170
+ { message: 'Not connected to RFDB server' }
171
+ );
172
+ });
173
+
174
+ it('nodeCount() throws when not connected', async () => {
175
+ await assert.rejects(
176
+ () => client.nodeCount(),
177
+ { message: 'Not connected to RFDB server' }
178
+ );
179
+ });
180
+
181
+ it('edgeCount() throws when not connected', async () => {
182
+ await assert.rejects(
183
+ () => client.edgeCount(),
184
+ { message: 'Not connected to RFDB server' }
185
+ );
186
+ });
187
+
188
+ it('countNodesByType() throws when not connected', async () => {
189
+ await assert.rejects(
190
+ () => client.countNodesByType(),
191
+ { message: 'Not connected to RFDB server' }
192
+ );
193
+ });
194
+
195
+ it('countEdgesByType() throws when not connected', async () => {
196
+ await assert.rejects(
197
+ () => client.countEdgesByType(),
198
+ { message: 'Not connected to RFDB server' }
199
+ );
200
+ });
201
+
202
+ it('flush() throws when not connected', async () => {
203
+ await assert.rejects(
204
+ () => client.flush(),
205
+ { message: 'Not connected to RFDB server' }
206
+ );
207
+ });
208
+
209
+ it('compact() throws when not connected', async () => {
210
+ await assert.rejects(
211
+ () => client.compact(),
212
+ { message: 'Not connected to RFDB server' }
213
+ );
214
+ });
215
+
216
+ it('clear() throws when not connected', async () => {
217
+ await assert.rejects(
218
+ () => client.clear(),
219
+ { message: 'Not connected to RFDB server' }
220
+ );
221
+ });
222
+
223
+ it('deleteNode() throws when not connected', async () => {
224
+ await assert.rejects(
225
+ () => client.deleteNode('n1'),
226
+ { message: 'Not connected to RFDB server' }
227
+ );
228
+ });
229
+
230
+ it('deleteEdge() throws when not connected', async () => {
231
+ await assert.rejects(
232
+ () => client.deleteEdge('n1', 'n2', 'CALLS' as any),
233
+ { message: 'Not connected to RFDB server' }
234
+ );
235
+ });
236
+
237
+ it('createDatabase() throws when not connected', async () => {
238
+ await assert.rejects(
239
+ () => client.createDatabase('test'),
240
+ { message: 'Not connected to RFDB server' }
241
+ );
242
+ });
243
+
244
+ it('openDatabase() throws when not connected', async () => {
245
+ await assert.rejects(
246
+ () => client.openDatabase('test'),
247
+ { message: 'Not connected to RFDB server' }
248
+ );
249
+ });
250
+
251
+ it('closeDatabase() throws when not connected', async () => {
252
+ await assert.rejects(
253
+ () => client.closeDatabase(),
254
+ { message: 'Not connected to RFDB server' }
255
+ );
256
+ });
257
+
258
+ it('dropDatabase() throws when not connected', async () => {
259
+ await assert.rejects(
260
+ () => client.dropDatabase('test'),
261
+ { message: 'Not connected to RFDB server' }
262
+ );
263
+ });
264
+
265
+ it('listDatabases() throws when not connected', async () => {
266
+ await assert.rejects(
267
+ () => client.listDatabases(),
268
+ { message: 'Not connected to RFDB server' }
269
+ );
270
+ });
271
+
272
+ it('currentDatabase() throws when not connected', async () => {
273
+ await assert.rejects(
274
+ () => client.currentDatabase(),
275
+ { message: 'Not connected to RFDB server' }
276
+ );
277
+ });
278
+
279
+ it('datalogLoadRules() throws when not connected', async () => {
280
+ await assert.rejects(
281
+ () => client.datalogLoadRules('violation(X) :- node(X, "FUNCTION").'),
282
+ { message: 'Not connected to RFDB server' }
283
+ );
284
+ });
285
+
286
+ it('datalogClearRules() throws when not connected', async () => {
287
+ await assert.rejects(
288
+ () => client.datalogClearRules(),
289
+ { message: 'Not connected to RFDB server' }
290
+ );
291
+ });
292
+
293
+ it('datalogQuery() throws when not connected', async () => {
294
+ await assert.rejects(
295
+ () => client.datalogQuery('?- node(X, "FUNCTION").'),
296
+ { message: 'Not connected to RFDB server' }
297
+ );
298
+ });
299
+
300
+ it('checkGuarantee() throws when not connected', async () => {
301
+ await assert.rejects(
302
+ () => client.checkGuarantee('violation(X) :- node(X, "FUNCTION").'),
303
+ { message: 'Not connected to RFDB server' }
304
+ );
305
+ });
306
+
307
+ it('executeDatalog() throws when not connected', async () => {
308
+ await assert.rejects(
309
+ () => client.executeDatalog('violation(X) :- node(X, "FUNCTION").'),
310
+ { message: 'Not connected to RFDB server' }
311
+ );
312
+ });
313
+
314
+ it('updateNodeVersion() throws when not connected', async () => {
315
+ await assert.rejects(
316
+ () => client.updateNodeVersion('n1', 'v2'),
317
+ { message: 'Not connected to RFDB server' }
318
+ );
319
+ });
320
+
321
+ it('declareFields() throws when not connected', async () => {
322
+ await assert.rejects(
323
+ () => client.declareFields([{ name: 'async' }]),
324
+ { message: 'Not connected to RFDB server' }
325
+ );
326
+ });
327
+
328
+ it('isEndpoint() throws when not connected', async () => {
329
+ await assert.rejects(
330
+ () => client.isEndpoint('n1'),
331
+ { message: 'Not connected to RFDB server' }
332
+ );
333
+ });
334
+
335
+ it('getNodeIdentifier() throws when not connected', async () => {
336
+ await assert.rejects(
337
+ () => client.getNodeIdentifier('n1'),
338
+ { message: 'Not connected to RFDB server' }
339
+ );
340
+ });
341
+
342
+ it('diffSnapshots() throws when not connected', async () => {
343
+ await assert.rejects(
344
+ () => client.diffSnapshots(1, 2),
345
+ { message: 'Not connected to RFDB server' }
346
+ );
347
+ });
348
+
349
+ it('tagSnapshot() throws when not connected', async () => {
350
+ await assert.rejects(
351
+ () => client.tagSnapshot(1, { release: 'v1.0' }),
352
+ { message: 'Not connected to RFDB server' }
353
+ );
354
+ });
355
+
356
+ it('findSnapshot() throws when not connected', async () => {
357
+ await assert.rejects(
358
+ () => client.findSnapshot('release', 'v1.0'),
359
+ { message: 'Not connected to RFDB server' }
360
+ );
361
+ });
362
+
363
+ it('listSnapshots() throws when not connected', async () => {
364
+ await assert.rejects(
365
+ () => client.listSnapshots(),
366
+ { message: 'Not connected to RFDB server' }
367
+ );
368
+ });
369
+
370
+ it('rebuildIndexes() throws when not connected', async () => {
371
+ await assert.rejects(
372
+ () => client.rebuildIndexes(),
373
+ { message: 'Not connected to RFDB server' }
374
+ );
375
+ });
376
+ });
377
+
378
+ // =============================================================================
379
+ // Part 3: Batch operations (client-side state only)
380
+ // =============================================================================
381
+
382
+ describe('RFDBClient — Batch Operations (Locking)', () => {
383
+ it('beginBatch enables batching state', () => {
384
+ const client = new RFDBClient('/tmp/test.sock');
385
+ assert.strictEqual(client.isBatching(), false);
386
+ client.beginBatch();
387
+ assert.strictEqual(client.isBatching(), true);
388
+ });
389
+
390
+ it('double beginBatch throws', () => {
391
+ const client = new RFDBClient('/tmp/test.sock');
392
+ client.beginBatch();
393
+ assert.throws(
394
+ () => client.beginBatch(),
395
+ { message: 'Batch already in progress' }
396
+ );
397
+ });
398
+
399
+ it('abortBatch resets batching state', () => {
400
+ const client = new RFDBClient('/tmp/test.sock');
401
+ client.beginBatch();
402
+ client.abortBatch();
403
+ assert.strictEqual(client.isBatching(), false);
404
+ });
405
+
406
+ it('abortBatch when not batching is a no-op (no throw)', () => {
407
+ const client = new RFDBClient('/tmp/test.sock');
408
+ client.abortBatch(); // should not throw
409
+ assert.strictEqual(client.isBatching(), false);
410
+ });
411
+
412
+ it('commitBatch without beginBatch throws', async () => {
413
+ const client = new RFDBClient('/tmp/test.sock');
414
+ await assert.rejects(
415
+ () => client.commitBatch(),
416
+ { message: 'No batch in progress' }
417
+ );
418
+ });
419
+
420
+ it('addNodes during batch returns { ok: true } without sending', async () => {
421
+ const client = new RFDBClient('/tmp/test.sock');
422
+ client.beginBatch();
423
+ const result = await client.addNodes([
424
+ { id: 'n1', type: 'FUNCTION', name: 'foo', file: 'a.js' },
425
+ ]);
426
+ assert.deepStrictEqual(result, { ok: true });
427
+ assert.strictEqual(client.isBatching(), true);
428
+ });
429
+
430
+ it('addEdges during batch returns { ok: true } without sending', async () => {
431
+ const client = new RFDBClient('/tmp/test.sock');
432
+ client.beginBatch();
433
+ const result = await client.addEdges([
434
+ { src: 'n1', dst: 'n2', edgeType: 'CALLS' as any, metadata: '{}' },
435
+ ]);
436
+ assert.deepStrictEqual(result, { ok: true });
437
+ });
438
+
439
+ it('batchNode pushes directly into batch buffer', () => {
440
+ const client = new RFDBClient('/tmp/test.sock');
441
+ client.beginBatch();
442
+ client.batchNode({ id: 'n1', type: 'FUNCTION', name: 'foo', file: 'a.js' });
443
+ // Verify still batching (node was buffered, not sent)
444
+ assert.strictEqual(client.isBatching(), true);
445
+ });
446
+
447
+ it('batchNode throws when not batching', () => {
448
+ const client = new RFDBClient('/tmp/test.sock');
449
+ assert.throws(
450
+ () => client.batchNode({ id: 'n1', type: 'FUNCTION', name: 'foo', file: 'a.js' }),
451
+ { message: 'No batch in progress' }
452
+ );
453
+ });
454
+
455
+ it('batchEdge pushes directly into batch buffer', () => {
456
+ const client = new RFDBClient('/tmp/test.sock');
457
+ client.beginBatch();
458
+ client.batchEdge({ src: 'n1', dst: 'n2', edgeType: 'CALLS', metadata: '{}' });
459
+ assert.strictEqual(client.isBatching(), true);
460
+ });
461
+
462
+ it('batchEdge throws when not batching', () => {
463
+ const client = new RFDBClient('/tmp/test.sock');
464
+ assert.throws(
465
+ () => client.batchEdge({ src: 'n1', dst: 'n2', edgeType: 'CALLS', metadata: '{}' }),
466
+ { message: 'No batch in progress' }
467
+ );
468
+ });
469
+ });
470
+
471
+ // =============================================================================
472
+ // Part 4: BatchHandle (isolated batch)
473
+ // =============================================================================
474
+
475
+ describe('RFDBClient — BatchHandle (Locking)', () => {
476
+ it('createBatch returns a BatchHandle', () => {
477
+ const client = new RFDBClient('/tmp/test.sock');
478
+ const batch = client.createBatch();
479
+ assert.ok(batch instanceof BatchHandle);
480
+ });
481
+
482
+ it('BatchHandle addNode and addEdge buffer independently', () => {
483
+ const client = new RFDBClient('/tmp/test.sock');
484
+ const batch = client.createBatch();
485
+ batch.addNode(
486
+ { id: 'n1', nodeType: 'FUNCTION' as any, name: 'foo', file: 'a.js', exported: false, metadata: '{}' },
487
+ 'a.js'
488
+ );
489
+ batch.addEdge({ src: 'n1', dst: 'n2', edgeType: 'CALLS' as any, metadata: '{}' });
490
+ // BatchHandle does not affect client batching state
491
+ assert.strictEqual(client.isBatching(), false);
492
+ });
493
+
494
+ it('BatchHandle.abort clears buffers', () => {
495
+ const client = new RFDBClient('/tmp/test.sock');
496
+ const batch = client.createBatch();
497
+ batch.addNode(
498
+ { id: 'n1', nodeType: 'FUNCTION' as any, name: 'foo', file: 'a.js', exported: false, metadata: '{}' },
499
+ );
500
+ batch.abort();
501
+ // After abort, commit should send empty batch (no nodes/edges)
502
+ // We can't fully test this without connection, but abort should not throw
503
+ assert.strictEqual(client.isBatching(), false);
504
+ });
505
+ });
506
+
507
+ // =============================================================================
508
+ // Part 5: addNodes wire format (metadata merging)
509
+ // =============================================================================
510
+
511
+ describe('RFDBClient — addNodes Wire Format (Locking)', () => {
512
+ /**
513
+ * Lock: extra fields beyond id/type/name/file/exported/metadata are
514
+ * merged into the metadata JSON string. This behavior was added in REG-274.
515
+ */
516
+ it('addNodes during batch merges extra fields into metadata', async () => {
517
+ const client = new RFDBClient('/tmp/test.sock');
518
+ client.beginBatch();
519
+
520
+ await client.addNodes([{
521
+ id: 'n1',
522
+ type: 'SCOPE',
523
+ name: 'if_branch',
524
+ file: 'test.js',
525
+ constraints: [{ variable: 'x', operator: '!==', value: 'null' }],
526
+ scopeType: 'if_statement',
527
+ } as any]);
528
+
529
+ // The node was buffered. We verify the format by checking that
530
+ // abortBatch doesn't crash (the node was successfully transformed).
531
+ // Full format verification requires commitBatch with a connected server.
532
+ client.abortBatch();
533
+ assert.strictEqual(client.isBatching(), false);
534
+ });
535
+
536
+ it('addNodes supports node_type, nodeType, and type aliases', async () => {
537
+ const client = new RFDBClient('/tmp/test.sock');
538
+
539
+ // Verify no crash with various type-field aliases
540
+ client.beginBatch();
541
+ await client.addNodes([
542
+ { id: 'n1', type: 'FUNCTION', name: 'foo', file: 'a.js' },
543
+ { id: 'n2', node_type: 'CLASS', name: 'Bar', file: 'b.js' } as any,
544
+ { id: 'n3', nodeType: 'MODULE', name: 'mod', file: 'c.js' } as any,
545
+ ]);
546
+ client.abortBatch();
547
+ });
548
+ });
549
+
550
+ // =============================================================================
551
+ // Part 6: addEdges wire format
552
+ // =============================================================================
553
+
554
+ describe('RFDBClient — addEdges Wire Format (Locking)', () => {
555
+ it('addEdges during batch merges extra fields into metadata', async () => {
556
+ const client = new RFDBClient('/tmp/test.sock');
557
+ client.beginBatch();
558
+
559
+ await client.addEdges([{
560
+ src: 'n1',
561
+ dst: 'n2',
562
+ edgeType: 'CALLS' as any,
563
+ callSite: 'line:42',
564
+ confidence: 0.95,
565
+ } as any]);
566
+
567
+ client.abortBatch();
568
+ });
569
+
570
+ it('addEdges supports type, edge_type, edgeType aliases', async () => {
571
+ const client = new RFDBClient('/tmp/test.sock');
572
+ client.beginBatch();
573
+
574
+ await client.addEdges([
575
+ { src: 'n1', dst: 'n2', type: 'CALLS', metadata: '{}' } as any,
576
+ { src: 'n2', dst: 'n3', edge_type: 'CONTAINS', metadata: '{}' } as any,
577
+ { src: 'n3', dst: 'n4', edgeType: 'IMPORTS_FROM' as any, metadata: '{}' },
578
+ ]);
579
+
580
+ client.abortBatch();
581
+ });
582
+ });
583
+
584
+ // =============================================================================
585
+ // Part 7: _handleData length-prefix framing
586
+ // =============================================================================
587
+
588
+ describe('RFDBClient — _handleData Framing (Locking)', () => {
589
+ /**
590
+ * To test _handleData, we need to simulate a connected client and inject
591
+ * data chunks. We do this by accessing the private _handleData method
592
+ * via a subclass trick.
593
+ */
594
+
595
+ it('should parse a single complete message', async () => {
596
+ const client = new RFDBClient('/tmp/test.sock');
597
+ // Simulate connected state
598
+ (client as any).connected = true;
599
+ (client as any).socket = { write: () => {}, removeListener: () => {}, once: () => {} };
600
+
601
+ // Set up a pending request that the response will resolve
602
+ const promise = new Promise<any>((resolve, reject) => {
603
+ (client as any).pending.set(0, { resolve, reject });
604
+ });
605
+
606
+ // Build a framed message: 4-byte BE length + msgpack payload
607
+ const response = { requestId: 'r0', pong: true, version: '1.0.0' };
608
+ const msgBytes = encode(response);
609
+ const header = Buffer.alloc(4);
610
+ header.writeUInt32BE(msgBytes.length);
611
+ const frame = Buffer.concat([header, Buffer.from(msgBytes)]);
612
+
613
+ // Inject the data
614
+ (client as any)._handleData(frame);
615
+
616
+ const result = await promise;
617
+ assert.strictEqual(result.pong, true);
618
+ assert.strictEqual(result.version, '1.0.0');
619
+ });
620
+
621
+ it('should handle split delivery (partial frames)', async () => {
622
+ const client = new RFDBClient('/tmp/test.sock');
623
+ (client as any).connected = true;
624
+ (client as any).socket = { write: () => {}, removeListener: () => {}, once: () => {} };
625
+
626
+ const promise = new Promise<any>((resolve, reject) => {
627
+ (client as any).pending.set(0, { resolve, reject });
628
+ });
629
+
630
+ const response = { requestId: 'r0', ok: true };
631
+ const msgBytes = encode(response);
632
+ const header = Buffer.alloc(4);
633
+ header.writeUInt32BE(msgBytes.length);
634
+ const frame = Buffer.concat([header, Buffer.from(msgBytes)]);
635
+
636
+ // Split the frame in half and deliver in two chunks
637
+ const mid = Math.floor(frame.length / 2);
638
+ (client as any)._handleData(frame.subarray(0, mid));
639
+ (client as any)._handleData(frame.subarray(mid));
640
+
641
+ const result = await promise;
642
+ assert.strictEqual(result.ok, true);
643
+ });
644
+
645
+ it('should handle multiple messages in a single chunk', async () => {
646
+ const client = new RFDBClient('/tmp/test.sock');
647
+ (client as any).connected = true;
648
+ (client as any).socket = { write: () => {}, removeListener: () => {}, once: () => {} };
649
+
650
+ const promise0 = new Promise<any>((resolve, reject) => {
651
+ (client as any).pending.set(0, { resolve, reject });
652
+ });
653
+ const promise1 = new Promise<any>((resolve, reject) => {
654
+ (client as any).pending.set(1, { resolve, reject });
655
+ });
656
+
657
+ // Build two framed messages
658
+ const buildFrame = (resp: any) => {
659
+ const msgBytes = encode(resp);
660
+ const header = Buffer.alloc(4);
661
+ header.writeUInt32BE(msgBytes.length);
662
+ return Buffer.concat([header, Buffer.from(msgBytes)]);
663
+ };
664
+
665
+ const frame0 = buildFrame({ requestId: 'r0', count: 5 });
666
+ const frame1 = buildFrame({ requestId: 'r1', count: 10 });
667
+ const combined = Buffer.concat([frame0, frame1]);
668
+
669
+ // Deliver both in a single chunk
670
+ (client as any)._handleData(combined);
671
+
672
+ const result0 = await promise0;
673
+ const result1 = await promise1;
674
+ assert.strictEqual(result0.count, 5);
675
+ assert.strictEqual(result1.count, 10);
676
+ });
677
+
678
+ it('should reject pending request when response has error field', async () => {
679
+ const client = new RFDBClient('/tmp/test.sock');
680
+ (client as any).connected = true;
681
+ (client as any).socket = { write: () => {}, removeListener: () => {}, once: () => {} };
682
+
683
+ const promise = new Promise<any>((resolve, reject) => {
684
+ (client as any).pending.set(0, { resolve, reject });
685
+ });
686
+
687
+ const response = { requestId: 'r0', error: 'No database selected' };
688
+ const msgBytes = encode(response);
689
+ const header = Buffer.alloc(4);
690
+ header.writeUInt32BE(msgBytes.length);
691
+ const frame = Buffer.concat([header, Buffer.from(msgBytes)]);
692
+
693
+ (client as any)._handleData(frame);
694
+
695
+ await assert.rejects(promise, { message: 'No database selected' });
696
+ });
697
+ });
698
+
699
+ // =============================================================================
700
+ // Part 8: close() and shutdown() behavior
701
+ // =============================================================================
702
+
703
+ describe('RFDBClient — close() and shutdown() (Locking)', () => {
704
+ it('close() when not connected does not throw', async () => {
705
+ const client = new RFDBClient('/tmp/test.sock');
706
+ await client.close(); // should not throw
707
+ assert.strictEqual(client.connected, false);
708
+ });
709
+
710
+ it('close() sets connected to false', async () => {
711
+ const client = new RFDBClient('/tmp/test.sock');
712
+ // Simulate a connected state
713
+ (client as any).connected = true;
714
+ (client as any).socket = { destroy: () => {} };
715
+
716
+ await client.close();
717
+ assert.strictEqual(client.connected, false);
718
+ });
719
+
720
+ it('unref() when not connected does not throw', () => {
721
+ const client = new RFDBClient('/tmp/test.sock');
722
+ client.unref(); // should not throw
723
+ });
724
+ });
725
+
726
+ // =============================================================================
727
+ // Part 9: _parseRequestId behavior
728
+ // =============================================================================
729
+
730
+ describe('RFDBClient — _parseRequestId (Locking)', () => {
731
+ /**
732
+ * We access the private method through the class prototype for testing.
733
+ */
734
+ it('should parse "r0" to 0', () => {
735
+ const client = new RFDBClient('/tmp/test.sock');
736
+ const result = (client as any)._parseRequestId('r0');
737
+ assert.strictEqual(result, 0);
738
+ });
739
+
740
+ it('should parse "r123" to 123', () => {
741
+ const client = new RFDBClient('/tmp/test.sock');
742
+ const result = (client as any)._parseRequestId('r123');
743
+ assert.strictEqual(result, 123);
744
+ });
745
+
746
+ it('should return null for string not starting with "r"', () => {
747
+ const client = new RFDBClient('/tmp/test.sock');
748
+ const result = (client as any)._parseRequestId('x42');
749
+ assert.strictEqual(result, null);
750
+ });
751
+
752
+ it('should return null for empty string', () => {
753
+ const client = new RFDBClient('/tmp/test.sock');
754
+ const result = (client as any)._parseRequestId('');
755
+ assert.strictEqual(result, null);
756
+ });
757
+
758
+ it('should return null for "r" without number', () => {
759
+ const client = new RFDBClient('/tmp/test.sock');
760
+ const result = (client as any)._parseRequestId('r');
761
+ assert.strictEqual(result, null);
762
+ });
763
+
764
+ it('should return null for "rNaN"', () => {
765
+ const client = new RFDBClient('/tmp/test.sock');
766
+ const result = (client as any)._parseRequestId('rNaN');
767
+ assert.strictEqual(result, null);
768
+ });
769
+ });
770
+
771
+ // =============================================================================
772
+ // Part 10: Snapshot ref resolution (locking _resolveSnapshotRef)
773
+ // =============================================================================
774
+
775
+ describe('RFDBClient — _resolveSnapshotRef (Locking)', () => {
776
+ it('should resolve number ref to { version }', () => {
777
+ const client = new RFDBClient('/tmp/test.sock');
778
+ const result = (client as any)._resolveSnapshotRef(42);
779
+ assert.deepStrictEqual(result, { version: 42 });
780
+ });
781
+
782
+ it('should resolve tag ref to { tagKey, tagValue }', () => {
783
+ const client = new RFDBClient('/tmp/test.sock');
784
+ const result = (client as any)._resolveSnapshotRef({ tag: 'release', value: 'v1.0' });
785
+ assert.deepStrictEqual(result, { tagKey: 'release', tagValue: 'v1.0' });
786
+ });
787
+
788
+ it('should handle version 0', () => {
789
+ const client = new RFDBClient('/tmp/test.sock');
790
+ const result = (client as any)._resolveSnapshotRef(0);
791
+ assert.deepStrictEqual(result, { version: 0 });
792
+ });
793
+ });
794
+
795
+ // =============================================================================
796
+ // Part 11: queryNodes async generator behavior (non-streaming)
797
+ // =============================================================================
798
+
799
+ describe('RFDBClient — queryNodes non-streaming fallback (Locking)', () => {
800
+ it('queryNodesStream delegates to queryNodes when streaming not supported', async () => {
801
+ const client = new RFDBClient('/tmp/test.sock');
802
+ // supportsStreaming is false by default
803
+ assert.strictEqual(client.supportsStreaming, false);
804
+
805
+ // queryNodesStream should throw "Not connected" (same path as queryNodes)
806
+ const gen = client.queryNodesStream({ nodeType: 'FUNCTION' });
807
+ await assert.rejects(
808
+ () => gen.next(),
809
+ { message: 'Not connected to RFDB server' }
810
+ );
811
+ });
812
+ });
813
+
814
+ // =============================================================================
815
+ // Part 12: getAllNodes delegates to queryNodes
816
+ // =============================================================================
817
+
818
+ describe('RFDBClient — getAllNodes (Locking)', () => {
819
+ it('getAllNodes throws when not connected', async () => {
820
+ const client = new RFDBClient('/tmp/test.sock');
821
+ await assert.rejects(
822
+ () => client.getAllNodes(),
823
+ { message: 'Not connected to RFDB server' }
824
+ );
825
+ });
826
+ });
827
+
828
+ // =============================================================================
829
+ // Part 13: getAllEdges behavior
830
+ // =============================================================================
831
+
832
+ describe('RFDBClient — getAllEdges (Locking)', () => {
833
+ it('getAllEdges throws when not connected', async () => {
834
+ const client = new RFDBClient('/tmp/test.sock');
835
+ await assert.rejects(
836
+ () => client.getAllEdges(),
837
+ { message: 'Not connected to RFDB server' }
838
+ );
839
+ });
840
+ });
841
+
842
+ // =============================================================================
843
+ // Part 14: Edge metadata parsing on getOutgoingEdges/getIncomingEdges
844
+ // =============================================================================
845
+
846
+ describe('RFDBClient — Edge Metadata Parsing (Locking)', () => {
847
+ /**
848
+ * Lock: getOutgoingEdges and getIncomingEdges parse metadata JSON and
849
+ * spread it onto the edge object. This is client-side convenience logic.
850
+ */
851
+
852
+ it('getOutgoingEdges parses and spreads metadata', async () => {
853
+ const client = new RFDBClient('/tmp/test.sock');
854
+ (client as any).connected = true;
855
+ (client as any).socket = { write: () => {}, removeListener: () => {}, once: () => {} };
856
+
857
+ // Intercept _send by resolving the pending request with a mock response
858
+ const originalReqId = (client as any).reqId;
859
+ const callPromise = client.getOutgoingEdges('n1');
860
+
861
+ // Simulate response
862
+ const response = {
863
+ requestId: `r${originalReqId}`,
864
+ edges: [
865
+ { src: 'n1', dst: 'n2', edgeType: 'CALLS', metadata: '{"callSite":"line:42","confidence":0.9}' },
866
+ ],
867
+ };
868
+ const msgBytes = encode(response);
869
+ const header = Buffer.alloc(4);
870
+ header.writeUInt32BE(msgBytes.length);
871
+ (client as any)._handleData(Buffer.concat([header, Buffer.from(msgBytes)]));
872
+
873
+ const result = await callPromise;
874
+ assert.strictEqual(result.length, 1);
875
+ assert.strictEqual(result[0].src, 'n1');
876
+ assert.strictEqual(result[0].dst, 'n2');
877
+ assert.strictEqual((result[0] as any).callSite, 'line:42');
878
+ assert.strictEqual((result[0] as any).confidence, 0.9);
879
+ // type alias is set from edgeType
880
+ assert.strictEqual((result[0] as any).type, 'CALLS');
881
+ });
882
+ });
883
+
884
+ // =============================================================================
885
+ // Part 15: connect() returns immediately if already connected
886
+ // =============================================================================
887
+
888
+ describe('RFDBClient — connect() idempotency (Locking)', () => {
889
+ it('connect() returns immediately if already connected', async () => {
890
+ const client = new RFDBClient('/tmp/test.sock');
891
+ (client as any).connected = true;
892
+
893
+ // Should return immediately without attempting socket connection
894
+ await client.connect();
895
+ assert.strictEqual(client.connected, true);
896
+ });
897
+ });