@bod.ee/db 0.11.1 → 0.12.2

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.
@@ -159,7 +159,14 @@ export class Transport {
159
159
  if (this.options.keyAuth && path.startsWith(AUTH_PREFIX)) {
160
160
  return Response.json({ ok: false, error: `Write to ${AUTH_PREFIX} is reserved`, code: Errors.PERMISSION_DENIED }, { status: 403 });
161
161
  }
162
- if (this.db.replication?.isReplica) {
162
+ const auth = await this.extractAuth(req);
163
+ if (this.rules && !this.rules.check('write', path, auth)) {
164
+ return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
165
+ }
166
+ if (this.db.replication?.shouldRejectPath(path)) {
167
+ return Response.json({ ok: false, error: 'Path is readonly', code: Errors.PERMISSION_DENIED }, { status: 403 });
168
+ }
169
+ if (this.db.replication?.shouldProxyPath(path)) {
163
170
  try {
164
171
  const body = await req.json();
165
172
  await this.db.replication.proxyWrite({ op: 'set', path, value: body });
@@ -168,10 +175,6 @@ export class Transport {
168
175
  return Response.json({ ok: false, error: e.message, code: Errors.INTERNAL }, { status: 500 });
169
176
  }
170
177
  }
171
- const auth = await this.extractAuth(req);
172
- if (this.rules && !this.rules.check('write', path, auth)) {
173
- return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
174
- }
175
178
  const body = await req.json();
176
179
  this.db.set(path, body);
177
180
  return Response.json({ ok: true });
@@ -185,7 +188,14 @@ export class Transport {
185
188
  if (this.options.keyAuth && path.startsWith(AUTH_PREFIX)) {
186
189
  return Response.json({ ok: false, error: `Write to ${AUTH_PREFIX} is reserved`, code: Errors.PERMISSION_DENIED }, { status: 403 });
187
190
  }
188
- if (this.db.replication?.isReplica) {
191
+ const auth = await this.extractAuth(req);
192
+ if (this.rules && !this.rules.check('write', path, auth)) {
193
+ return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
194
+ }
195
+ if (this.db.replication?.shouldRejectPath(path)) {
196
+ return Response.json({ ok: false, error: 'Path is readonly', code: Errors.PERMISSION_DENIED }, { status: 403 });
197
+ }
198
+ if (this.db.replication?.shouldProxyPath(path)) {
189
199
  try {
190
200
  await this.db.replication.proxyWrite({ op: 'delete', path });
191
201
  return Response.json({ ok: true });
@@ -193,10 +203,6 @@ export class Transport {
193
203
  return Response.json({ ok: false, error: e.message, code: Errors.INTERNAL }, { status: 500 });
194
204
  }
195
205
  }
196
- const auth = await this.extractAuth(req);
197
- if (this.rules && !this.rules.check('write', path, auth)) {
198
- return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
199
- }
200
206
  this.db.delete(path);
201
207
  return Response.json({ ok: true });
202
208
  })();
@@ -418,13 +424,14 @@ export class Transport {
418
424
 
419
425
  case 'set': {
420
426
  if (guardAuthPrefix(msg.path)) return;
421
- if (self.db.replication?.isReplica) {
422
- try { return reply(await self.db.replication.proxyWrite(msg)); }
423
- catch (e: any) { return error(e.message, Errors.INTERNAL); }
424
- }
425
427
  if (self.rules && !self.rules.check('write', msg.path, ws.data.auth, self.db.get(msg.path), msg.value)) {
426
428
  return error('Permission denied', Errors.PERMISSION_DENIED);
427
429
  }
430
+ if (self.db.replication?.shouldRejectPath(msg.path)) return error('Path is readonly', Errors.PERMISSION_DENIED);
431
+ if (self.db.replication?.shouldProxyPath(msg.path)) {
432
+ try { return reply(await self.db.replication.proxyWrite(msg)); }
433
+ catch (e: any) { return error(e.message, Errors.INTERNAL); }
434
+ }
428
435
  self.db.set(msg.path, msg.value, msg.ttl ? { ttl: msg.ttl } : undefined);
429
436
  return reply(null);
430
437
  }
@@ -433,28 +440,34 @@ export class Transport {
433
440
  for (const path of Object.keys(msg.updates)) {
434
441
  if (guardAuthPrefix(path)) return;
435
442
  }
436
- if (self.db.replication?.isReplica) {
437
- try { return reply(await self.db.replication.proxyWrite(msg)); }
438
- catch (e: any) { return error(e.message, Errors.INTERNAL); }
439
- }
440
443
  for (const path of Object.keys(msg.updates)) {
441
444
  if (self.rules && !self.rules.check('write', path, ws.data.auth)) {
442
445
  return error(`Permission denied for ${path}`, Errors.PERMISSION_DENIED);
443
446
  }
444
447
  }
448
+ // Per-path: if ANY path needs reject, reject; if ANY needs proxy, proxy all (known limitation: greedy)
449
+ if (self.db.replication) {
450
+ const paths = Object.keys(msg.updates);
451
+ if (paths.some(p => self.db.replication!.shouldRejectPath(p))) return error('Path is readonly', Errors.PERMISSION_DENIED);
452
+ if (paths.some(p => self.db.replication!.shouldProxyPath(p))) {
453
+ try { return reply(await self.db.replication.proxyWrite(msg)); }
454
+ catch (e: any) { return error(e.message, Errors.INTERNAL); }
455
+ }
456
+ }
445
457
  self.db.update(msg.updates);
446
458
  return reply(null);
447
459
  }
448
460
 
449
461
  case 'delete': {
450
462
  if (guardAuthPrefix(msg.path)) return;
451
- if (self.db.replication?.isReplica) {
452
- try { return reply(await self.db.replication.proxyWrite(msg)); }
453
- catch (e: any) { return error(e.message, Errors.INTERNAL); }
454
- }
455
463
  if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
456
464
  return error('Permission denied', Errors.PERMISSION_DENIED);
457
465
  }
466
+ if (self.db.replication?.shouldRejectPath(msg.path)) return error('Path is readonly', Errors.PERMISSION_DENIED);
467
+ if (self.db.replication?.shouldProxyPath(msg.path)) {
468
+ try { return reply(await self.db.replication.proxyWrite(msg)); }
469
+ catch (e: any) { return error(e.message, Errors.INTERNAL); }
470
+ }
458
471
  self.db.delete(msg.path);
459
472
  return reply(null);
460
473
  }
@@ -502,9 +515,23 @@ export class Transport {
502
515
  }
503
516
 
504
517
  case 'batch': {
505
- if (self.db.replication?.isReplica) {
506
- try { return reply(await self.db.replication.proxyWrite(msg)); }
507
- catch (e: any) { return error(e.message, Errors.INTERNAL); }
518
+ // Upfront rules check before proxy (defense-in-depth)
519
+ for (const batchOp of msg.operations) {
520
+ const opPaths = batchOp.op === 'update' ? Object.keys(batchOp.updates) : [batchOp.path];
521
+ for (const p of opPaths) {
522
+ if (self.rules && !self.rules.check('write', p, ws.data.auth)) {
523
+ return error(`Permission denied for ${p}`, Errors.PERMISSION_DENIED);
524
+ }
525
+ }
526
+ }
527
+ // Per-path topology: if ANY path needs reject, reject; if ANY needs proxy, proxy all (known limitation: greedy)
528
+ if (self.db.replication) {
529
+ const batchPaths = msg.operations.map((o: any) => o.path != null ? [o.path] : (o.updates ? Object.keys(o.updates) : [])).flat();
530
+ if (batchPaths.some((p: string) => self.db.replication!.shouldRejectPath(p))) return error('Path is readonly', Errors.PERMISSION_DENIED);
531
+ if (batchPaths.some((p: string) => self.db.replication!.shouldProxyPath(p))) {
532
+ try { return reply(await self.db.replication.proxyWrite(msg)); }
533
+ catch (e: any) { return error(e.message, Errors.INTERNAL); }
534
+ }
508
535
  }
509
536
  const results: unknown[] = [];
510
537
  self.db.transaction((tx) => {
@@ -546,13 +573,14 @@ export class Transport {
546
573
 
547
574
  case 'push': {
548
575
  if (guardAuthPrefix(msg.path)) return;
549
- if (self.db.replication?.isReplica) {
550
- try { return reply(await self.db.replication.proxyWrite(msg)); }
551
- catch (e: any) { return error(e.message, Errors.INTERNAL); }
552
- }
553
576
  if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
554
577
  return error('Permission denied', Errors.PERMISSION_DENIED);
555
578
  }
579
+ if (self.db.replication?.shouldRejectPath(msg.path)) return error('Path is readonly', Errors.PERMISSION_DENIED);
580
+ if (self.db.replication?.shouldProxyPath(msg.path)) {
581
+ try { return reply(await self.db.replication.proxyWrite(msg)); }
582
+ catch (e: any) { return error(e.message, Errors.INTERNAL); }
583
+ }
556
584
  const key = self.db.push(msg.path, msg.value, msg.idempotencyKey ? { idempotencyKey: msg.idempotencyKey } : undefined);
557
585
  return reply(key);
558
586
  }
@@ -753,6 +781,14 @@ export class Transport {
753
781
  }
754
782
  return reply(self.db.vfs.list(msg.path));
755
783
  }
784
+ case 'vfs-tree': {
785
+ if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
786
+ if (self.rules && !self.rules.check('read', `_vfs/${msg.path}`, ws.data.auth)) {
787
+ return error('Permission denied', Errors.PERMISSION_DENIED);
788
+ }
789
+ const hiddenPaths = msg.hiddenPaths ? new Set(msg.hiddenPaths) : undefined;
790
+ return reply(self.db.vfs.tree(msg.path, { hiddenPaths, hideDotfiles: msg.hideDotfiles }));
791
+ }
756
792
  case 'vfs-delete': {
757
793
  if (!self.db.vfs) return error('VFS not configured', Errors.INTERNAL);
758
794
  if (self.rules && !self.rules.check('write', `_vfs/${msg.path}`, ws.data.auth)) {
@@ -133,9 +133,14 @@ export class VFSEngine {
133
133
 
134
134
  async write(virtualPath: string, data: Uint8Array, mime?: string): Promise<FileStat> {
135
135
  const vp = normalizePath(virtualPath);
136
- const existing = this.db.get(this.metaPath(vp)) as Record<string, unknown> | null;
136
+ const existing = this.db.get(this.metaPath(vp)) as FileStat | null;
137
137
  const fileId = this.options.pathAsFileId ? vp : ((existing?.fileId as string) || generatePushId());
138
138
 
139
+ const hash = await computeHash(data);
140
+
141
+ // Skip write entirely if content hasn't changed (prevents unnecessary replication events)
142
+ if (existing?.hash === hash) return existing;
143
+
139
144
  await this.backend.write(fileId, data);
140
145
 
141
146
  const name = vp.split('/').pop()!;
@@ -147,6 +152,7 @@ export class VFSEngine {
147
152
  mtime: Date.now(),
148
153
  fileId,
149
154
  isDir: false,
155
+ hash,
150
156
  };
151
157
  this.db.set(this.metaPath(vp), stat);
152
158
  return stat;
@@ -183,6 +189,33 @@ export class VFSEngine {
183
189
  return results;
184
190
  }
185
191
 
192
+ tree(virtualPath: string, opts?: { hiddenPaths?: Set<string>; hideDotfiles?: boolean }): any[] {
193
+ const hiddenPaths = opts?.hiddenPaths ?? new Set<string>();
194
+ const hideDotfiles = opts?.hideDotfiles ?? false;
195
+
196
+ const walk = (vPath: string): any[] => {
197
+ const stats = this.list(vPath);
198
+ stats.sort((a, b) => {
199
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
200
+ return a.name.localeCompare(b.name);
201
+ });
202
+ const result: any[] = [];
203
+ for (const s of stats) {
204
+ if (hiddenPaths.has(s.name)) continue;
205
+ if (hideDotfiles && s.name.startsWith('.')) continue;
206
+ if (s.isDir) {
207
+ const childPath = vPath ? `${vPath}/${s.name}` : s.name;
208
+ result.push({ name: s.name, type: 'directory', children: walk(childPath) });
209
+ } else {
210
+ result.push({ name: s.name, type: 'file' });
211
+ }
212
+ }
213
+ return result;
214
+ };
215
+
216
+ return walk(normalizePath(virtualPath));
217
+ }
218
+
186
219
  mkdir(virtualPath: string): FileStat {
187
220
  const vp = normalizePath(virtualPath);
188
221
  const name = vp.split('/').pop()!;
@@ -224,13 +257,18 @@ export class VFSEngine {
224
257
 
225
258
  const newName = dstPath.split('/').pop()!;
226
259
  const fileId = this.options.pathAsFileId ? dstPath : meta.fileId;
227
- const updated: FileStat = { ...meta, name: newName, path: dstPath, fileId, mtime: Date.now() };
260
+ const updated: FileStat = { ...meta, name: newName, path: dstPath, fileId, mtime: Date.now(), hash: meta.hash };
228
261
  this.db.set(this.metaPath(dstPath), updated);
229
262
  this.db.delete(this.containerPath(srcPath));
230
263
  return updated;
231
264
  }
232
265
  }
233
266
 
267
+ async function computeHash(data: Uint8Array): Promise<string> {
268
+ const digest = await crypto.subtle.digest('SHA-256', data);
269
+ return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
270
+ }
271
+
234
272
  function guessMime(name: string): string {
235
273
  const ext = name.split('.').pop()?.toLowerCase();
236
274
  const mimes: Record<string, string> = {
@@ -36,6 +36,7 @@ export type ClientMessage =
36
36
  | { id: string; op: 'vfs-download-init'; path: string }
37
37
  | { id: string; op: 'vfs-stat'; path: string }
38
38
  | { id: string; op: 'vfs-list'; path: string }
39
+ | { id: string; op: 'vfs-tree'; path: string; hiddenPaths?: string[]; hideDotfiles?: boolean }
39
40
  | { id: string; op: 'vfs-delete'; path: string }
40
41
  | { id: string; op: 'vfs-mkdir'; path: string }
41
42
  | { id: string; op: 'vfs-move'; path: string; dst: string }
@@ -100,6 +101,7 @@ export interface FileStat {
100
101
  mtime: number;
101
102
  fileId?: string;
102
103
  isDir: boolean;
104
+ hash?: string;
103
105
  }
104
106
 
105
107
  export type BatchOp =
@@ -0,0 +1,270 @@
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
+
5
+ const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
6
+ let nextPort = 26400 + Math.floor(Math.random() * 1000);
7
+
8
+ /**
9
+ * Stress tests for _repl stream bloat.
10
+ *
11
+ * The real-world issue: _repl grows unbounded → streamMaterialize produces
12
+ * a massive WS response → client requestTimeout (30s) fires or WS chokes.
13
+ *
14
+ * Locally we can't easily reproduce network latency, but we CAN:
15
+ * 1. Push the entry count + payload size to stress serialization/parsing
16
+ * 2. Use short requestTimeout on the replica client to simulate the real failure
17
+ * 3. Measure materialization time scaling (O(n) proof)
18
+ * 4. Verify compaction actually fixes it
19
+ */
20
+ describe('_repl stream bloat', () => {
21
+ const instances: BodDB[] = [];
22
+ const clients: BodClient[] = [];
23
+
24
+ afterEach(() => {
25
+ for (const c of clients) c.disconnect();
26
+ clients.length = 0;
27
+ for (const db of [...instances].reverse()) db.close();
28
+ instances.length = 0;
29
+ });
30
+
31
+ function getPort() { return nextPort++; }
32
+
33
+ /** Primary with auto-compact DISABLED so _repl grows unbounded */
34
+ function createPrimary(opts?: { maxMessageSize?: number }) {
35
+ const port = getPort();
36
+ const db = new BodDB({
37
+ path: ':memory:',
38
+ sweepInterval: 0,
39
+ replication: { role: 'primary', compact: {} },
40
+ });
41
+ db.replication!.start();
42
+ db.serve({ port, maxMessageSize: opts?.maxMessageSize });
43
+ instances.push(db);
44
+ return { db, port };
45
+ }
46
+
47
+ function createReplica(primaryPort: number) {
48
+ const port = getPort();
49
+ const db = new BodDB({
50
+ path: ':memory:',
51
+ sweepInterval: 0,
52
+ replication: {
53
+ role: 'replica',
54
+ primaryUrl: `ws://localhost:${primaryPort}`,
55
+ replicaId: `bloat-replica-${port}`,
56
+ },
57
+ });
58
+ db.serve({ port });
59
+ instances.push(db);
60
+ return { db, port };
61
+ }
62
+
63
+ // Helper: fill _repl with N entries, each ~payloadBytes
64
+ function fillRepl(db: BodDB, count: number, payloadBytes: number) {
65
+ const padding = 'x'.repeat(Math.max(0, payloadBytes - 100));
66
+ for (let i = 0; i < count; i++) {
67
+ db.set(`vfs/files/project/src/deep/nested/path/module${i}/component.tsx`, {
68
+ size: 1024 + i,
69
+ mime: 'application/typescript',
70
+ mtime: Date.now(),
71
+ fileId: `fid_${i}_${padding}`,
72
+ hash: `sha256_${i.toString(16).padStart(64, '0')}`,
73
+ isDir: false,
74
+ metadata: { author: 'test', tags: ['a', 'b', 'c'], version: i },
75
+ });
76
+ }
77
+ }
78
+
79
+ // --- Accumulation ---
80
+
81
+ it('_repl grows unbounded without compaction', () => {
82
+ const { db } = createPrimary();
83
+ fillRepl(db, 5000, 300);
84
+ const repl = db.get('_repl') as Record<string, any>;
85
+ expect(Object.keys(repl).length).toBe(5000);
86
+ });
87
+
88
+ // --- Scaling: measure materialize time at increasing sizes ---
89
+
90
+ it('materialize time scales linearly (O(n) proof)', async () => {
91
+ const sizes = [500, 2000, 5000];
92
+ const timings: { count: number; ms: number; bytes: number }[] = [];
93
+
94
+ for (const count of sizes) {
95
+ const { db, port } = createPrimary();
96
+ fillRepl(db, count, 500);
97
+
98
+ const client = new BodClient({ url: `ws://localhost:${port}` });
99
+ clients.push(client);
100
+ await client.connect();
101
+
102
+ const start = Date.now();
103
+ const result = await client.streamMaterialize('_repl', { keepKey: 'path' });
104
+ const elapsed = Date.now() - start;
105
+
106
+ const json = JSON.stringify(result);
107
+ timings.push({ count, ms: elapsed, bytes: json.length });
108
+ }
109
+
110
+ for (const t of timings) {
111
+ console.log(` ${t.count} entries → ${t.ms}ms, ${(t.bytes / 1024 / 1024).toFixed(2)}MB response`);
112
+ }
113
+
114
+ // Response size should scale roughly linearly
115
+ const ratio = timings[2].bytes / timings[0].bytes;
116
+ console.log(` Size ratio (${sizes[2]}/${sizes[0]}): ${ratio.toFixed(1)}x (expect ~${(sizes[2] / sizes[0]).toFixed(1)}x)`);
117
+ expect(ratio).toBeGreaterThan(5); // 5000/500 = 10x, allow some dedup
118
+ });
119
+
120
+ // --- Timeout reproduction: short requestTimeout simulates real-world failure ---
121
+
122
+ it('short requestTimeout causes streamMaterialize to fail on bloated _repl', async () => {
123
+ const { db: primary, port: primaryPort } = createPrimary();
124
+ fillRepl(primary, 10000, 1000); // 10k entries × 1KB = ~10MB response
125
+
126
+ const replCount = Object.keys(primary.get('_repl') as Record<string, any>).length;
127
+ expect(replCount).toBe(10000);
128
+
129
+ // Direct client with 1ms timeout — guaranteed to fail, proving the
130
+ // timeout path exists and that materialize has no retry/fallback
131
+ const client = new BodClient({
132
+ url: `ws://localhost:${primaryPort}`,
133
+ requestTimeout: 1,
134
+ });
135
+ clients.push(client);
136
+ await client.connect();
137
+
138
+ let error: Error | null = null;
139
+ try {
140
+ await client.streamMaterialize('_repl', { keepKey: 'path' });
141
+ } catch (e) {
142
+ error = e as Error;
143
+ }
144
+
145
+ expect(error).not.toBeNull();
146
+ console.log(` REPRODUCED: streamMaterialize failed: ${error!.message}`);
147
+ expect(error!.message).toContain('timeout');
148
+ });
149
+
150
+ it('streamMaterialize response exceeds typical WS comfort zone', async () => {
151
+ const { db, port } = createPrimary();
152
+ fillRepl(db, 10000, 1000);
153
+
154
+ const client = new BodClient({ url: `ws://localhost:${port}` });
155
+ clients.push(client);
156
+ await client.connect();
157
+
158
+ const start = Date.now();
159
+ const result = await client.streamMaterialize('_repl', { keepKey: 'path' });
160
+ const elapsed = Date.now() - start;
161
+ const responseSize = JSON.stringify(result).length;
162
+
163
+ console.log(` 10k entries × 1KB: ${(responseSize / 1024 / 1024).toFixed(1)}MB response in ${elapsed}ms`);
164
+ // On real networks with latency, this 10MB+ response would easily exceed 30s timeout
165
+ expect(responseSize).toBeGreaterThan(5 * 1024 * 1024); // >5MB
166
+ });
167
+
168
+ // --- Payload size bomb: fewer entries but huge payloads ---
169
+
170
+ it('large payloads per entry amplify the problem', async () => {
171
+ const { db, port } = createPrimary();
172
+
173
+ // 1000 entries but 2KB each → ~2MB+ materialize response
174
+ const bigPadding = 'y'.repeat(2000);
175
+ for (let i = 0; i < 1000; i++) {
176
+ db.set(`data/big${i}`, {
177
+ content: bigPadding,
178
+ nested: { deep: { value: bigPadding.slice(0, 500) } },
179
+ index: i,
180
+ });
181
+ }
182
+
183
+ const client = new BodClient({ url: `ws://localhost:${port}` });
184
+ clients.push(client);
185
+ await client.connect();
186
+
187
+ const start = Date.now();
188
+ const result = await client.streamMaterialize('_repl', { keepKey: 'path' });
189
+ const elapsed = Date.now() - start;
190
+ const responseSize = JSON.stringify(result).length;
191
+
192
+ console.log(` 1000 entries × 2KB = ${(responseSize / 1024 / 1024).toFixed(2)}MB, ${elapsed}ms`);
193
+ expect(responseSize).toBeGreaterThan(2 * 1024 * 1024); // >2MB
194
+ });
195
+
196
+ // --- Compaction fixes it ---
197
+
198
+ it('compaction reduces _repl and speeds up bootstrap', async () => {
199
+ const { db: primary, port: primaryPort } = createPrimary();
200
+ fillRepl(primary, 5000, 500);
201
+
202
+ const beforeCount = Object.keys(primary.get('_repl') as Record<string, any>).length;
203
+ expect(beforeCount).toBe(5000);
204
+
205
+ // Compact down to 500
206
+ primary.stream.compact('_repl', { maxCount: 500, keepKey: 'path' });
207
+ const afterRepl = primary.get('_repl') as Record<string, any>;
208
+ const afterCount = afterRepl ? Object.keys(afterRepl).length : 0;
209
+ console.log(` Compacted: ${beforeCount} → ${afterCount} entries`);
210
+ expect(afterCount).toBeLessThanOrEqual(500);
211
+
212
+ // Bootstrap should now work fast with compacted stream
213
+ const { db: replica } = createReplica(primaryPort);
214
+ const start = Date.now();
215
+ await replica.replication!.start();
216
+ const elapsed = Date.now() - start;
217
+
218
+ console.log(` Compacted bootstrap: ${elapsed}ms`);
219
+ expect(elapsed).toBeLessThan(3000);
220
+
221
+ await wait(300);
222
+ // Verify latest writes are present (compaction keeps newest by keepKey)
223
+ const val = replica.get('vfs/files/project/src/deep/nested/path/module4999/component.tsx') as any;
224
+ expect(val?.size).toBe(1024 + 4999);
225
+ });
226
+
227
+ // --- Repeated writes to same paths: worst case for non-compacted stream ---
228
+
229
+ it('repeated writes to same paths bloat _repl with duplicates', () => {
230
+ const { db } = createPrimary();
231
+
232
+ // 100 paths × 50 writes each = 5000 _repl entries, but only 100 unique paths
233
+ for (let round = 0; round < 50; round++) {
234
+ for (let i = 0; i < 100; i++) {
235
+ db.set(`config/setting${i}`, { value: round, updated: Date.now() });
236
+ }
237
+ }
238
+
239
+ const repl = db.get('_repl') as Record<string, any>;
240
+ const totalEntries = Object.keys(repl).length;
241
+ console.log(` 100 paths × 50 writes = ${totalEntries} _repl entries`);
242
+ expect(totalEntries).toBe(5000);
243
+
244
+ // Compact with keepKey deduplicates to 100
245
+ db.stream.compact('_repl', { keepKey: 'path' });
246
+ const after = db.get('_repl') as Record<string, any>;
247
+ const afterCount = after ? Object.keys(after).length : 0;
248
+ // snapshot (1) + remaining entries
249
+ console.log(` After compact: ${afterCount} entries (expect ~100 unique paths)`);
250
+ expect(afterCount).toBeLessThanOrEqual(150);
251
+ });
252
+
253
+ // --- No bootstrap protection: replica.start() has no timeout ---
254
+
255
+ it('replica.start() has no built-in timeout (current gap)', async () => {
256
+ const { db: primary, port: primaryPort } = createPrimary();
257
+ fillRepl(primary, 3000, 300);
258
+
259
+ const { db: replica } = createReplica(primaryPort);
260
+
261
+ // Measure: start() blocks until materialize completes — no internal timeout
262
+ const start = Date.now();
263
+ await replica.replication!.start();
264
+ const elapsed = Date.now() - start;
265
+
266
+ console.log(` replica.start() blocked for ${elapsed}ms (no internal timeout)`);
267
+ // This documents the gap: there's no way to bail out of a slow bootstrap
268
+ // P0 fix should add a configurable timeout here
269
+ });
270
+ });