@bod.ee/db 0.12.1 → 0.12.4

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,835 @@
1
+ import { describe, it, expect, afterEach } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+ import { BodClient } from '../src/client/BodClient.ts';
4
+ import { PathTopologyRouter, type PathTopology } from '../src/server/ReplicationEngine.ts';
5
+
6
+ /** Poll until fn() returns true, or timeout (default 3s, poll every 50ms) */
7
+ async function waitFor(fn: () => boolean, timeoutMs = 3000, intervalMs = 50): Promise<void> {
8
+ const start = Date.now();
9
+ while (!fn()) {
10
+ if (Date.now() - start > timeoutMs) throw new Error(`waitFor timed out after ${timeoutMs}ms`);
11
+ await new Promise(r => setTimeout(r, intervalMs));
12
+ }
13
+ }
14
+
15
+ /** Short delay for setup (connection establishment, subscription registration) */
16
+ const settle = () => new Promise(r => setTimeout(r, 150));
17
+
18
+ let nextPort = 26400 + Math.floor(Math.random() * 1000);
19
+
20
+ // --- Unit: PathTopologyRouter ---
21
+
22
+ describe('PathTopologyRouter', () => {
23
+ it('string paths default to sync', () => {
24
+ const router = new PathTopologyRouter(['_vfs', '_auth'], 'primary');
25
+ expect(router.resolve('_vfs').mode).toBe('sync');
26
+ expect(router.resolve('_auth').mode).toBe('sync');
27
+ });
28
+
29
+ it('object paths preserve mode', () => {
30
+ const router = new PathTopologyRouter([{ path: '_vfs', mode: 'primary' }], 'replica');
31
+ expect(router.resolve('_vfs').mode).toBe('primary');
32
+ expect(router.resolve('_vfs/file1').mode).toBe('primary');
33
+ });
34
+
35
+ it('longest-prefix match wins', () => {
36
+ const router = new PathTopologyRouter([
37
+ { path: '_auth', mode: 'replica' },
38
+ { path: '_auth/nonces', mode: 'primary' },
39
+ ], 'primary');
40
+ expect(router.resolve('_auth/sessions/s1').mode).toBe('replica');
41
+ expect(router.resolve('_auth/nonces/x').mode).toBe('primary');
42
+ });
43
+
44
+ it('unmatched path falls back to role', () => {
45
+ const router = new PathTopologyRouter([{ path: '_vfs', mode: 'primary' }], 'replica');
46
+ expect(router.resolve('users/u1').mode).toBe('replica');
47
+ });
48
+
49
+ it('mixed string + object array', () => {
50
+ const router = new PathTopologyRouter([
51
+ 'config',
52
+ { path: '_vfs', mode: 'primary' },
53
+ { path: 'logs', mode: 'writeonly' },
54
+ ], 'replica');
55
+ expect(router.resolve('config').mode).toBe('sync');
56
+ expect(router.resolve('_vfs').mode).toBe('primary');
57
+ expect(router.resolve('logs').mode).toBe('writeonly');
58
+ expect(router.resolve('other').mode).toBe('replica');
59
+ });
60
+
61
+ it('shouldEmit for each mode', () => {
62
+ const router = new PathTopologyRouter([
63
+ { path: 'a', mode: 'primary' },
64
+ { path: 'b', mode: 'replica' },
65
+ { path: 'c', mode: 'sync' },
66
+ { path: 'd', mode: 'readonly' },
67
+ { path: 'e', mode: 'writeonly' },
68
+ ], 'primary');
69
+ expect(router.shouldEmit('a/x')).toBe(true);
70
+ expect(router.shouldEmit('b/x')).toBe(false);
71
+ expect(router.shouldEmit('c/x')).toBe(true);
72
+ expect(router.shouldEmit('d/x')).toBe(false);
73
+ expect(router.shouldEmit('e/x')).toBe(true);
74
+ });
75
+
76
+ it('shouldApply for each mode', () => {
77
+ const router = new PathTopologyRouter([
78
+ { path: 'a', mode: 'primary' },
79
+ { path: 'b', mode: 'replica' },
80
+ { path: 'c', mode: 'sync' },
81
+ { path: 'd', mode: 'readonly' },
82
+ { path: 'e', mode: 'writeonly' },
83
+ ], 'primary');
84
+ expect(router.shouldApply('a/x')).toBe(false);
85
+ expect(router.shouldApply('b/x')).toBe(true);
86
+ expect(router.shouldApply('c/x')).toBe(true);
87
+ expect(router.shouldApply('d/x')).toBe(true);
88
+ expect(router.shouldApply('e/x')).toBe(false);
89
+ });
90
+
91
+ it('shouldProxy/shouldReject for each mode', () => {
92
+ const router = new PathTopologyRouter([
93
+ { path: 'a', mode: 'primary' },
94
+ { path: 'b', mode: 'replica' },
95
+ { path: 'c', mode: 'sync' },
96
+ { path: 'd', mode: 'readonly' },
97
+ { path: 'e', mode: 'writeonly' },
98
+ ], 'primary');
99
+ expect(router.shouldProxy('a/x')).toBe(false);
100
+ expect(router.shouldProxy('b/x')).toBe(true);
101
+ expect(router.shouldProxy('c/x')).toBe(false);
102
+ expect(router.shouldProxy('d/x')).toBe(false);
103
+ expect(router.shouldReject('d/x')).toBe(true);
104
+ expect(router.shouldProxy('e/x')).toBe(false);
105
+ });
106
+
107
+ it('writeProxy override', () => {
108
+ const router = new PathTopologyRouter([
109
+ { path: 'a', mode: 'replica', writeProxy: 'reject' },
110
+ ], 'primary');
111
+ expect(router.shouldProxy('a/x')).toBe(false);
112
+ expect(router.shouldReject('a/x')).toBe(true);
113
+ });
114
+
115
+ it('getBootstrapPaths excludes sync', () => {
116
+ const router = new PathTopologyRouter([
117
+ { path: 'a', mode: 'replica' },
118
+ { path: 'b', mode: 'sync' },
119
+ { path: 'c', mode: 'readonly' },
120
+ { path: 'd', mode: 'primary' },
121
+ ], 'primary');
122
+ const bootstrap = router.getBootstrapPaths();
123
+ expect(bootstrap).toContain('a');
124
+ expect(bootstrap).toContain('c');
125
+ expect(bootstrap).not.toContain('b');
126
+ expect(bootstrap).not.toContain('d');
127
+ });
128
+
129
+ it('getReplicaPaths includes sync', () => {
130
+ const router = new PathTopologyRouter([
131
+ { path: 'a', mode: 'replica' },
132
+ { path: 'b', mode: 'sync' },
133
+ { path: 'c', mode: 'readonly' },
134
+ { path: 'd', mode: 'primary' },
135
+ ], 'primary');
136
+ const pull = router.getReplicaPaths();
137
+ expect(pull).toContain('a');
138
+ expect(pull).toContain('b');
139
+ expect(pull).toContain('c');
140
+ expect(pull).not.toContain('d');
141
+ });
142
+ });
143
+
144
+ // --- Integration: Per-path topology ---
145
+
146
+ describe('Per-path replication topology', () => {
147
+ const instances: BodDB[] = [];
148
+ const clients: BodClient[] = [];
149
+
150
+ afterEach(() => {
151
+ for (const c of clients) c.disconnect();
152
+ clients.length = 0;
153
+ for (const db of [...instances].reverse()) db.close();
154
+ instances.length = 0;
155
+ });
156
+
157
+ function getPort() { return nextPort++; }
158
+
159
+ function createNode(opts: { port: number; replication: any; rules?: any }) {
160
+ const db = new BodDB({ path: ':memory:', sweepInterval: 0, replication: opts.replication, rules: opts.rules });
161
+ db.serve({ port: opts.port });
162
+ instances.push(db);
163
+ return db;
164
+ }
165
+
166
+ // --- Emit filtering ---
167
+
168
+ it('primary-mode path emits to _repl', () => {
169
+ const db = new BodDB({
170
+ path: ':memory:',
171
+ sweepInterval: 0,
172
+ replication: {
173
+ role: 'primary',
174
+ paths: [{ path: '_vfs', mode: 'primary' }],
175
+ },
176
+ });
177
+ instances.push(db);
178
+ db.replication!.start();
179
+
180
+ db.set('_vfs/file1', { data: 'hello' });
181
+ const repl = db.get('_repl');
182
+ expect(repl).toBeTruthy();
183
+ const events = Object.values(repl as Record<string, any>);
184
+ expect(events.some((e: any) => e.path === '_vfs/file1')).toBe(true);
185
+ });
186
+
187
+ it('non-emitting modes do NOT emit', () => {
188
+ const db = new BodDB({
189
+ path: ':memory:',
190
+ sweepInterval: 0,
191
+ replication: {
192
+ role: 'primary',
193
+ paths: [
194
+ { path: 'local', mode: 'primary' },
195
+ { path: 'telemetry', mode: 'writeonly' },
196
+ ],
197
+ },
198
+ });
199
+ instances.push(db);
200
+ db.replication!.start();
201
+
202
+ db.set('local/data', { v: 1 });
203
+ db.set('telemetry/t1', { event: 'click' });
204
+ db.set('other/data', { v: 2 });
205
+
206
+ const repl = db.get('_repl');
207
+ expect(repl).toBeTruthy();
208
+ const events = Object.values(repl as Record<string, any>);
209
+ expect(events.some((e: any) => e.path === 'local/data')).toBe(true);
210
+ expect(events.some((e: any) => e.path === 'telemetry/t1')).toBe(true);
211
+ expect(events.some((e: any) => e.path === 'other/data')).toBe(true);
212
+ });
213
+
214
+ it('writeonly path emits', () => {
215
+ const db = new BodDB({
216
+ path: ':memory:',
217
+ sweepInterval: 0,
218
+ replication: {
219
+ role: 'primary',
220
+ paths: [{ path: 'telemetry', mode: 'writeonly' }],
221
+ },
222
+ });
223
+ instances.push(db);
224
+ db.replication!.start();
225
+
226
+ db.set('telemetry/t1', { event: 'click' });
227
+ const repl = db.get('_repl');
228
+ expect(repl).toBeTruthy();
229
+ const events = Object.values(repl as Record<string, any>);
230
+ expect(events.some((e: any) => e.path === 'telemetry/t1')).toBe(true);
231
+ });
232
+
233
+ // --- Write interception via WS Transport ---
234
+
235
+ it('readonly path rejected via WS (default, no writeProxy)', async () => {
236
+ const port = getPort();
237
+ createNode({
238
+ port,
239
+ replication: {
240
+ role: 'primary',
241
+ paths: [{ path: 'data', mode: 'readonly' }],
242
+ },
243
+ });
244
+
245
+ const client = new BodClient({ url: `ws://localhost:${port}` });
246
+ clients.push(client);
247
+ await client.connect();
248
+
249
+ try {
250
+ await client.set('data/x', 'val');
251
+ expect(true).toBe(false);
252
+ } catch (e: any) {
253
+ expect(e.message).toContain('readonly');
254
+ }
255
+ });
256
+
257
+ it('readonly path rejected via WS (explicit writeProxy: reject)', async () => {
258
+ const port = getPort();
259
+ createNode({
260
+ port,
261
+ replication: {
262
+ role: 'replica',
263
+ primaryUrl: 'ws://localhost:1',
264
+ paths: [{ path: 'data', mode: 'readonly', writeProxy: 'reject' }],
265
+ },
266
+ });
267
+
268
+ const client = new BodClient({ url: `ws://localhost:${port}` });
269
+ clients.push(client);
270
+ await client.connect();
271
+
272
+ try {
273
+ await client.set('data/x', 'val');
274
+ expect(true).toBe(false);
275
+ } catch (e: any) {
276
+ expect(e.message).toContain('readonly');
277
+ }
278
+ });
279
+
280
+ it('primary path written locally (not proxied)', async () => {
281
+ const port = getPort();
282
+ const db = createNode({
283
+ port,
284
+ replication: {
285
+ role: 'replica',
286
+ primaryUrl: 'ws://localhost:1',
287
+ paths: [{ path: '_vfs', mode: 'primary' }],
288
+ },
289
+ });
290
+
291
+ const client = new BodClient({ url: `ws://localhost:${port}` });
292
+ clients.push(client);
293
+ await client.connect();
294
+
295
+ await client.set('_vfs/file1', { data: 'hello' });
296
+ expect(db.get('_vfs/file1')).toEqual({ data: 'hello' });
297
+ });
298
+
299
+ it('replica path proxied to primary via WS', async () => {
300
+ const pPort = getPort();
301
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
302
+ primary.replication!.start();
303
+
304
+ const rPort = getPort();
305
+ const replica = createNode({
306
+ port: rPort,
307
+ replication: {
308
+ role: 'replica',
309
+ primaryUrl: `ws://localhost:${pPort}`,
310
+ paths: [{ path: 'shared', mode: 'replica' }],
311
+ },
312
+ });
313
+ await replica.replication!.start();
314
+ await settle();
315
+
316
+ const client = new BodClient({ url: `ws://localhost:${rPort}` });
317
+ clients.push(client);
318
+ await client.connect();
319
+ await client.set('shared/item1', { v: 1 });
320
+
321
+ await waitFor(() => primary.get('shared/item1') != null);
322
+ expect(primary.get('shared/item1')).toEqual({ v: 1 });
323
+ });
324
+
325
+ // --- Write interception via REST ---
326
+
327
+ it('readonly path rejected via REST PUT', async () => {
328
+ const port = getPort();
329
+ createNode({
330
+ port,
331
+ replication: {
332
+ role: 'primary',
333
+ paths: [{ path: 'data', mode: 'readonly' }],
334
+ },
335
+ });
336
+
337
+ const res = await fetch(`http://localhost:${port}/db/data/x`, {
338
+ method: 'PUT',
339
+ headers: { 'Content-Type': 'application/json' },
340
+ body: JSON.stringify({ v: 1 }),
341
+ });
342
+ const body = await res.json();
343
+ expect(body.ok).toBe(false);
344
+ expect(body.error).toContain('readonly');
345
+ });
346
+
347
+ it('readonly path rejected via REST DELETE', async () => {
348
+ const port = getPort();
349
+ createNode({
350
+ port,
351
+ replication: {
352
+ role: 'primary',
353
+ paths: [{ path: 'data', mode: 'readonly' }],
354
+ },
355
+ });
356
+
357
+ const res = await fetch(`http://localhost:${port}/db/data/x`, { method: 'DELETE' });
358
+ const body = await res.json();
359
+ expect(body.ok).toBe(false);
360
+ expect(body.error).toContain('readonly');
361
+ });
362
+
363
+ it('primary path written locally via REST', async () => {
364
+ const port = getPort();
365
+ const db = createNode({
366
+ port,
367
+ replication: {
368
+ role: 'replica',
369
+ primaryUrl: 'ws://localhost:1',
370
+ paths: [{ path: 'local', mode: 'primary' }],
371
+ },
372
+ });
373
+
374
+ const res = await fetch(`http://localhost:${port}/db/local/item1`, {
375
+ method: 'PUT',
376
+ headers: { 'Content-Type': 'application/json' },
377
+ body: JSON.stringify({ v: 42 }),
378
+ });
379
+ const body = await res.json();
380
+ expect(body.ok).toBe(true);
381
+ expect(db.get('local/item1')).toEqual({ v: 42 });
382
+ });
383
+
384
+ // --- Auth check before proxy (security) ---
385
+
386
+ it('rules checked before proxy on WS set', async () => {
387
+ const pPort = getPort();
388
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
389
+ primary.replication!.start();
390
+
391
+ const rPort = getPort();
392
+ createNode({
393
+ port: rPort,
394
+ replication: {
395
+ role: 'replica',
396
+ primaryUrl: `ws://localhost:${pPort}`,
397
+ paths: [{ path: 'protected', mode: 'replica' }],
398
+ },
399
+ rules: { 'protected/$any': { write: false } },
400
+ });
401
+
402
+ const client = new BodClient({ url: `ws://localhost:${rPort}` });
403
+ clients.push(client);
404
+ await client.connect();
405
+
406
+ try {
407
+ await client.set('protected/x', 'val');
408
+ expect(true).toBe(false);
409
+ } catch (e: any) {
410
+ expect(e.message).toContain('Permission denied');
411
+ }
412
+ expect(primary.get('protected/x')).toBeNull();
413
+ });
414
+
415
+ it('rules checked before proxy on REST PUT', async () => {
416
+ const pPort = getPort();
417
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
418
+ primary.replication!.start();
419
+
420
+ const rPort = getPort();
421
+ createNode({
422
+ port: rPort,
423
+ replication: {
424
+ role: 'replica',
425
+ primaryUrl: `ws://localhost:${pPort}`,
426
+ paths: [{ path: 'protected', mode: 'replica' }],
427
+ },
428
+ rules: { 'protected/$any': { write: false } },
429
+ });
430
+
431
+ const res = await fetch(`http://localhost:${rPort}/db/protected/x`, {
432
+ method: 'PUT',
433
+ headers: { 'Content-Type': 'application/json' },
434
+ body: JSON.stringify({ v: 1 }),
435
+ });
436
+ const body = await res.json();
437
+ expect(body.ok).toBe(false);
438
+ expect(body.error).toContain('Permission denied');
439
+ expect(primary.get('protected/x')).toBeNull();
440
+ });
441
+
442
+ // --- Fallback path behavior ---
443
+
444
+ it('unconfigured path falls back to role (proxy when connected)', async () => {
445
+ const pPort = getPort();
446
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
447
+ primary.replication!.start();
448
+
449
+ const rPort = getPort();
450
+ createNode({
451
+ port: rPort,
452
+ replication: {
453
+ role: 'replica',
454
+ primaryUrl: `ws://localhost:${pPort}`,
455
+ paths: [
456
+ { path: 'local', mode: 'primary' },
457
+ { path: 'shared', mode: 'replica' },
458
+ ],
459
+ },
460
+ });
461
+ // Need to start replica to connect
462
+ const replica = instances[instances.length - 1];
463
+ await replica.replication!.start();
464
+ await settle();
465
+
466
+ const client = new BodClient({ url: `ws://localhost:${rPort}` });
467
+ clients.push(client);
468
+ await client.connect();
469
+
470
+ await client.set('users/u1', { name: 'Bob' });
471
+ await waitFor(() => primary.get('users/u1') != null);
472
+ expect(primary.get('users/u1')).toEqual({ name: 'Bob' });
473
+ });
474
+
475
+ it('unconfigured path rejected when no primary connection', async () => {
476
+ const port = getPort();
477
+ createNode({
478
+ port,
479
+ replication: {
480
+ role: 'replica',
481
+ paths: [{ path: 'local', mode: 'primary' }],
482
+ },
483
+ });
484
+
485
+ const client = new BodClient({ url: `ws://localhost:${port}` });
486
+ clients.push(client);
487
+ await client.connect();
488
+
489
+ try {
490
+ await client.set('users/u1', { name: 'Bob' });
491
+ expect(true).toBe(false);
492
+ } catch (e: any) {
493
+ expect(e.message).toContain('readonly');
494
+ }
495
+ });
496
+
497
+ // --- Mixed-topology batch/update ---
498
+
499
+ it('batch with mixed primary+replica paths proxies entire batch (greedy)', async () => {
500
+ const pPort = getPort();
501
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
502
+ primary.replication!.start();
503
+
504
+ const rPort = getPort();
505
+ const replica = createNode({
506
+ port: rPort,
507
+ replication: {
508
+ role: 'replica',
509
+ primaryUrl: `ws://localhost:${pPort}`,
510
+ paths: [
511
+ { path: 'local', mode: 'primary' },
512
+ { path: 'remote', mode: 'replica' },
513
+ ],
514
+ },
515
+ });
516
+ await replica.replication!.start();
517
+ await settle();
518
+
519
+ const client = new BodClient({ url: `ws://localhost:${rPort}` });
520
+ clients.push(client);
521
+ await client.connect();
522
+
523
+ // Batch with one primary path + one replica path → entire batch proxied to primary
524
+ await client.batch([
525
+ { op: 'set', path: 'local/a', value: 1 },
526
+ { op: 'set', path: 'remote/b', value: 2 },
527
+ ]);
528
+
529
+ // Both should end up on primary (greedy proxy)
530
+ await waitFor(() => primary.get('remote/b') != null);
531
+ expect(primary.get('remote/b')).toBe(2);
532
+ // local/a also went to primary via proxy
533
+ expect(primary.get('local/a')).toBe(1);
534
+ });
535
+
536
+ it('batch with readonly path rejected', async () => {
537
+ const port = getPort();
538
+ createNode({
539
+ port,
540
+ replication: {
541
+ role: 'primary',
542
+ paths: [
543
+ { path: 'rw', mode: 'primary' },
544
+ { path: 'ro', mode: 'readonly' },
545
+ ],
546
+ },
547
+ });
548
+
549
+ const client = new BodClient({ url: `ws://localhost:${port}` });
550
+ clients.push(client);
551
+ await client.connect();
552
+
553
+ try {
554
+ await client.batch([
555
+ { op: 'set', path: 'rw/a', value: 1 },
556
+ { op: 'set', path: 'ro/b', value: 2 },
557
+ ]);
558
+ expect(true).toBe(false);
559
+ } catch (e: any) {
560
+ expect(e.message).toContain('readonly');
561
+ }
562
+ });
563
+
564
+ it('update with mixed paths: proxy when any path is replica', async () => {
565
+ const pPort = getPort();
566
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
567
+ primary.replication!.start();
568
+
569
+ const rPort = getPort();
570
+ const replica = createNode({
571
+ port: rPort,
572
+ replication: {
573
+ role: 'replica',
574
+ primaryUrl: `ws://localhost:${pPort}`,
575
+ paths: [
576
+ { path: 'local', mode: 'primary' },
577
+ { path: 'remote', mode: 'replica' },
578
+ ],
579
+ },
580
+ });
581
+ await replica.replication!.start();
582
+ await settle();
583
+
584
+ const client = new BodClient({ url: `ws://localhost:${rPort}` });
585
+ clients.push(client);
586
+ await client.connect();
587
+
588
+ await client.update({ 'local/x': 10, 'remote/y': 20 });
589
+ await waitFor(() => primary.get('remote/y') != null);
590
+ expect(primary.get('remote/y')).toBe(20);
591
+ });
592
+
593
+ // --- Bootstrap per topology ---
594
+
595
+ it('replica paths pulled from primary, primary paths NOT pulled', async () => {
596
+ const pPort = getPort();
597
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
598
+ primary.replication!.start();
599
+
600
+ primary.set('_auth/accounts/a1', { name: 'Alice' });
601
+ primary.set('_vfs/file1', { data: 'should-not-pull' });
602
+ await settle();
603
+
604
+ const rPort = getPort();
605
+ const replica = createNode({
606
+ port: rPort,
607
+ replication: {
608
+ role: 'replica',
609
+ primaryUrl: `ws://localhost:${pPort}`,
610
+ paths: [
611
+ { path: '_auth', mode: 'replica' },
612
+ { path: '_vfs', mode: 'primary' },
613
+ ],
614
+ },
615
+ });
616
+ await replica.replication!.start();
617
+ await waitFor(() => replica.get('_auth/accounts/a1') != null);
618
+
619
+ expect(replica.get('_auth/accounts/a1')).toEqual({ name: 'Alice' });
620
+ expect(replica.get('_vfs/file1')).toBeNull();
621
+ });
622
+
623
+ it('sync paths NOT bootstrapped (avoids overwriting local state)', async () => {
624
+ const pPort = getPort();
625
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
626
+ primary.replication!.start();
627
+
628
+ primary.set('config/remote', 'from-primary');
629
+
630
+ const rPort = getPort();
631
+ const replica = createNode({
632
+ port: rPort,
633
+ replication: {
634
+ role: 'replica',
635
+ primaryUrl: `ws://localhost:${pPort}`,
636
+ paths: ['config'],
637
+ },
638
+ });
639
+ replica.set('config/local', 'from-replica');
640
+
641
+ await replica.replication!.start();
642
+ await settle();
643
+
644
+ // Local data preserved — sync doesn't bootstrap
645
+ expect(replica.get('config/local')).toBe('from-replica');
646
+ });
647
+
648
+ it('ongoing replication per mode', async () => {
649
+ const pPort = getPort();
650
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
651
+ primary.replication!.start();
652
+
653
+ const rPort = getPort();
654
+ const replica = createNode({
655
+ port: rPort,
656
+ replication: {
657
+ role: 'replica',
658
+ primaryUrl: `ws://localhost:${pPort}`,
659
+ paths: [
660
+ { path: 'shared', mode: 'replica' },
661
+ { path: 'local', mode: 'primary' },
662
+ ],
663
+ },
664
+ });
665
+ await replica.replication!.start();
666
+ await settle();
667
+
668
+ primary.set('shared/item1', { v: 1 });
669
+ await waitFor(() => replica.get('shared/item1') != null);
670
+ expect(replica.get('shared/item1')).toEqual({ v: 1 });
671
+
672
+ primary.set('local/item1', { v: 2 });
673
+ // Give some time for potential (unwanted) replication, then verify it didn't happen
674
+ await settle();
675
+ expect(replica.get('local/item1')).toBeNull();
676
+ });
677
+
678
+ // --- Reconnect ---
679
+
680
+ it('replica re-subscribes after primary restart', async () => {
681
+ const pPort = getPort();
682
+ let primary = createNode({ port: pPort, replication: { role: 'primary' } });
683
+ primary.replication!.start();
684
+
685
+ const rPort = getPort();
686
+ const replica = createNode({
687
+ port: rPort,
688
+ replication: {
689
+ role: 'replica',
690
+ primaryUrl: `ws://localhost:${pPort}`,
691
+ paths: [{ path: 'data', mode: 'replica' }],
692
+ replicaId: 'reconnect-test',
693
+ },
694
+ });
695
+ await replica.replication!.start();
696
+ await settle();
697
+
698
+ // Write before restart
699
+ primary.set('data/before', { v: 1 });
700
+ await waitFor(() => replica.get('data/before') != null);
701
+ expect(replica.get('data/before')).toEqual({ v: 1 });
702
+
703
+ // Restart primary (close + create new on same port)
704
+ primary.close();
705
+ instances.splice(instances.indexOf(primary), 1);
706
+
707
+ primary = createNode({ port: pPort, replication: { role: 'primary' } });
708
+ primary.replication!.start();
709
+
710
+ // Wait for BodClient auto-reconnect + re-subscribe
711
+ await waitFor(() => {
712
+ // Write on new primary and check if replica eventually gets it
713
+ primary.set('data/after', { v: 2 });
714
+ return replica.get('data/after') != null;
715
+ }, 5000, 200);
716
+ expect(replica.get('data/after')).toEqual({ v: 2 });
717
+ });
718
+
719
+ // --- Config validation ---
720
+
721
+ it('start() throws when replica paths exist but no primaryUrl', async () => {
722
+ const db = new BodDB({
723
+ path: ':memory:',
724
+ sweepInterval: 0,
725
+ replication: {
726
+ role: 'replica',
727
+ paths: [{ path: 'data', mode: 'replica' }],
728
+ },
729
+ });
730
+ instances.push(db);
731
+
732
+ try {
733
+ await db.replication!.start();
734
+ expect(true).toBe(false);
735
+ } catch (e: any) {
736
+ expect(e.message).toContain('primaryUrl');
737
+ }
738
+ });
739
+
740
+ it('start() succeeds with only primary paths and no primaryUrl', async () => {
741
+ const db = new BodDB({
742
+ path: ':memory:',
743
+ sweepInterval: 0,
744
+ replication: {
745
+ role: 'primary',
746
+ paths: [{ path: '_vfs', mode: 'primary' }, { path: 'logs', mode: 'writeonly' }],
747
+ },
748
+ });
749
+ instances.push(db);
750
+
751
+ await db.replication!.start();
752
+ expect(db.replication!.started).toBe(true);
753
+ });
754
+
755
+ // --- Backward compatibility ---
756
+
757
+ it('no paths key = role-based (current behavior)', async () => {
758
+ const pPort = getPort();
759
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
760
+ primary.replication!.start();
761
+ primary.set('users/u1', { name: 'Eli' });
762
+ await settle();
763
+
764
+ const rPort = getPort();
765
+ const replica = createNode({
766
+ port: rPort,
767
+ replication: {
768
+ role: 'replica',
769
+ primaryUrl: `ws://localhost:${pPort}`,
770
+ replicaId: `compat-test-${rPort}`,
771
+ },
772
+ });
773
+ await replica.replication!.start();
774
+ await waitFor(() => replica.get('users/u1') != null);
775
+
776
+ expect(replica.get('users/u1')).toEqual({ name: 'Eli' });
777
+ expect(replica.replication!.isReplica).toBe(true);
778
+ });
779
+
780
+ it('with paths, isReplica returns false, emitsToRepl returns true', () => {
781
+ const db = new BodDB({
782
+ path: ':memory:',
783
+ sweepInterval: 0,
784
+ replication: {
785
+ role: 'replica',
786
+ paths: [{ path: '_auth', mode: 'replica' }],
787
+ },
788
+ });
789
+ instances.push(db);
790
+ expect(db.replication!.isReplica).toBe(false);
791
+ expect(db.replication!.emitsToRepl).toBe(true);
792
+ expect(db.replication!.pullsFromPrimary).toBe(true);
793
+ expect(db.replication!.router).toBeTruthy();
794
+ });
795
+
796
+ it('sync path written locally AND emitted (end-to-end)', async () => {
797
+ const pPort = getPort();
798
+ const primary = createNode({ port: pPort, replication: { role: 'primary' } });
799
+ primary.replication!.start();
800
+
801
+ const rPort = getPort();
802
+ const replica = createNode({
803
+ port: rPort,
804
+ replication: {
805
+ role: 'replica',
806
+ primaryUrl: `ws://localhost:${pPort}`,
807
+ paths: ['config'],
808
+ },
809
+ });
810
+ await replica.replication!.start();
811
+ await settle();
812
+
813
+ replica.set('config/theme', 'dark');
814
+ expect(replica.get('config/theme')).toBe('dark');
815
+
816
+ const repl = replica.get('_repl');
817
+ expect(repl).toBeTruthy();
818
+ const events = Object.values(repl as Record<string, any>);
819
+ expect(events.some((e: any) => e.path === 'config/theme')).toBe(true);
820
+ });
821
+
822
+ it('stable replicaId derived from config', () => {
823
+ const opts = {
824
+ role: 'replica' as const,
825
+ primaryUrl: 'ws://localhost:4400',
826
+ paths: [{ path: 'data', mode: 'replica' as const }],
827
+ };
828
+ const db1 = new BodDB({ path: ':memory:', sweepInterval: 0, replication: opts });
829
+ const db2 = new BodDB({ path: ':memory:', sweepInterval: 0, replication: opts });
830
+ instances.push(db1, db2);
831
+
832
+ expect(db1.replication!.options.replicaId).toBe(db2.replication!.options.replicaId);
833
+ expect(db1.replication!.options.replicaId).toMatch(/^replica_/);
834
+ });
835
+ });