@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,295 @@
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
+ describe('_repl stream bloat', () => {
9
+ const instances: BodDB[] = [];
10
+ const clients: BodClient[] = [];
11
+
12
+ afterEach(() => {
13
+ for (const c of clients) c.disconnect();
14
+ clients.length = 0;
15
+ for (const db of [...instances].reverse()) db.close();
16
+ instances.length = 0;
17
+ });
18
+
19
+ function getPort() { return nextPort++; }
20
+
21
+ /** Primary with auto-compact DISABLED so _repl grows unbounded */
22
+ function createPrimary(opts?: { maxMessageSize?: number; compact?: any; autoCompactThreshold?: number }) {
23
+ const port = getPort();
24
+ const db = new BodDB({
25
+ path: ':memory:',
26
+ sweepInterval: 0,
27
+ replication: { role: 'primary', compact: opts?.compact ?? {}, autoCompactThreshold: opts?.autoCompactThreshold ?? 0 },
28
+ });
29
+ db.replication!.start();
30
+ db.serve({ port, maxMessageSize: opts?.maxMessageSize });
31
+ instances.push(db);
32
+ return { db, port };
33
+ }
34
+
35
+ function createReplica(primaryPort: number) {
36
+ const port = getPort();
37
+ const db = new BodDB({
38
+ path: ':memory:',
39
+ sweepInterval: 0,
40
+ replication: {
41
+ role: 'replica',
42
+ primaryUrl: `ws://localhost:${primaryPort}`,
43
+ replicaId: `bloat-replica-${port}`,
44
+ },
45
+ });
46
+ db.serve({ port });
47
+ instances.push(db);
48
+ return { db, port };
49
+ }
50
+
51
+ // Helper: fill _repl with N entries, each ~payloadBytes
52
+ function fillRepl(db: BodDB, count: number, payloadBytes: number) {
53
+ const padding = 'x'.repeat(Math.max(0, payloadBytes - 100));
54
+ for (let i = 0; i < count; i++) {
55
+ db.set(`vfs/files/project/src/deep/nested/path/module${i}/component.tsx`, {
56
+ size: 1024 + i,
57
+ mime: 'application/typescript',
58
+ mtime: Date.now(),
59
+ fileId: `fid_${i}_${padding}`,
60
+ hash: `sha256_${i.toString(16).padStart(64, '0')}`,
61
+ isDir: false,
62
+ metadata: { author: 'test', tags: ['a', 'b', 'c'], version: i },
63
+ });
64
+ }
65
+ }
66
+
67
+ // --- Accumulation ---
68
+
69
+ it('_repl grows unbounded without compaction', () => {
70
+ const { db } = createPrimary();
71
+ fillRepl(db, 5000, 300);
72
+ const repl = db.get('_repl') as Record<string, any>;
73
+ expect(Object.keys(repl).length).toBe(5000);
74
+ });
75
+
76
+ // --- Scaling: measure materialize time at increasing sizes ---
77
+
78
+ it('materialize time scales linearly (O(n) proof)', async () => {
79
+ const sizes = [500, 2000, 5000];
80
+ const timings: { count: number; ms: number; bytes: number }[] = [];
81
+
82
+ for (const count of sizes) {
83
+ const { db, port } = createPrimary();
84
+ fillRepl(db, count, 500);
85
+
86
+ const client = new BodClient({ url: `ws://localhost:${port}` });
87
+ clients.push(client);
88
+ await client.connect();
89
+
90
+ const start = Date.now();
91
+ const result = await client.streamMaterialize('_repl', { keepKey: 'path' });
92
+ const elapsed = Date.now() - start;
93
+
94
+ const json = JSON.stringify(result);
95
+ timings.push({ count, ms: elapsed, bytes: json.length });
96
+ }
97
+
98
+ for (const t of timings) {
99
+ console.log(` ${t.count} entries → ${t.ms}ms, ${(t.bytes / 1024 / 1024).toFixed(2)}MB response`);
100
+ }
101
+
102
+ // Response size should scale roughly linearly
103
+ const ratio = timings[2].bytes / timings[0].bytes;
104
+ console.log(` Size ratio (${sizes[2]}/${sizes[0]}): ${ratio.toFixed(1)}x (expect ~${(sizes[2] / sizes[0]).toFixed(1)}x)`);
105
+ expect(ratio).toBeGreaterThan(5); // 5000/500 = 10x, allow some dedup
106
+ });
107
+
108
+ // --- Cursor-based materialize ---
109
+
110
+ it('cursor-based materialize pages correctly over large _repl', async () => {
111
+ const { db, port } = createPrimary();
112
+ fillRepl(db, 1000, 200);
113
+
114
+ const client = new BodClient({ url: `ws://localhost:${port}` });
115
+ clients.push(client);
116
+ await client.connect();
117
+
118
+ // Page through with batchSize=200
119
+ const allKeys = new Set<string>();
120
+ let cursor: string | undefined;
121
+ let pages = 0;
122
+ do {
123
+ const page = await client.streamMaterialize('_repl', { keepKey: 'path', batchSize: 200, cursor });
124
+ if (page.data) {
125
+ for (const key of Object.keys(page.data)) allKeys.add(key);
126
+ }
127
+ cursor = page.nextCursor;
128
+ pages++;
129
+ } while (cursor);
130
+
131
+ console.log(` 1000 entries paged in ${pages} pages, got ${allKeys.size} unique keys`);
132
+ expect(allKeys.size).toBe(1000);
133
+ expect(pages).toBeGreaterThanOrEqual(5); // 1000/200 = 5 pages minimum
134
+ });
135
+
136
+ // --- Auto-compact on write threshold ---
137
+
138
+ it('auto-compact triggers after N writes', () => {
139
+ const { db } = createPrimary({ compact: { maxCount: 50, keepKey: 'path' }, autoCompactThreshold: 100 });
140
+
141
+ // Write 250 entries — compact triggers at 100, 200; maxCount=50 keeps only 50 each time
142
+ for (let i = 0; i < 250; i++) {
143
+ db.set(`data/item${i}`, { value: i });
144
+ }
145
+
146
+ const repl = db.get('_repl') as Record<string, any>;
147
+ const count = repl ? Object.keys(repl).length : 0;
148
+ console.log(` 250 writes with threshold=100, maxCount=50: ${count} _repl entries`);
149
+ // After compact at 200 (keeps 50), then 50 more → ~100. Way less than 250.
150
+ expect(count).toBeLessThan(150);
151
+ });
152
+
153
+ // --- Timeout reproduction: short requestTimeout simulates real-world failure ---
154
+
155
+ it('short requestTimeout causes streamMaterialize to fail on bloated _repl', async () => {
156
+ const { db: primary, port: primaryPort } = createPrimary();
157
+ fillRepl(primary, 10000, 1000); // 10k entries × 1KB = ~10MB response
158
+
159
+ const replCount = Object.keys(primary.get('_repl') as Record<string, any>).length;
160
+ expect(replCount).toBe(10000);
161
+
162
+ const client = new BodClient({
163
+ url: `ws://localhost:${primaryPort}`,
164
+ requestTimeout: 1,
165
+ });
166
+ clients.push(client);
167
+ await client.connect();
168
+
169
+ let error: Error | null = null;
170
+ try {
171
+ await client.streamMaterialize('_repl', { keepKey: 'path' });
172
+ } catch (e) {
173
+ error = e as Error;
174
+ }
175
+
176
+ expect(error).not.toBeNull();
177
+ console.log(` REPRODUCED: streamMaterialize failed: ${error!.message}`);
178
+ expect(error!.message).toContain('timeout');
179
+ });
180
+
181
+ it('streamMaterialize response exceeds typical WS comfort zone', async () => {
182
+ const { db, port } = createPrimary();
183
+ fillRepl(db, 10000, 1000);
184
+
185
+ const client = new BodClient({ url: `ws://localhost:${port}` });
186
+ clients.push(client);
187
+ await client.connect();
188
+
189
+ const start = Date.now();
190
+ const result = await client.streamMaterialize('_repl', { keepKey: 'path' });
191
+ const elapsed = Date.now() - start;
192
+ const responseSize = JSON.stringify(result).length;
193
+
194
+ console.log(` 10k entries × 1KB: ${(responseSize / 1024 / 1024).toFixed(1)}MB response in ${elapsed}ms`);
195
+ expect(responseSize).toBeGreaterThan(5 * 1024 * 1024); // >5MB
196
+ });
197
+
198
+ // --- Payload size bomb ---
199
+
200
+ it('large payloads per entry amplify the problem', async () => {
201
+ const { db, port } = createPrimary();
202
+
203
+ const bigPadding = 'y'.repeat(2000);
204
+ for (let i = 0; i < 1000; i++) {
205
+ db.set(`data/big${i}`, {
206
+ content: bigPadding,
207
+ nested: { deep: { value: bigPadding.slice(0, 500) } },
208
+ index: i,
209
+ });
210
+ }
211
+
212
+ const client = new BodClient({ url: `ws://localhost:${port}` });
213
+ clients.push(client);
214
+ await client.connect();
215
+
216
+ const start = Date.now();
217
+ const result = await client.streamMaterialize('_repl', { keepKey: 'path' });
218
+ const elapsed = Date.now() - start;
219
+ const responseSize = JSON.stringify(result).length;
220
+
221
+ console.log(` 1000 entries × 2KB = ${(responseSize / 1024 / 1024).toFixed(2)}MB, ${elapsed}ms`);
222
+ expect(responseSize).toBeGreaterThan(2 * 1024 * 1024);
223
+ });
224
+
225
+ // --- Compaction fixes it ---
226
+
227
+ it('compaction reduces _repl and speeds up bootstrap', async () => {
228
+ const { db: primary, port: primaryPort } = createPrimary();
229
+ fillRepl(primary, 5000, 500);
230
+
231
+ const beforeCount = Object.keys(primary.get('_repl') as Record<string, any>).length;
232
+ expect(beforeCount).toBe(5000);
233
+
234
+ primary.stream.compact('_repl', { maxCount: 500, keepKey: 'path' });
235
+ const afterRepl = primary.get('_repl') as Record<string, any>;
236
+ const afterCount = afterRepl ? Object.keys(afterRepl).length : 0;
237
+ console.log(` Compacted: ${beforeCount} → ${afterCount} entries`);
238
+ expect(afterCount).toBeLessThanOrEqual(500);
239
+
240
+ const { db: replica } = createReplica(primaryPort);
241
+ const start = Date.now();
242
+ await replica.replication!.start();
243
+ const elapsed = Date.now() - start;
244
+
245
+ console.log(` Compacted bootstrap: ${elapsed}ms`);
246
+ expect(elapsed).toBeLessThan(3000);
247
+
248
+ await wait(300);
249
+ const val = replica.get('vfs/files/project/src/deep/nested/path/module4999/component.tsx') as any;
250
+ expect(val?.size).toBe(1024 + 4999);
251
+ });
252
+
253
+ // --- Repeated writes ---
254
+
255
+ it('repeated writes to same paths bloat _repl with duplicates', () => {
256
+ const { db } = createPrimary();
257
+
258
+ for (let round = 0; round < 50; round++) {
259
+ for (let i = 0; i < 100; i++) {
260
+ db.set(`config/setting${i}`, { value: round, updated: Date.now() });
261
+ }
262
+ }
263
+
264
+ const repl = db.get('_repl') as Record<string, any>;
265
+ const totalEntries = Object.keys(repl).length;
266
+ console.log(` 100 paths × 50 writes = ${totalEntries} _repl entries`);
267
+ expect(totalEntries).toBe(5000);
268
+
269
+ db.stream.compact('_repl', { keepKey: 'path' });
270
+ const after = db.get('_repl') as Record<string, any>;
271
+ const afterCount = after ? Object.keys(after).length : 0;
272
+ console.log(` After compact: ${afterCount} entries (expect ~100 unique paths)`);
273
+ expect(afterCount).toBeLessThanOrEqual(150);
274
+ });
275
+
276
+ // --- Cursor-based bootstrap works for replica ---
277
+
278
+ it('replica bootstrap uses cursor-based pagination (no timeout on large _repl)', async () => {
279
+ const { db: primary, port: primaryPort } = createPrimary();
280
+ fillRepl(primary, 3000, 300);
281
+
282
+ const { db: replica } = createReplica(primaryPort);
283
+
284
+ const start = Date.now();
285
+ await replica.replication!.start();
286
+ const elapsed = Date.now() - start;
287
+
288
+ console.log(` Cursor-based replica bootstrap: ${elapsed}ms for 3000 entries`);
289
+
290
+ await wait(300);
291
+ // Verify data arrived
292
+ const val = replica.get('vfs/files/project/src/deep/nested/path/module2999/component.tsx') as any;
293
+ expect(val?.size).toBe(1024 + 2999);
294
+ });
295
+ });