@grafema/rfdb-client 0.2.5-beta → 0.2.7
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/README.md +25 -14
- package/dist/client.d.ts +113 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +364 -14
- package/dist/client.js.map +1 -1
- package/dist/client.test.js +212 -0
- package/dist/client.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/stream-queue.d.ts +30 -0
- package/dist/stream-queue.d.ts.map +1 -0
- package/dist/stream-queue.js +74 -0
- package/dist/stream-queue.js.map +1 -0
- package/package.json +2 -2
- package/ts/client.test.ts +635 -0
- package/ts/client.ts +420 -15
- package/ts/index.ts +10 -0
- package/ts/protocol.ts +11 -0
- package/ts/stream-queue.test.ts +114 -0
- package/ts/stream-queue.ts +83 -0
package/ts/client.test.ts
CHANGED
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
|
|
12
12
|
import { describe, it, beforeEach, mock } from 'node:test';
|
|
13
13
|
import assert from 'node:assert';
|
|
14
|
+
import { RFDBClient } from '../dist/client.js';
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
SnapshotRef,
|
|
18
|
+
SnapshotStats,
|
|
19
|
+
SegmentInfo,
|
|
20
|
+
SnapshotDiff,
|
|
21
|
+
SnapshotInfo,
|
|
22
|
+
} from '@grafema/types';
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
25
|
* Mock RFDBClient that captures what would be sent to the server
|
|
@@ -295,3 +304,629 @@ describe('RFDBClient.addNodes() Metadata Preservation', () => {
|
|
|
295
304
|
assert.strictEqual(innerMeta.parentScope, 'SCOPE:file.js:5');
|
|
296
305
|
});
|
|
297
306
|
});
|
|
307
|
+
|
|
308
|
+
// ============================================================================
|
|
309
|
+
// Snapshot API Tests
|
|
310
|
+
// ============================================================================
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Extracted helper that mirrors RFDBClient._resolveSnapshotRef().
|
|
314
|
+
* Tested directly since the actual method is private and requires socket.
|
|
315
|
+
*/
|
|
316
|
+
function resolveSnapshotRef(ref: SnapshotRef): Record<string, unknown> {
|
|
317
|
+
if (typeof ref === 'number') return { version: ref };
|
|
318
|
+
return { tagKey: ref.tag, tagValue: ref.value };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
describe('Snapshot API — resolveSnapshotRef', () => {
|
|
322
|
+
/**
|
|
323
|
+
* WHY: When referencing a snapshot by version number, the wire format
|
|
324
|
+
* must send { version: N } so the server can look up the manifest.
|
|
325
|
+
*/
|
|
326
|
+
it('should resolve number ref to { version }', () => {
|
|
327
|
+
const result = resolveSnapshotRef(42);
|
|
328
|
+
assert.deepStrictEqual(result, { version: 42 });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* WHY: When referencing a snapshot by tag, the wire format must send
|
|
333
|
+
* { tagKey, tagValue } so the server can do a tag lookup.
|
|
334
|
+
*/
|
|
335
|
+
it('should resolve tag ref to { tagKey, tagValue }', () => {
|
|
336
|
+
const result = resolveSnapshotRef({ tag: 'release', value: 'v1.0.0' });
|
|
337
|
+
assert.deepStrictEqual(result, { tagKey: 'release', tagValue: 'v1.0.0' });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* WHY: Version 0 is a valid snapshot (initial empty state).
|
|
342
|
+
* Must not be treated as falsy.
|
|
343
|
+
*/
|
|
344
|
+
it('should handle version 0 correctly', () => {
|
|
345
|
+
const result = resolveSnapshotRef(0);
|
|
346
|
+
assert.deepStrictEqual(result, { version: 0 });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* WHY: Discriminating between number and object is critical for
|
|
351
|
+
* the wire format. typeof === 'number' is the correct check.
|
|
352
|
+
*/
|
|
353
|
+
it('should discriminate SnapshotRef union correctly', () => {
|
|
354
|
+
const numRef: SnapshotRef = 5;
|
|
355
|
+
const tagRef: SnapshotRef = { tag: 'env', value: 'staging' };
|
|
356
|
+
|
|
357
|
+
assert.strictEqual(typeof numRef, 'number');
|
|
358
|
+
assert.strictEqual(typeof tagRef, 'object');
|
|
359
|
+
|
|
360
|
+
// Both should produce distinct wire formats
|
|
361
|
+
const numResult = resolveSnapshotRef(numRef);
|
|
362
|
+
const tagResult = resolveSnapshotRef(tagRef);
|
|
363
|
+
|
|
364
|
+
assert.ok('version' in numResult);
|
|
365
|
+
assert.ok(!('tagKey' in numResult));
|
|
366
|
+
assert.ok('tagKey' in tagResult);
|
|
367
|
+
assert.ok(!('version' in tagResult));
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('Snapshot API — Type Contracts', () => {
|
|
372
|
+
/**
|
|
373
|
+
* WHY: SnapshotStats must match Rust ManifestStats wire format.
|
|
374
|
+
* Fields: total_nodes -> totalNodes, total_edges -> totalEdges, etc.
|
|
375
|
+
*/
|
|
376
|
+
it('SnapshotStats should have correct shape', () => {
|
|
377
|
+
const stats: SnapshotStats = {
|
|
378
|
+
totalNodes: 1500,
|
|
379
|
+
totalEdges: 3200,
|
|
380
|
+
nodeSegmentCount: 4,
|
|
381
|
+
edgeSegmentCount: 2,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
assert.strictEqual(stats.totalNodes, 1500);
|
|
385
|
+
assert.strictEqual(stats.totalEdges, 3200);
|
|
386
|
+
assert.strictEqual(stats.nodeSegmentCount, 4);
|
|
387
|
+
assert.strictEqual(stats.edgeSegmentCount, 2);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* WHY: SegmentInfo must expose the subset of Rust SegmentDescriptor
|
|
392
|
+
* that's useful for client-side diff analysis. HashSet -> string[].
|
|
393
|
+
*/
|
|
394
|
+
it('SegmentInfo should have correct shape', () => {
|
|
395
|
+
const segment: SegmentInfo = {
|
|
396
|
+
segmentId: 7,
|
|
397
|
+
recordCount: 500,
|
|
398
|
+
byteSize: 102400,
|
|
399
|
+
nodeTypes: ['FUNCTION', 'CLASS'],
|
|
400
|
+
filePaths: ['src/app.js', 'src/utils.js'],
|
|
401
|
+
edgeTypes: [],
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
assert.strictEqual(segment.segmentId, 7);
|
|
405
|
+
assert.strictEqual(segment.recordCount, 500);
|
|
406
|
+
assert.strictEqual(segment.byteSize, 102400);
|
|
407
|
+
assert.deepStrictEqual(segment.nodeTypes, ['FUNCTION', 'CLASS']);
|
|
408
|
+
assert.deepStrictEqual(segment.filePaths, ['src/app.js', 'src/utils.js']);
|
|
409
|
+
assert.deepStrictEqual(segment.edgeTypes, []);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* WHY: SnapshotDiff is the primary return type of diffSnapshots().
|
|
414
|
+
* Must carry from/to versions, segment lists, and stats for both.
|
|
415
|
+
*/
|
|
416
|
+
it('SnapshotDiff should have correct shape', () => {
|
|
417
|
+
const diff: SnapshotDiff = {
|
|
418
|
+
fromVersion: 1,
|
|
419
|
+
toVersion: 3,
|
|
420
|
+
addedNodeSegments: [
|
|
421
|
+
{ segmentId: 10, recordCount: 200, byteSize: 40960, nodeTypes: ['MODULE'], filePaths: ['new.js'], edgeTypes: [] },
|
|
422
|
+
],
|
|
423
|
+
removedNodeSegments: [],
|
|
424
|
+
addedEdgeSegments: [
|
|
425
|
+
{ segmentId: 11, recordCount: 50, byteSize: 8192, nodeTypes: [], filePaths: [], edgeTypes: ['CALLS'] },
|
|
426
|
+
],
|
|
427
|
+
removedEdgeSegments: [
|
|
428
|
+
{ segmentId: 5, recordCount: 30, byteSize: 4096, nodeTypes: [], filePaths: [], edgeTypes: ['IMPORTS'] },
|
|
429
|
+
],
|
|
430
|
+
statsFrom: { totalNodes: 1000, totalEdges: 2000, nodeSegmentCount: 3, edgeSegmentCount: 2 },
|
|
431
|
+
statsTo: { totalNodes: 1200, totalEdges: 2020, nodeSegmentCount: 4, edgeSegmentCount: 2 },
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
assert.strictEqual(diff.fromVersion, 1);
|
|
435
|
+
assert.strictEqual(diff.toVersion, 3);
|
|
436
|
+
assert.strictEqual(diff.addedNodeSegments.length, 1);
|
|
437
|
+
assert.strictEqual(diff.removedNodeSegments.length, 0);
|
|
438
|
+
assert.strictEqual(diff.addedEdgeSegments.length, 1);
|
|
439
|
+
assert.strictEqual(diff.removedEdgeSegments.length, 1);
|
|
440
|
+
assert.strictEqual(diff.statsFrom.totalNodes, 1000);
|
|
441
|
+
assert.strictEqual(diff.statsTo.totalNodes, 1200);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* WHY: SnapshotInfo is the return type of listSnapshots().
|
|
446
|
+
* Must carry version, timestamp, tags, and stats.
|
|
447
|
+
* createdAt is Unix epoch seconds (not milliseconds).
|
|
448
|
+
*/
|
|
449
|
+
it('SnapshotInfo should have correct shape', () => {
|
|
450
|
+
const info: SnapshotInfo = {
|
|
451
|
+
version: 5,
|
|
452
|
+
createdAt: 1707900000,
|
|
453
|
+
tags: { release: 'v1.0.0', env: 'production' },
|
|
454
|
+
stats: { totalNodes: 500, totalEdges: 1200, nodeSegmentCount: 2, edgeSegmentCount: 1 },
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
assert.strictEqual(info.version, 5);
|
|
458
|
+
assert.strictEqual(info.createdAt, 1707900000);
|
|
459
|
+
assert.deepStrictEqual(info.tags, { release: 'v1.0.0', env: 'production' });
|
|
460
|
+
assert.strictEqual(info.stats.totalNodes, 500);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* WHY: SnapshotInfo with empty tags should be valid — not all snapshots
|
|
465
|
+
* are tagged. The tags field should be an empty object, not undefined.
|
|
466
|
+
*/
|
|
467
|
+
it('SnapshotInfo should allow empty tags', () => {
|
|
468
|
+
const info: SnapshotInfo = {
|
|
469
|
+
version: 1,
|
|
470
|
+
createdAt: 1707800000,
|
|
471
|
+
tags: {},
|
|
472
|
+
stats: { totalNodes: 0, totalEdges: 0, nodeSegmentCount: 0, edgeSegmentCount: 0 },
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
assert.deepStrictEqual(info.tags, {});
|
|
476
|
+
assert.strictEqual(info.stats.totalNodes, 0);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe('Snapshot API — Wire Format', () => {
|
|
481
|
+
/**
|
|
482
|
+
* WHY: tagSnapshot sends version + tags to the server.
|
|
483
|
+
* The wire format must include both fields at the top level of the payload.
|
|
484
|
+
*/
|
|
485
|
+
it('tagSnapshot payload should have version and tags', () => {
|
|
486
|
+
const version = 3;
|
|
487
|
+
const tags = { release: 'v2.0', branch: 'main' };
|
|
488
|
+
|
|
489
|
+
// Simulate the payload that tagSnapshot() would send
|
|
490
|
+
const payload = { version, tags };
|
|
491
|
+
|
|
492
|
+
assert.strictEqual(payload.version, 3);
|
|
493
|
+
assert.deepStrictEqual(payload.tags, { release: 'v2.0', branch: 'main' });
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* WHY: findSnapshot sends tagKey + tagValue.
|
|
498
|
+
* The response contains version (number) or null if not found.
|
|
499
|
+
*/
|
|
500
|
+
it('findSnapshot wire format — found', () => {
|
|
501
|
+
const response = { version: 7 };
|
|
502
|
+
assert.strictEqual(response.version, 7);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('findSnapshot wire format — not found', () => {
|
|
506
|
+
const response = { version: null };
|
|
507
|
+
assert.strictEqual(response.version, null);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* WHY: listSnapshots with filter sends filterTag in payload.
|
|
512
|
+
* Without filter, the payload should be empty.
|
|
513
|
+
*/
|
|
514
|
+
it('listSnapshots payload with filter', () => {
|
|
515
|
+
const filterTag = 'release';
|
|
516
|
+
const payload: Record<string, unknown> = {};
|
|
517
|
+
if (filterTag !== undefined) payload.filterTag = filterTag;
|
|
518
|
+
|
|
519
|
+
assert.strictEqual(payload.filterTag, 'release');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('listSnapshots payload without filter', () => {
|
|
523
|
+
const filterTag = undefined;
|
|
524
|
+
const payload: Record<string, unknown> = {};
|
|
525
|
+
if (filterTag !== undefined) payload.filterTag = filterTag;
|
|
526
|
+
|
|
527
|
+
assert.strictEqual(Object.keys(payload).length, 0);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* WHY: diffSnapshots sends from + to as resolved snapshot refs.
|
|
532
|
+
* Must handle mixed refs (number + tag) correctly.
|
|
533
|
+
*/
|
|
534
|
+
it('diffSnapshots with mixed refs', () => {
|
|
535
|
+
const from: SnapshotRef = 1;
|
|
536
|
+
const to: SnapshotRef = { tag: 'release', value: 'v2.0' };
|
|
537
|
+
|
|
538
|
+
const payload = {
|
|
539
|
+
from: resolveSnapshotRef(from),
|
|
540
|
+
to: resolveSnapshotRef(to),
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
assert.deepStrictEqual(payload.from, { version: 1 });
|
|
544
|
+
assert.deepStrictEqual(payload.to, { tagKey: 'release', tagValue: 'v2.0' });
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// ===========================================================================
|
|
549
|
+
// Batch Operations Unit Tests (RFD-9)
|
|
550
|
+
// ===========================================================================
|
|
551
|
+
|
|
552
|
+
describe('RFDBClient Batch Operations', () => {
|
|
553
|
+
/**
|
|
554
|
+
* We test batch state management by creating a real RFDBClient instance
|
|
555
|
+
* (not connected) and exercising the batch methods. Since batch state is
|
|
556
|
+
* purely client-side, we don't need a server connection for these tests.
|
|
557
|
+
*/
|
|
558
|
+
|
|
559
|
+
it('beginBatch sets batching state', () => {
|
|
560
|
+
// RFDBClient constructor doesn't require connection
|
|
561
|
+
const client = new RFDBClient('/tmp/test-nonexistent.sock');
|
|
562
|
+
|
|
563
|
+
assert.strictEqual(client.isBatching(), false, 'should not be batching initially');
|
|
564
|
+
client.beginBatch();
|
|
565
|
+
assert.strictEqual(client.isBatching(), true, 'should be batching after beginBatch');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('double beginBatch throws', () => {
|
|
569
|
+
const client = new RFDBClient('/tmp/test-nonexistent.sock');
|
|
570
|
+
|
|
571
|
+
client.beginBatch();
|
|
572
|
+
assert.throws(
|
|
573
|
+
() => client.beginBatch(),
|
|
574
|
+
{ message: 'Batch already in progress' },
|
|
575
|
+
'should throw on double beginBatch',
|
|
576
|
+
);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('commitBatch without beginBatch throws', async () => {
|
|
580
|
+
const client = new RFDBClient('/tmp/test-nonexistent.sock');
|
|
581
|
+
|
|
582
|
+
await assert.rejects(
|
|
583
|
+
() => client.commitBatch(),
|
|
584
|
+
{ message: 'No batch in progress' },
|
|
585
|
+
'should throw on commitBatch without beginBatch',
|
|
586
|
+
);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('abortBatch clears batching state', () => {
|
|
590
|
+
const client = new RFDBClient('/tmp/test-nonexistent.sock');
|
|
591
|
+
|
|
592
|
+
client.beginBatch();
|
|
593
|
+
assert.strictEqual(client.isBatching(), true);
|
|
594
|
+
client.abortBatch();
|
|
595
|
+
assert.strictEqual(client.isBatching(), false, 'should not be batching after abort');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('abortBatch when not batching is a no-op', () => {
|
|
599
|
+
const client = new RFDBClient('/tmp/test-nonexistent.sock');
|
|
600
|
+
|
|
601
|
+
// Should not throw
|
|
602
|
+
client.abortBatch();
|
|
603
|
+
assert.strictEqual(client.isBatching(), false);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('addNodes during batch buffers locally and returns ok', async () => {
|
|
607
|
+
const client = new RFDBClient('/tmp/test-nonexistent.sock');
|
|
608
|
+
// Not connected — _send would fail, but buffering skips _send
|
|
609
|
+
|
|
610
|
+
client.beginBatch();
|
|
611
|
+
const result = await client.addNodes([
|
|
612
|
+
{ id: 'n1', type: 'FUNCTION', name: 'foo', file: 'a.js' },
|
|
613
|
+
{ id: 'n2', type: 'FUNCTION', name: 'bar', file: 'b.js' },
|
|
614
|
+
]);
|
|
615
|
+
|
|
616
|
+
assert.deepStrictEqual(result, { ok: true }, 'should return ok without sending');
|
|
617
|
+
assert.strictEqual(client.isBatching(), true, 'should still be batching');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('addEdges during batch buffers locally and returns ok', async () => {
|
|
621
|
+
const client = new RFDBClient('/tmp/test-nonexistent.sock');
|
|
622
|
+
|
|
623
|
+
client.beginBatch();
|
|
624
|
+
const result = await client.addEdges([
|
|
625
|
+
{ src: 'n1', dst: 'n2', edgeType: 'CALLS', metadata: '{}' },
|
|
626
|
+
]);
|
|
627
|
+
|
|
628
|
+
assert.deepStrictEqual(result, { ok: true }, 'should return ok without sending');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('addNodes without batch still requires connection (legacy behavior)', async () => {
|
|
632
|
+
const client = new RFDBClient('/tmp/test-nonexistent.sock');
|
|
633
|
+
// Not connected, not batching — should throw "Not connected"
|
|
634
|
+
|
|
635
|
+
await assert.rejects(
|
|
636
|
+
() => client.addNodes([{ id: 'n1', type: 'FUNCTION', name: 'foo', file: 'a.js' }]),
|
|
637
|
+
{ message: 'Not connected to RFDB server' },
|
|
638
|
+
'should require connection when not batching',
|
|
639
|
+
);
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ===========================================================================
|
|
644
|
+
// Protocol v3 — Semantic ID Wire Format Tests (RFD-12)
|
|
645
|
+
// ===========================================================================
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Simulate RFDBServerBackend.addNodes() wire format for v3 protocol.
|
|
649
|
+
* In v3: semanticId is set on WireNode, originalId is NOT injected into metadata.
|
|
650
|
+
*/
|
|
651
|
+
function mapAddNodesV3(node: Record<string, unknown>): {
|
|
652
|
+
id: string;
|
|
653
|
+
nodeType: string;
|
|
654
|
+
name: string;
|
|
655
|
+
file: string;
|
|
656
|
+
exported: boolean;
|
|
657
|
+
metadata: string;
|
|
658
|
+
semanticId?: string;
|
|
659
|
+
} {
|
|
660
|
+
const { id, type, nodeType, node_type, name, file, exported, ...rest } = node;
|
|
661
|
+
return {
|
|
662
|
+
id: String(id),
|
|
663
|
+
nodeType: (nodeType || node_type || type || 'UNKNOWN') as string,
|
|
664
|
+
name: (name as string) || '',
|
|
665
|
+
file: (file as string) || '',
|
|
666
|
+
exported: (exported as boolean) || false,
|
|
667
|
+
metadata: JSON.stringify(rest),
|
|
668
|
+
semanticId: String(id),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Simulate RFDBServerBackend.addNodes() wire format for v2 protocol (legacy).
|
|
674
|
+
* In v2: originalId is injected into metadata, no semanticId field.
|
|
675
|
+
*/
|
|
676
|
+
function mapAddNodesV2(node: Record<string, unknown>): {
|
|
677
|
+
id: string;
|
|
678
|
+
nodeType: string;
|
|
679
|
+
name: string;
|
|
680
|
+
file: string;
|
|
681
|
+
exported: boolean;
|
|
682
|
+
metadata: string;
|
|
683
|
+
semanticId?: string;
|
|
684
|
+
} {
|
|
685
|
+
const { id, type, nodeType, node_type, name, file, exported, ...rest } = node;
|
|
686
|
+
return {
|
|
687
|
+
id: String(id),
|
|
688
|
+
nodeType: (nodeType || node_type || type || 'UNKNOWN') as string,
|
|
689
|
+
name: (name as string) || '',
|
|
690
|
+
file: (file as string) || '',
|
|
691
|
+
exported: (exported as boolean) || false,
|
|
692
|
+
metadata: JSON.stringify({ originalId: String(id), ...rest }),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Simulate RFDBServerBackend.addEdges() wire format for v3 protocol.
|
|
698
|
+
* In v3: _origSrc/_origDst are NOT injected into metadata.
|
|
699
|
+
*/
|
|
700
|
+
function mapAddEdgesV3(edge: Record<string, unknown>): {
|
|
701
|
+
src: string; dst: string; edgeType: string; metadata: string;
|
|
702
|
+
} {
|
|
703
|
+
const { src, dst, type, edgeType, edge_type, metadata, ...rest } = edge;
|
|
704
|
+
const flatMetadata = {
|
|
705
|
+
...rest,
|
|
706
|
+
...(typeof metadata === 'object' && metadata !== null ? metadata : {}),
|
|
707
|
+
};
|
|
708
|
+
return {
|
|
709
|
+
src: String(src), dst: String(dst),
|
|
710
|
+
edgeType: (edgeType || edge_type || type || 'UNKNOWN') as string,
|
|
711
|
+
metadata: JSON.stringify(flatMetadata),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Simulate RFDBServerBackend.addEdges() wire format for v2 protocol (legacy).
|
|
717
|
+
* In v2: _origSrc/_origDst are injected into metadata.
|
|
718
|
+
*/
|
|
719
|
+
function mapAddEdgesV2(edge: Record<string, unknown>): {
|
|
720
|
+
src: string; dst: string; edgeType: string; metadata: string;
|
|
721
|
+
} {
|
|
722
|
+
const { src, dst, type, edgeType, edge_type, metadata, ...rest } = edge;
|
|
723
|
+
const flatMetadata = {
|
|
724
|
+
_origSrc: String(src), _origDst: String(dst),
|
|
725
|
+
...rest,
|
|
726
|
+
...(typeof metadata === 'object' && metadata !== null ? metadata : {}),
|
|
727
|
+
};
|
|
728
|
+
return {
|
|
729
|
+
src: String(src), dst: String(dst),
|
|
730
|
+
edgeType: (edgeType || edge_type || type || 'UNKNOWN') as string,
|
|
731
|
+
metadata: JSON.stringify(flatMetadata),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Simulate RFDBServerBackend._parseNode() for both v3 and v2 protocols.
|
|
737
|
+
*/
|
|
738
|
+
function parseNode(wireNode: { id: string; nodeType: string; name: string; file: string; exported: boolean; metadata: string; semanticId?: string }): {
|
|
739
|
+
id: string; type: string; name: string; file: string; exported: boolean; [key: string]: unknown;
|
|
740
|
+
} {
|
|
741
|
+
const metadata: Record<string, unknown> = wireNode.metadata ? JSON.parse(wireNode.metadata) : {};
|
|
742
|
+
// v3: use semanticId directly; v2: fall back to originalId metadata hack
|
|
743
|
+
const humanId = wireNode.semanticId || (metadata.originalId as string) || wireNode.id;
|
|
744
|
+
const { id: _id, type: _type, name: _name, file: _file, exported: _exported, nodeType: _nodeType, originalId: _originalId, ...safeMetadata } = metadata;
|
|
745
|
+
return { id: humanId, type: wireNode.nodeType, name: wireNode.name, file: wireNode.file, exported: wireNode.exported, ...safeMetadata };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Simulate RFDBServerBackend._parseEdge() for v3 and v2 protocols.
|
|
750
|
+
*/
|
|
751
|
+
function parseEdge(wireEdge: { src: string; dst: string; edgeType: string; metadata: string }, protocolVersion: number): {
|
|
752
|
+
src: string; dst: string; type: string; metadata?: Record<string, unknown>;
|
|
753
|
+
} {
|
|
754
|
+
const meta: Record<string, unknown> = wireEdge.metadata ? JSON.parse(wireEdge.metadata) : {};
|
|
755
|
+
const { _origSrc, _origDst, ...rest } = meta;
|
|
756
|
+
const src = protocolVersion >= 3 ? wireEdge.src : (_origSrc as string) || wireEdge.src;
|
|
757
|
+
const dst = protocolVersion >= 3 ? wireEdge.dst : (_origDst as string) || wireEdge.dst;
|
|
758
|
+
return { src, dst, type: wireEdge.edgeType, metadata: Object.keys(rest).length > 0 ? rest : undefined };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
describe('Protocol v3 — addNodes Wire Format (RFD-12)', () => {
|
|
762
|
+
it('v3: should set semanticId and exclude originalId from metadata', () => {
|
|
763
|
+
const wire = mapAddNodesV3({
|
|
764
|
+
id: 'src/app.js->global->FUNCTION->processData',
|
|
765
|
+
type: 'FUNCTION',
|
|
766
|
+
name: 'processData',
|
|
767
|
+
file: 'src/app.js',
|
|
768
|
+
exported: true,
|
|
769
|
+
async: true,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
assert.strictEqual(wire.semanticId, 'src/app.js->global->FUNCTION->processData');
|
|
773
|
+
const meta = JSON.parse(wire.metadata);
|
|
774
|
+
assert.strictEqual(meta.originalId, undefined, 'v3 should NOT inject originalId');
|
|
775
|
+
assert.strictEqual(meta.async, true, 'extra fields should still be in metadata');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('v2: should inject originalId into metadata, no semanticId', () => {
|
|
779
|
+
const wire = mapAddNodesV2({
|
|
780
|
+
id: 'src/app.js->global->FUNCTION->processData',
|
|
781
|
+
type: 'FUNCTION',
|
|
782
|
+
name: 'processData',
|
|
783
|
+
file: 'src/app.js',
|
|
784
|
+
exported: true,
|
|
785
|
+
async: true,
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
assert.strictEqual(wire.semanticId, undefined, 'v2 should NOT set semanticId');
|
|
789
|
+
const meta = JSON.parse(wire.metadata);
|
|
790
|
+
assert.strictEqual(meta.originalId, 'src/app.js->global->FUNCTION->processData', 'v2 should inject originalId');
|
|
791
|
+
assert.strictEqual(meta.async, true, 'extra fields should still be in metadata');
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe('Protocol v3 — addEdges Wire Format (RFD-12)', () => {
|
|
796
|
+
it('v3: should NOT inject _origSrc/_origDst into metadata', () => {
|
|
797
|
+
const wire = mapAddEdgesV3({
|
|
798
|
+
src: 'src/app.js->global->FUNCTION->processData',
|
|
799
|
+
dst: 'src/db.js->global->FUNCTION->query',
|
|
800
|
+
edgeType: 'CALLS',
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
const meta = JSON.parse(wire.metadata);
|
|
804
|
+
assert.strictEqual(meta._origSrc, undefined, 'v3 should NOT inject _origSrc');
|
|
805
|
+
assert.strictEqual(meta._origDst, undefined, 'v3 should NOT inject _origDst');
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('v2: should inject _origSrc/_origDst into metadata', () => {
|
|
809
|
+
const wire = mapAddEdgesV2({
|
|
810
|
+
src: 'src/app.js->global->FUNCTION->processData',
|
|
811
|
+
dst: 'src/db.js->global->FUNCTION->query',
|
|
812
|
+
edgeType: 'CALLS',
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const meta = JSON.parse(wire.metadata);
|
|
816
|
+
assert.strictEqual(meta._origSrc, 'src/app.js->global->FUNCTION->processData');
|
|
817
|
+
assert.strictEqual(meta._origDst, 'src/db.js->global->FUNCTION->query');
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('v3: extra edge properties should still be in metadata', () => {
|
|
821
|
+
const wire = mapAddEdgesV3({
|
|
822
|
+
src: 'a', dst: 'b', edgeType: 'CALLS',
|
|
823
|
+
callSite: 'line:42', confidence: 0.95,
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const meta = JSON.parse(wire.metadata);
|
|
827
|
+
assert.strictEqual(meta.callSite, 'line:42');
|
|
828
|
+
assert.strictEqual(meta.confidence, 0.95);
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
describe('Protocol v3 — _parseNode (RFD-12)', () => {
|
|
833
|
+
it('v3: should use semanticId for human-readable ID', () => {
|
|
834
|
+
const parsed = parseNode({
|
|
835
|
+
id: '123456789012345678901234567890', // u128 decimal from server
|
|
836
|
+
nodeType: 'FUNCTION',
|
|
837
|
+
name: 'processData',
|
|
838
|
+
file: 'src/app.js',
|
|
839
|
+
exported: true,
|
|
840
|
+
metadata: '{"async": true}',
|
|
841
|
+
semanticId: 'src/app.js->global->FUNCTION->processData',
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
assert.strictEqual(parsed.id, 'src/app.js->global->FUNCTION->processData');
|
|
845
|
+
assert.strictEqual(parsed.type, 'FUNCTION');
|
|
846
|
+
assert.strictEqual(parsed.async, true);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it('v2: should fall back to originalId from metadata', () => {
|
|
850
|
+
const parsed = parseNode({
|
|
851
|
+
id: '123456789012345678901234567890',
|
|
852
|
+
nodeType: 'FUNCTION',
|
|
853
|
+
name: 'processData',
|
|
854
|
+
file: 'src/app.js',
|
|
855
|
+
exported: true,
|
|
856
|
+
metadata: '{"originalId": "src/app.js->global->FUNCTION->processData", "async": true}',
|
|
857
|
+
// no semanticId (v2)
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
assert.strictEqual(parsed.id, 'src/app.js->global->FUNCTION->processData');
|
|
861
|
+
assert.strictEqual(parsed.async, true);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('fallback: should use wire id when neither semanticId nor originalId present', () => {
|
|
865
|
+
const parsed = parseNode({
|
|
866
|
+
id: '123456789',
|
|
867
|
+
nodeType: 'MODULE',
|
|
868
|
+
name: 'test',
|
|
869
|
+
file: 'test.js',
|
|
870
|
+
exported: false,
|
|
871
|
+
metadata: '{}',
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
assert.strictEqual(parsed.id, '123456789');
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
describe('Protocol v3 — _parseEdge (RFD-12)', () => {
|
|
879
|
+
it('v3: should use wire src/dst directly (server-resolved semantic IDs)', () => {
|
|
880
|
+
const parsed = parseEdge({
|
|
881
|
+
src: 'src/app.js->global->FUNCTION->processData',
|
|
882
|
+
dst: 'src/db.js->global->FUNCTION->query',
|
|
883
|
+
edgeType: 'CALLS',
|
|
884
|
+
metadata: '{}',
|
|
885
|
+
}, 3);
|
|
886
|
+
|
|
887
|
+
assert.strictEqual(parsed.src, 'src/app.js->global->FUNCTION->processData');
|
|
888
|
+
assert.strictEqual(parsed.dst, 'src/db.js->global->FUNCTION->query');
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it('v2: should extract _origSrc/_origDst from metadata', () => {
|
|
892
|
+
const parsed = parseEdge({
|
|
893
|
+
src: '123456789', // u128 decimal from server
|
|
894
|
+
dst: '987654321',
|
|
895
|
+
edgeType: 'CALLS',
|
|
896
|
+
metadata: '{"_origSrc": "src/app.js->global->FUNCTION->processData", "_origDst": "src/db.js->global->FUNCTION->query"}',
|
|
897
|
+
}, 2);
|
|
898
|
+
|
|
899
|
+
assert.strictEqual(parsed.src, 'src/app.js->global->FUNCTION->processData');
|
|
900
|
+
assert.strictEqual(parsed.dst, 'src/db.js->global->FUNCTION->query');
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('v3: should strip _origSrc/_origDst from metadata even if present', () => {
|
|
904
|
+
// Edge case: v3 edge that somehow has _origSrc in metadata
|
|
905
|
+
const parsed = parseEdge({
|
|
906
|
+
src: 'src/app.js->global->FUNCTION->processData',
|
|
907
|
+
dst: 'src/db.js->global->FUNCTION->query',
|
|
908
|
+
edgeType: 'CALLS',
|
|
909
|
+
metadata: '{"_origSrc": "stale", "_origDst": "stale", "callSite": "line:42"}',
|
|
910
|
+
}, 3);
|
|
911
|
+
|
|
912
|
+
// v3 uses wire src/dst directly, ignoring _origSrc/_origDst
|
|
913
|
+
assert.strictEqual(parsed.src, 'src/app.js->global->FUNCTION->processData');
|
|
914
|
+
assert.strictEqual(parsed.dst, 'src/db.js->global->FUNCTION->query');
|
|
915
|
+
// _origSrc/_origDst should be stripped from metadata
|
|
916
|
+
assert.strictEqual(parsed.metadata?.callSite, 'line:42');
|
|
917
|
+
assert.strictEqual((parsed.metadata as any)?._origSrc, undefined);
|
|
918
|
+
assert.strictEqual((parsed.metadata as any)?._origDst, undefined);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('v2 fallback: should use wire IDs when _origSrc/_origDst not in metadata', () => {
|
|
922
|
+
const parsed = parseEdge({
|
|
923
|
+
src: '123456789',
|
|
924
|
+
dst: '987654321',
|
|
925
|
+
edgeType: 'CALLS',
|
|
926
|
+
metadata: '{}',
|
|
927
|
+
}, 2);
|
|
928
|
+
|
|
929
|
+
assert.strictEqual(parsed.src, '123456789');
|
|
930
|
+
assert.strictEqual(parsed.dst, '987654321');
|
|
931
|
+
});
|
|
932
|
+
});
|