@bod.ee/db 0.12.8 → 0.13.1
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/CLAUDE.md +2 -1
- package/admin/admin.ts +23 -3
- package/admin/bun.lock +248 -0
- package/admin/index.html +12 -0
- package/admin/package.json +22 -0
- package/admin/src/App.tsx +23 -0
- package/admin/src/client/ZuzClient.ts +183 -0
- package/admin/src/client/types.ts +28 -0
- package/admin/src/components/MetricsBar.tsx +167 -0
- package/admin/src/components/Sparkline.tsx +72 -0
- package/admin/src/components/TreePane.tsx +287 -0
- package/admin/src/components/tabs/Advanced.tsx +222 -0
- package/admin/src/components/tabs/AuthRules.tsx +104 -0
- package/admin/src/components/tabs/Cache.tsx +113 -0
- package/admin/src/components/tabs/KeyAuth.tsx +462 -0
- package/admin/src/components/tabs/MessageQueue.tsx +237 -0
- package/admin/src/components/tabs/Query.tsx +75 -0
- package/admin/src/components/tabs/ReadWrite.tsx +177 -0
- package/admin/src/components/tabs/Replication.tsx +94 -0
- package/admin/src/components/tabs/Streams.tsx +329 -0
- package/admin/src/components/tabs/StressTests.tsx +209 -0
- package/admin/src/components/tabs/Subscriptions.tsx +69 -0
- package/admin/src/components/tabs/TabPane.tsx +151 -0
- package/admin/src/components/tabs/VFS.tsx +435 -0
- package/admin/src/components/tabs/View.tsx +14 -0
- package/admin/src/components/tabs/utils.ts +25 -0
- package/admin/src/context/DbContext.tsx +33 -0
- package/admin/src/context/StatsContext.tsx +56 -0
- package/admin/src/main.tsx +10 -0
- package/admin/src/styles.css +96 -0
- package/admin/tsconfig.app.json +21 -0
- package/admin/tsconfig.json +7 -0
- package/admin/tsconfig.node.json +15 -0
- package/admin/vite.config.ts +42 -0
- package/deploy/base.yaml +1 -1
- package/deploy/prod-il.config.ts +5 -2
- package/deploy/prod.config.ts +5 -2
- package/package.json +4 -1
- package/src/server/BodDB.ts +62 -5
- package/src/server/ReplicationEngine.ts +148 -35
- package/src/server/StreamEngine.ts +2 -2
- package/src/server/Transport.ts +17 -0
- package/tests/replication.test.ts +162 -1
- package/admin/ui.html +0 -3562
|
@@ -116,6 +116,8 @@ export class ReplicationSource {
|
|
|
116
116
|
paths: string[] = [];
|
|
117
117
|
localPrefix?: string;
|
|
118
118
|
excludePrefixes?: string[];
|
|
119
|
+
/** Subscribe to `_repl_scoped/<prefix>` instead of filtering `_repl`. Requires primary to have matching `scopedPrefixes`. */
|
|
120
|
+
scopedStream?: boolean;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
export class ReplicationOptions {
|
|
@@ -124,7 +126,7 @@ export class ReplicationOptions {
|
|
|
124
126
|
primaryAuth?: () => string | Promise<string>;
|
|
125
127
|
replicaId?: string;
|
|
126
128
|
/** Paths excluded from replication. Note: when `paths` explicitly configures a prefix (e.g. `_auth`), it overrides this exclusion. */
|
|
127
|
-
excludePrefixes: string[] = ['_repl', '_streams', '_mq', '_admin', '_auth'];
|
|
129
|
+
excludePrefixes: string[] = ['_repl', '_repl_scoped', '_streams', '_mq', '_admin', '_auth'];
|
|
128
130
|
/** Bootstrap replica from primary's full state before applying _repl stream */
|
|
129
131
|
fullBootstrap: boolean = true;
|
|
130
132
|
compact?: CompactOptions;
|
|
@@ -133,6 +135,8 @@ export class ReplicationOptions {
|
|
|
133
135
|
sources?: ReplicationSource[];
|
|
134
136
|
/** Per-path topology: strings default to 'sync', objects specify mode. When absent, role governs all paths. */
|
|
135
137
|
paths?: Array<string | PathTopology>;
|
|
138
|
+
/** Path prefixes that get their own `_repl_scoped/<prefix>` stream. Auto-derived from paths/sources if not set. */
|
|
139
|
+
scopedPrefixes?: string[];
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
const BOOTSTRAP_BATCH_SIZE = 200;
|
|
@@ -142,6 +146,8 @@ type ProxyableMessage = Extract<ClientMessage, { op: 'set' | 'delete' | 'update'
|
|
|
142
146
|
export class ReplicationEngine {
|
|
143
147
|
readonly options: ReplicationOptions;
|
|
144
148
|
readonly router: PathTopologyRouter | null = null;
|
|
149
|
+
/** Resolved scoped prefixes (sorted longest-first for matching) */
|
|
150
|
+
private _scopedPrefixes: string[] = [];
|
|
145
151
|
private client: BodClient | null = null;
|
|
146
152
|
private unsubWrite: (() => void) | null = null;
|
|
147
153
|
private unsubStream: (() => void) | null = null;
|
|
@@ -193,6 +199,8 @@ export class ReplicationEngine {
|
|
|
193
199
|
if (this.options.paths?.length) {
|
|
194
200
|
this.router = new PathTopologyRouter(this.options.paths, this.options.role);
|
|
195
201
|
}
|
|
202
|
+
// Resolve scoped prefixes
|
|
203
|
+
this._scopedPrefixes = this._resolveScopedPrefixes();
|
|
196
204
|
if (!this.options.replicaId) {
|
|
197
205
|
// Derive stable ID from config when possible, fall back to random
|
|
198
206
|
if (this.options.primaryUrl) {
|
|
@@ -293,6 +301,29 @@ export class ReplicationEngine {
|
|
|
293
301
|
}
|
|
294
302
|
}
|
|
295
303
|
|
|
304
|
+
/** Derive scoped prefixes for primary emit. Only auto-derives from topology paths when role is 'primary'.
|
|
305
|
+
* Replicas must use explicit `scopedPrefixes` config since they don't control what the primary emits. */
|
|
306
|
+
private _resolveScopedPrefixes(): string[] {
|
|
307
|
+
if (this.options.scopedPrefixes) return [...this.options.scopedPrefixes].sort((a, b) => b.length - a.length);
|
|
308
|
+
// Only auto-derive for primary role — replica/sync nodes don't control primary's emit topics
|
|
309
|
+
if (this.options.role !== 'primary') return [];
|
|
310
|
+
const set = new Set<string>();
|
|
311
|
+
if (this.router) {
|
|
312
|
+
for (const e of this.router.getEntries()) {
|
|
313
|
+
if (e.path) set.add(e.path);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return [...set].sort((a, b) => b.length - a.length);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Longest-prefix match against scoped prefixes */
|
|
320
|
+
private _matchScopedPrefix(path: string): string | null {
|
|
321
|
+
for (const p of this._scopedPrefixes) {
|
|
322
|
+
if (path === p || path.startsWith(p + '/')) return p;
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
296
327
|
// --- Primary mode ---
|
|
297
328
|
|
|
298
329
|
private startPrimary(): void {
|
|
@@ -307,6 +338,9 @@ export class ReplicationEngine {
|
|
|
307
338
|
const compact = this.options.compact ?? { maxCount: 500, keepKey: 'path' };
|
|
308
339
|
if (compact.maxCount || compact.maxAge) {
|
|
309
340
|
try { this.db.stream.compact('_repl', compact); } catch {}
|
|
341
|
+
for (const prefix of this._scopedPrefixes) {
|
|
342
|
+
try { this.db.stream.compact(`_repl_scoped/${prefix}`, compact); } catch {}
|
|
343
|
+
}
|
|
310
344
|
}
|
|
311
345
|
}
|
|
312
346
|
|
|
@@ -324,7 +358,7 @@ export class ReplicationEngine {
|
|
|
324
358
|
|
|
325
359
|
/** Buffer replication events during transactions, emit immediately otherwise */
|
|
326
360
|
private emit(ev: WriteEvent): void {
|
|
327
|
-
// _repl writes must never emit
|
|
361
|
+
// _repl/_repl_scoped writes must never emit (infinite loop)
|
|
328
362
|
if (ev.path.startsWith('_repl')) return;
|
|
329
363
|
if (this._emitting) {
|
|
330
364
|
this.log.debug('emit: skipped (re-entrant)', { path: ev.path });
|
|
@@ -363,6 +397,19 @@ export class ReplicationEngine {
|
|
|
363
397
|
const idempotencyKey = `${replEvent.ts}:${seq}:${ev.path}`;
|
|
364
398
|
this.db.push('_repl', replEvent, { idempotencyKey });
|
|
365
399
|
this.log.info('_repl emit', { seq, op: ev.op, path: ev.path });
|
|
400
|
+
|
|
401
|
+
// Dual emit to scoped stream
|
|
402
|
+
const scopedPrefix = this._matchScopedPrefix(ev.path);
|
|
403
|
+
if (scopedPrefix) {
|
|
404
|
+
const scopedTopic = `_repl_scoped/${scopedPrefix}`;
|
|
405
|
+
const scopedKey = `${replEvent.ts}:${seq}:scoped:${ev.path}`;
|
|
406
|
+
try {
|
|
407
|
+
this.db.push(scopedTopic, replEvent, { idempotencyKey: scopedKey });
|
|
408
|
+
this.log.debug('scoped emit', { topic: scopedTopic, path: ev.path });
|
|
409
|
+
} catch (e: any) {
|
|
410
|
+
this.log.error('scoped emit failed', { topic: scopedTopic, error: e.message });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
366
413
|
} catch (e: any) {
|
|
367
414
|
this.log.error('_repl emit failed', { path: ev.path, error: e.message });
|
|
368
415
|
} finally {
|
|
@@ -375,6 +422,10 @@ export class ReplicationEngine {
|
|
|
375
422
|
this._emitCount = 0;
|
|
376
423
|
const compact = this.options.compact ?? { maxCount: 500, keepKey: 'path' };
|
|
377
424
|
try { this.db.stream.compact('_repl', compact); } catch {}
|
|
425
|
+
// Compact scoped streams too
|
|
426
|
+
for (const prefix of this._scopedPrefixes) {
|
|
427
|
+
try { this.db.stream.compact(`_repl_scoped/${prefix}`, compact); } catch {}
|
|
428
|
+
}
|
|
378
429
|
}
|
|
379
430
|
}
|
|
380
431
|
|
|
@@ -416,30 +467,67 @@ export class ReplicationEngine {
|
|
|
416
467
|
await this.bootstrapFullState(bootstrapPaths);
|
|
417
468
|
}
|
|
418
469
|
|
|
419
|
-
//
|
|
420
|
-
const applied = await this.bootstrapFromStream(this.client!, { filter: ev => this.matchesPathPrefixes(ev.path, pathPrefixes) });
|
|
421
|
-
this.log.info(`Stream bootstrap (paths): ${applied} events applied`);
|
|
422
|
-
|
|
423
|
-
// Subscribe to ongoing events, filter by paths
|
|
470
|
+
// Use scoped streams for paths that have them, fall back to filtered _repl
|
|
424
471
|
const groupId = this.options.replicaId!;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
472
|
+
const scopedSubs: Array<() => void> = [];
|
|
473
|
+
const unscopedPaths: string[] = [];
|
|
474
|
+
|
|
475
|
+
// Also bootstrap scoped paths from _repl (catches historical events from before scoped streams existed)
|
|
476
|
+
const scopedPathPrefixes = pathPrefixes.filter(p => this._scopedPrefixes.includes(p));
|
|
477
|
+
if (scopedPathPrefixes.length > 0) {
|
|
478
|
+
const fallbackApplied = await this.bootstrapFromStream(this.client!, { filter: ev => this.matchesPathPrefixes(ev.path, scopedPathPrefixes), groupId });
|
|
479
|
+
if (fallbackApplied > 0) this.log.info(`Scoped _repl fallback bootstrap: ${fallbackApplied} events`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const prefix of pathPrefixes) {
|
|
483
|
+
if (this._scopedPrefixes.includes(prefix)) {
|
|
484
|
+
const scopedTopic = `_repl_scoped/${prefix}`;
|
|
485
|
+
// Bootstrap from scoped stream (may apply duplicates from _repl fallback — applyEvent is idempotent via set/delete)
|
|
486
|
+
const applied = await this.bootstrapFromStream(this.client!, { streamTopic: scopedTopic, groupId });
|
|
487
|
+
this.log.info(`Scoped stream bootstrap: ${scopedTopic} → ${applied} events`);
|
|
488
|
+
|
|
489
|
+
// Subscribe to scoped stream (no filter needed)
|
|
490
|
+
const unsub = this.client!.stream(scopedTopic, groupId).on((events) => {
|
|
491
|
+
this.db.setReplaying(true);
|
|
492
|
+
try {
|
|
493
|
+
for (const e of events) {
|
|
494
|
+
this.applyEvent(e.val() as ReplEvent);
|
|
495
|
+
this.client!.stream(scopedTopic, groupId).ack(e.key).catch(() => {});
|
|
496
|
+
}
|
|
497
|
+
} finally {
|
|
498
|
+
this.db.setReplaying(false);
|
|
436
499
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
|
|
500
|
+
});
|
|
501
|
+
scopedSubs.push(unsub);
|
|
502
|
+
} else {
|
|
503
|
+
unscopedPaths.push(prefix);
|
|
441
504
|
}
|
|
442
|
-
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Fall back to _repl with filter for paths without scoped streams
|
|
508
|
+
if (unscopedPaths.length > 0) {
|
|
509
|
+
const applied = await this.bootstrapFromStream(this.client!, { filter: ev => this.matchesPathPrefixes(ev.path, unscopedPaths), groupId });
|
|
510
|
+
this.log.info(`Stream bootstrap (filtered _repl): ${applied} events for [${unscopedPaths.join(', ')}]`);
|
|
511
|
+
|
|
512
|
+
const unsub = this.client!.stream('_repl', groupId).on((events) => {
|
|
513
|
+
this.db.setReplaying(true);
|
|
514
|
+
try {
|
|
515
|
+
for (const e of events) {
|
|
516
|
+
const ev = e.val() as ReplEvent;
|
|
517
|
+
if (this.matchesPathPrefixes(ev.path, unscopedPaths)) {
|
|
518
|
+
this.applyEvent(ev);
|
|
519
|
+
}
|
|
520
|
+
this.client!.stream('_repl', groupId).ack(e.key).catch(() => {});
|
|
521
|
+
}
|
|
522
|
+
} finally {
|
|
523
|
+
this.db.setReplaying(false);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
scopedSubs.push(unsub);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Combine all unsub functions
|
|
530
|
+
this.unsubStream = () => { for (const u of scopedSubs) u(); };
|
|
443
531
|
}
|
|
444
532
|
|
|
445
533
|
/** Check if path matches any of the given prefixes */
|
|
@@ -464,11 +552,11 @@ export class ReplicationEngine {
|
|
|
464
552
|
}
|
|
465
553
|
|
|
466
554
|
// Stream bootstrap: cursor-based to avoid huge single response
|
|
467
|
-
const
|
|
555
|
+
const groupId = this.options.replicaId!;
|
|
556
|
+
const applied = await this.bootstrapFromStream(this.client!, { groupId });
|
|
468
557
|
this.log.info(`Stream bootstrap: ${applied} events applied`);
|
|
469
558
|
|
|
470
559
|
// Subscribe to ongoing events
|
|
471
|
-
const groupId = this.options.replicaId!;
|
|
472
560
|
this.log.info(`Subscribing to stream as '${groupId}'`);
|
|
473
561
|
this.unsubStream = this.client.stream('_repl', groupId).on((events) => {
|
|
474
562
|
this.log.debug(`Received ${events.length} events`);
|
|
@@ -529,28 +617,39 @@ export class ReplicationEngine {
|
|
|
529
617
|
const client = new BodClient({ url: source.url, auth: source.auth });
|
|
530
618
|
await client.connect();
|
|
531
619
|
|
|
532
|
-
|
|
620
|
+
const groupId = source.id || `source_${source.url}_${source.paths.sort().join('+')}`;
|
|
621
|
+
const connState = { client, unsub: () => {}, lastEventTs: Date.now(), eventsApplied: 0, pending: 0 };
|
|
622
|
+
|
|
623
|
+
// Use scoped stream if explicitly enabled and source has a single path
|
|
624
|
+
if (source.scopedStream && source.paths.length > 1) {
|
|
625
|
+
this.log.warn('scopedStream only supported for single-path sources, falling back to filtered _repl', { paths: source.paths });
|
|
626
|
+
}
|
|
627
|
+
const useScoped = source.scopedStream && source.paths.length === 1;
|
|
628
|
+
const topic = useScoped ? `_repl_scoped/${source.paths[0]}` : '_repl';
|
|
629
|
+
const needsFilter = !useScoped;
|
|
630
|
+
|
|
631
|
+
// Bootstrap
|
|
533
632
|
await this.bootstrapFromStream(client, {
|
|
534
|
-
filter: ev => this.matchesSourcePaths(ev.path, source),
|
|
633
|
+
filter: needsFilter ? (ev => this.matchesSourcePaths(ev.path, source)) : undefined,
|
|
535
634
|
source,
|
|
635
|
+
streamTopic: topic,
|
|
636
|
+
groupId,
|
|
536
637
|
});
|
|
537
638
|
|
|
538
639
|
// Subscribe to ongoing events
|
|
539
|
-
const
|
|
540
|
-
const connState = { client, unsub: () => {}, lastEventTs: Date.now(), eventsApplied: 0, pending: 0 };
|
|
541
|
-
const unsub = client.stream('_repl', groupId).on((events) => {
|
|
640
|
+
const unsub = client.stream(topic, groupId).on((events) => {
|
|
542
641
|
connState.pending += events.length;
|
|
543
642
|
this.db.setReplaying(true);
|
|
544
643
|
try {
|
|
545
644
|
for (const e of events) {
|
|
546
645
|
const ev = e.val() as ReplEvent;
|
|
547
646
|
connState.lastEventTs = ev.ts || Date.now();
|
|
548
|
-
if (this.matchesSourcePaths(ev.path, source)) {
|
|
647
|
+
if (!needsFilter || this.matchesSourcePaths(ev.path, source)) {
|
|
549
648
|
this.applyEvent(ev, source);
|
|
550
649
|
connState.eventsApplied++;
|
|
551
650
|
}
|
|
552
651
|
connState.pending--;
|
|
553
|
-
client.stream(
|
|
652
|
+
client.stream(topic, groupId).ack(e.key).catch(() => {});
|
|
554
653
|
}
|
|
555
654
|
} finally {
|
|
556
655
|
this.db.setReplaying(false);
|
|
@@ -570,8 +669,22 @@ export class ReplicationEngine {
|
|
|
570
669
|
return source.localPrefix ? `${source.localPrefix}/${path}` : path;
|
|
571
670
|
}
|
|
572
671
|
|
|
573
|
-
/** Cursor-based stream bootstrap: pages through _repl materialize to avoid huge single responses
|
|
574
|
-
|
|
672
|
+
/** Cursor-based stream bootstrap: pages through _repl (or scoped stream) materialize to avoid huge single responses.
|
|
673
|
+
* Skips bootstrap entirely when the consumer group already has a stored offset (subscribe handles incremental replay). */
|
|
674
|
+
private async bootstrapFromStream(client: BodClient, opts?: { filter?: (ev: ReplEvent) => boolean; source?: ReplicationSource; streamTopic?: string; groupId?: string }): Promise<number> {
|
|
675
|
+
const topic = opts?.streamTopic ?? '_repl';
|
|
676
|
+
const groupId = opts?.groupId;
|
|
677
|
+
|
|
678
|
+
// If consumer group has a stored offset, skip full bootstrap — subscribe() will replay incrementally from that offset
|
|
679
|
+
if (groupId) {
|
|
680
|
+
const offsetPath = `_streams/${topic}/groups/${groupId}/offset`;
|
|
681
|
+
const existingOffset = await client.get(offsetPath);
|
|
682
|
+
if (existingOffset != null) {
|
|
683
|
+
this.log.info(`Skipping bootstrap for ${topic} (group '${groupId}' has offset, subscribe will resume)`);
|
|
684
|
+
return 0;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
575
688
|
let cursor: string | undefined;
|
|
576
689
|
let applied = 0;
|
|
577
690
|
const filter = opts?.filter;
|
|
@@ -579,7 +692,7 @@ export class ReplicationEngine {
|
|
|
579
692
|
this.db.setReplaying(true);
|
|
580
693
|
try {
|
|
581
694
|
do {
|
|
582
|
-
const page = await client.streamMaterialize(
|
|
695
|
+
const page = await client.streamMaterialize(topic, { keepKey: 'path', batchSize: BOOTSTRAP_BATCH_SIZE, cursor });
|
|
583
696
|
if (page.data) {
|
|
584
697
|
for (const [, event] of Object.entries(page.data)) {
|
|
585
698
|
const ev = event as ReplEvent;
|
|
@@ -128,9 +128,9 @@ export class StreamEngine {
|
|
|
128
128
|
}
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
// 2. Fetch replay batch
|
|
131
|
+
// 2. Fetch replay batch (use large limit to ensure all missed events are replayed for catch-up)
|
|
132
132
|
const offset = this.getOffset(topic, groupId);
|
|
133
|
-
const replayBatch = this.storage.queryAfterKey(topic, offset,
|
|
133
|
+
const replayBatch = this.storage.queryAfterKey(topic, offset, Number.MAX_SAFE_INTEGER);
|
|
134
134
|
|
|
135
135
|
// 3. Send replay batch
|
|
136
136
|
if (replayBatch.length > 0) {
|
package/src/server/Transport.ts
CHANGED
|
@@ -30,6 +30,8 @@ export class TransportOptions {
|
|
|
30
30
|
keyAuth?: KeyAuthEngine;
|
|
31
31
|
/** Map URL paths to local file paths, e.g. { '/admin': './admin/ui.html' } */
|
|
32
32
|
staticRoutes?: Record<string, string>;
|
|
33
|
+
/** Map URL path prefixes to local directories, e.g. { '/assets': './admin/dist/assets' } */
|
|
34
|
+
staticDirs?: Record<string, string>;
|
|
33
35
|
/** Custom REST routes checked before 404. Return null to fall through. */
|
|
34
36
|
extraRoutes?: Record<string, (req: Request, url: URL) => Response | Promise<Response> | null>;
|
|
35
37
|
/** Maximum concurrent WebSocket connections (default 10000) */
|
|
@@ -52,6 +54,11 @@ export class Transport {
|
|
|
52
54
|
private _log: ComponentLogger;
|
|
53
55
|
get clientCount(): number { return this._clients.size; }
|
|
54
56
|
|
|
57
|
+
/** Returns true if any WS client is subscribed to _admin/stats or _admin. */
|
|
58
|
+
get hasStatsSubscribers(): boolean {
|
|
59
|
+
return !!(this._valueGroups.get('_admin/stats')?.size || this._valueGroups.get('_admin')?.size);
|
|
60
|
+
}
|
|
61
|
+
|
|
55
62
|
/** Send pre-serialized stats directly to subscribed WS clients, bypassing SubscriptionEngine. */
|
|
56
63
|
broadcastStats(data: unknown, updatedAt: number): void {
|
|
57
64
|
// Check both exact path and ancestor subscribers
|
|
@@ -378,6 +385,16 @@ export class Transport {
|
|
|
378
385
|
if (filePath) return new Response(Bun.file(filePath));
|
|
379
386
|
}
|
|
380
387
|
|
|
388
|
+
// Static dirs (prefix-based, for SPA assets)
|
|
389
|
+
if (this.options.staticDirs) {
|
|
390
|
+
for (const [prefix, dir] of Object.entries(this.options.staticDirs)) {
|
|
391
|
+
if (url.pathname.startsWith(prefix)) {
|
|
392
|
+
const rel = url.pathname.slice(prefix.length);
|
|
393
|
+
return new Response(Bun.file(`${dir}${rel}`));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
381
398
|
// Extra routes (custom REST handlers)
|
|
382
399
|
if (this.options.extraRoutes) {
|
|
383
400
|
for (const [pattern, handler] of Object.entries(this.options.extraRoutes)) {
|
|
@@ -276,7 +276,7 @@ describe('ReplicationEngine', () => {
|
|
|
276
276
|
|
|
277
277
|
// --- Source feed subscription tests ---
|
|
278
278
|
|
|
279
|
-
function createSourceLocal(sources: Array<{ url: string; paths: string[]; localPrefix?: string; excludePrefixes?: string[] }>) {
|
|
279
|
+
function createSourceLocal(sources: Array<{ url: string; paths: string[]; localPrefix?: string; excludePrefixes?: string[]; scopedStream?: boolean }>) {
|
|
280
280
|
const port = getPort();
|
|
281
281
|
const db = new BodDB({
|
|
282
282
|
path: ':memory:',
|
|
@@ -393,6 +393,167 @@ describe('ReplicationEngine', () => {
|
|
|
393
393
|
expect(local.get('src/users/u1')).toBeNull();
|
|
394
394
|
});
|
|
395
395
|
|
|
396
|
+
// --- Scoped replication stream tests ---
|
|
397
|
+
|
|
398
|
+
function createScopedPrimary(scopedPrefixes: string[]) {
|
|
399
|
+
const port = getPort();
|
|
400
|
+
const db = new BodDB({
|
|
401
|
+
path: ':memory:',
|
|
402
|
+
sweepInterval: 0,
|
|
403
|
+
replication: { role: 'primary', scopedPrefixes },
|
|
404
|
+
});
|
|
405
|
+
db.replication!.start();
|
|
406
|
+
db.serve({ port });
|
|
407
|
+
instances.push(db);
|
|
408
|
+
return { db, port };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
it('scoped primary dual-emits to _repl and _repl_scoped/<prefix>', async () => {
|
|
412
|
+
const { db } = createScopedPrimary(['users']);
|
|
413
|
+
db.set('users/u1', { name: 'Eli' });
|
|
414
|
+
db.set('orders/o1', { total: 100 });
|
|
415
|
+
await tick();
|
|
416
|
+
|
|
417
|
+
// Both events in _repl
|
|
418
|
+
const replData = db.get('_repl') as Record<string, any>;
|
|
419
|
+
const replPaths = Object.values(replData).map((e: any) => e.path);
|
|
420
|
+
expect(replPaths).toContain('users/u1');
|
|
421
|
+
expect(replPaths).toContain('orders/o1');
|
|
422
|
+
|
|
423
|
+
// Only users event in scoped stream
|
|
424
|
+
const scopedData = db.get('_repl_scoped/users') as Record<string, any>;
|
|
425
|
+
const scopedPaths = Object.values(scopedData).map((e: any) => e.path);
|
|
426
|
+
expect(scopedPaths).toContain('users/u1');
|
|
427
|
+
expect(scopedPaths).not.toContain('orders/o1');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('source with scopedStream uses _repl_scoped instead of filtering _repl', async () => {
|
|
431
|
+
const { db: remote, port: rPort } = createScopedPrimary(['users']);
|
|
432
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
433
|
+
remote.set('orders/o1', { total: 100 });
|
|
434
|
+
|
|
435
|
+
const { db: local } = createSourceLocal([
|
|
436
|
+
{ url: `ws://localhost:${rPort}`, paths: ['users'], scopedStream: true },
|
|
437
|
+
]);
|
|
438
|
+
await local.replication!.start();
|
|
439
|
+
await wait(500);
|
|
440
|
+
|
|
441
|
+
expect(local.get('users/u1')).toEqual({ name: 'Eli' });
|
|
442
|
+
expect(local.get('orders/o1')).toBeNull();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('source with scopedStream receives ongoing events', async () => {
|
|
446
|
+
const { db: remote, port: rPort } = createScopedPrimary(['users']);
|
|
447
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
448
|
+
|
|
449
|
+
const { db: local } = createSourceLocal([
|
|
450
|
+
{ url: `ws://localhost:${rPort}`, paths: ['users'], scopedStream: true },
|
|
451
|
+
]);
|
|
452
|
+
await local.replication!.start();
|
|
453
|
+
await wait(500);
|
|
454
|
+
|
|
455
|
+
expect(local.get('users/u1')).toEqual({ name: 'Eli' });
|
|
456
|
+
|
|
457
|
+
remote.set('users/u2', { name: 'Dan' });
|
|
458
|
+
await wait(500);
|
|
459
|
+
expect(local.get('users/u2')).toEqual({ name: 'Dan' });
|
|
460
|
+
|
|
461
|
+
// Non-matching writes should not appear
|
|
462
|
+
remote.set('orders/o1', { total: 50 });
|
|
463
|
+
await wait(500);
|
|
464
|
+
expect(local.get('orders/o1')).toBeNull();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('whole-DB replica still works unchanged on _repl', async () => {
|
|
468
|
+
const { db: primary, port: pPort } = createScopedPrimary(['users']);
|
|
469
|
+
primary.set('users/u1', { name: 'Eli' });
|
|
470
|
+
primary.set('orders/o1', { total: 100 });
|
|
471
|
+
|
|
472
|
+
const { db: replica } = createReplica(pPort);
|
|
473
|
+
await replica.replication!.start();
|
|
474
|
+
await wait(500);
|
|
475
|
+
|
|
476
|
+
expect(replica.get('users/u1')).toEqual({ name: 'Eli' });
|
|
477
|
+
expect(replica.get('orders/o1')).toEqual({ total: 100 });
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('scoped compaction runs on _repl_scoped streams', async () => {
|
|
481
|
+
const { db } = createScopedPrimary(['users']);
|
|
482
|
+
// Write enough to trigger auto-compact threshold
|
|
483
|
+
for (let i = 0; i < 5; i++) db.set(`users/u${i}`, { n: i });
|
|
484
|
+
await tick();
|
|
485
|
+
|
|
486
|
+
const scopedData = db.get('_repl_scoped/users') as Record<string, any>;
|
|
487
|
+
expect(Object.keys(scopedData).length).toBeGreaterThan(0);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('per-path topology replica uses scoped streams', async () => {
|
|
491
|
+
const pPort = getPort();
|
|
492
|
+
const primary = new BodDB({
|
|
493
|
+
path: ':memory:',
|
|
494
|
+
sweepInterval: 0,
|
|
495
|
+
replication: { role: 'primary', scopedPrefixes: ['users', 'orders'] },
|
|
496
|
+
});
|
|
497
|
+
primary.replication!.start();
|
|
498
|
+
primary.serve({ port: pPort });
|
|
499
|
+
instances.push(primary);
|
|
500
|
+
|
|
501
|
+
primary.set('users/u1', { name: 'Eli' });
|
|
502
|
+
primary.set('orders/o1', { total: 100 });
|
|
503
|
+
primary.set('config/theme', 'dark');
|
|
504
|
+
|
|
505
|
+
const rPort = getPort();
|
|
506
|
+
const replica = new BodDB({
|
|
507
|
+
path: ':memory:',
|
|
508
|
+
sweepInterval: 0,
|
|
509
|
+
replication: {
|
|
510
|
+
role: 'replica',
|
|
511
|
+
primaryUrl: `ws://localhost:${pPort}`,
|
|
512
|
+
replicaId: `test-scoped-topo-${rPort}`,
|
|
513
|
+
paths: [{ path: 'users', mode: 'replica' as const }],
|
|
514
|
+
scopedPrefixes: ['users'],
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
replica.serve({ port: rPort });
|
|
518
|
+
instances.push(replica);
|
|
519
|
+
await replica.replication!.start();
|
|
520
|
+
await wait(500);
|
|
521
|
+
|
|
522
|
+
expect(replica.get('users/u1')).toEqual({ name: 'Eli' });
|
|
523
|
+
// orders not in topology — should not be replicated
|
|
524
|
+
expect(replica.get('orders/o1')).toBeNull();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('multi-path source with scopedStream falls back to filtered _repl', async () => {
|
|
528
|
+
const { db: remote, port: rPort } = createScopedPrimary(['users', 'orders']);
|
|
529
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
530
|
+
remote.set('orders/o1', { total: 100 });
|
|
531
|
+
|
|
532
|
+
const { db: local } = createSourceLocal([
|
|
533
|
+
{ url: `ws://localhost:${rPort}`, paths: ['users', 'orders'], scopedStream: true },
|
|
534
|
+
]);
|
|
535
|
+
await local.replication!.start();
|
|
536
|
+
await wait(500);
|
|
537
|
+
|
|
538
|
+
// Should still work via _repl fallback
|
|
539
|
+
expect(local.get('users/u1')).toEqual({ name: 'Eli' });
|
|
540
|
+
expect(local.get('orders/o1')).toEqual({ total: 100 });
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('source with scopedStream and localPrefix remaps correctly', async () => {
|
|
544
|
+
const { db: remote, port: rPort } = createScopedPrimary(['users']);
|
|
545
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
546
|
+
|
|
547
|
+
const { db: local } = createSourceLocal([
|
|
548
|
+
{ url: `ws://localhost:${rPort}`, paths: ['users'], scopedStream: true, localPrefix: 'ext' },
|
|
549
|
+
]);
|
|
550
|
+
await local.replication!.start();
|
|
551
|
+
await wait(500);
|
|
552
|
+
|
|
553
|
+
expect(local.get('ext/users/u1')).toEqual({ name: 'Eli' });
|
|
554
|
+
expect(local.get('users/u1')).toBeNull();
|
|
555
|
+
});
|
|
556
|
+
|
|
396
557
|
it('stop() disconnects source clients', async () => {
|
|
397
558
|
const { db: remote, port: rPort } = createPrimary();
|
|
398
559
|
remote.set('users/u1', { name: 'Eli' });
|