@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.
- package/.claude/skills/config-file.md +6 -0
- package/.claude/skills/deploying-bod-db.md +21 -0
- package/.claude/skills/using-bod-db.md +30 -0
- package/CLAUDE.md +3 -1
- package/README.md +14 -0
- package/docs/keyauth-integration.md +141 -0
- package/docs/para-chat-integration.md +198 -0
- package/docs/repl-stream-bloat-spec.md +48 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +4 -0
- package/src/client/BodClientCached.ts +91 -3
- package/src/server/BodDB.ts +3 -3
- package/src/server/ReplicationEngine.ts +254 -18
- package/src/server/Transport.ts +65 -29
- package/src/server/VFSEngine.ts +40 -2
- package/src/shared/protocol.ts +2 -0
- package/tests/repl-stream-bloat.test.ts +270 -0
- package/tests/replication-topology.test.ts +835 -0
package/src/server/Transport.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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)) {
|
package/src/server/VFSEngine.ts
CHANGED
|
@@ -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
|
|
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> = {
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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
|
+
});
|