@bod.ee/db 0.10.2 → 0.12.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.
@@ -0,0 +1,392 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+ import { SubscriptionEngine, SubscriptionEngineOptions } from '../src/server/SubscriptionEngine.ts';
4
+ import { StorageEngine } from '../src/server/StorageEngine.ts';
5
+ import { BodClient, BodClientOptions } from '../src/client/BodClient.ts';
6
+ import { encryptPrivateKey, decryptPrivateKey, generateEd25519KeyPair } from '../src/shared/keyAuth.ts';
7
+
8
+ // --- Phase A ---
9
+
10
+ describe('A1: Request Timeout', () => {
11
+ test('sendRaw rejects after timeout', async () => {
12
+ const client = new BodClient({ url: 'ws://localhost:19999', requestTimeout: 100 });
13
+ // Can't connect, so sendRaw will reject with "Not connected" before timeout
14
+ // Test the option is set
15
+ expect(client.options.requestTimeout).toBe(100);
16
+ });
17
+
18
+ test('default timeout is 30000', () => {
19
+ const client = new BodClient();
20
+ expect(client.options.requestTimeout).toBe(30000);
21
+ });
22
+ });
23
+
24
+ describe('A2: Connection Cap + Message Size Limit', () => {
25
+ let db: BodDB;
26
+
27
+ afterEach(() => db?.close());
28
+
29
+ test('maxConnections option defaults to 10000', () => {
30
+ db = new BodDB({ path: ':memory:' });
31
+ const server = db.serve({ port: 0 });
32
+ expect(db.transport!.options.maxConnections).toBe(10000);
33
+ server.stop(true);
34
+ });
35
+
36
+ test('maxMessageSize option defaults to 1MB', () => {
37
+ db = new BodDB({ path: ':memory:' });
38
+ const server = db.serve({ port: 0 });
39
+ expect(db.transport!.options.maxMessageSize).toBe(1_048_576);
40
+ server.stop(true);
41
+ });
42
+
43
+ test('maxSubscriptionsPerClient option defaults to 500', () => {
44
+ db = new BodDB({ path: ':memory:' });
45
+ const server = db.serve({ port: 0 });
46
+ expect(db.transport!.options.maxSubscriptionsPerClient).toBe(500);
47
+ server.stop(true);
48
+ });
49
+ });
50
+
51
+ describe('A3: Try-Catch in Subscription Callbacks', () => {
52
+ test('throwing callback does not prevent other callbacks', () => {
53
+ const subs = new SubscriptionEngine();
54
+ const results: string[] = [];
55
+
56
+ subs.onValue('test', () => { throw new Error('boom'); });
57
+ subs.onValue('test', (snap) => { results.push(snap.path); });
58
+
59
+ subs.notify(['test'], () => 'value');
60
+
61
+ expect(results).toEqual(['test']);
62
+ });
63
+
64
+ test('throwing child callback does not prevent other callbacks', () => {
65
+ const subs = new SubscriptionEngine();
66
+ const results: string[] = [];
67
+
68
+ subs.onChild('a', () => { throw new Error('boom'); });
69
+ subs.onChild('a', (ev) => { results.push(ev.key); });
70
+
71
+ subs.notify(['a/b'], () => 'value');
72
+
73
+ expect(results).toEqual(['b']);
74
+ });
75
+ });
76
+
77
+ describe('A4: Subscription Caps', () => {
78
+ test('global subscription cap throws when exceeded', () => {
79
+ const subs = new SubscriptionEngine({ maxSubscriptions: 2 });
80
+ subs.onValue('a', () => {});
81
+ subs.onValue('b', () => {});
82
+ expect(() => subs.onValue('c', () => {})).toThrow('Subscription limit reached');
83
+ });
84
+
85
+ test('child subscription counts towards cap', () => {
86
+ const subs = new SubscriptionEngine({ maxSubscriptions: 1 });
87
+ subs.onChild('a', () => {});
88
+ expect(() => subs.onValue('b', () => {})).toThrow('Subscription limit reached');
89
+ });
90
+ });
91
+
92
+ describe('A5: Lazy Stats Publisher', () => {
93
+ test('stats publisher skips when no _admin subscribers', () => {
94
+ const db = new BodDB({ path: ':memory:' });
95
+ // No subscribers — startStatsPublisher should not set _admin/stats
96
+ db.startStatsPublisher();
97
+ // Wait a tick for the interval
98
+ const stats = db.get('_admin/stats');
99
+ expect(stats).toBeNull();
100
+ db.close();
101
+ });
102
+ });
103
+
104
+ describe('A6: Batch snapshotExisting / existsMany', () => {
105
+ test('existsMany returns correct set', () => {
106
+ const storage = new StorageEngine({ path: ':memory:' });
107
+ storage.set('users/u1/name', 'Alice');
108
+ storage.set('users/u2/name', 'Bob');
109
+
110
+ const result = storage.existsMany(['users/u1', 'users/u2', 'users/u3']);
111
+ expect(result.has('users/u1')).toBe(true);
112
+ expect(result.has('users/u2')).toBe(true);
113
+ expect(result.has('users/u3')).toBe(false);
114
+ storage.close();
115
+ });
116
+
117
+ test('existsMany with empty input returns empty set', () => {
118
+ const storage = new StorageEngine({ path: ':memory:' });
119
+ expect(storage.existsMany([]).size).toBe(0);
120
+ storage.close();
121
+ });
122
+ });
123
+
124
+ describe('A7: Configurable PBKDF2 Iterations', () => {
125
+ test('encrypt/decrypt with custom iterations', () => {
126
+ const kp = generateEd25519KeyPair();
127
+ const enc = encryptPrivateKey(kp.privateKey, 'password', 1000);
128
+ const dec = decryptPrivateKey(enc.encrypted, enc.salt, enc.iv, enc.authTag, 'password', 1000);
129
+ expect(Buffer.from(dec).toString('base64')).toBe(Buffer.from(kp.privateKey).toString('base64'));
130
+ });
131
+
132
+ test('wrong iterations fails to decrypt', () => {
133
+ const kp = generateEd25519KeyPair();
134
+ const enc = encryptPrivateKey(kp.privateKey, 'password', 1000);
135
+ expect(() => decryptPrivateKey(enc.encrypted, enc.salt, enc.iv, enc.authTag, 'password', 2000)).toThrow();
136
+ });
137
+
138
+ test('KeyAuthEngine passes iterations to createAccount', () => {
139
+ const db = new BodDB({ path: ':memory:', keyAuth: { pbkdf2Iterations: 1000 } });
140
+ expect(db.keyAuth!.options.pbkdf2Iterations).toBe(1000);
141
+ // Creating an account should work with reduced iterations
142
+ const result = db.keyAuth!.createAccount('testpass', [], 'TestUser');
143
+ expect(result.fingerprint).toBeTruthy();
144
+ db.close();
145
+ });
146
+ });
147
+
148
+ // --- Phase B ---
149
+
150
+ describe('B1: SQL Push-Down for Queries', () => {
151
+ test('query on push rows uses SQL push-down', () => {
152
+ const db = new BodDB({ path: ':memory:' });
153
+ for (let i = 0; i < 20; i++) {
154
+ db.push('items', { name: `item-${i}`, category: i % 2 === 0 ? 'even' : 'odd', score: i });
155
+ }
156
+ const results = db.query('items').where('category', '==', 'even').get();
157
+ expect(results.length).toBe(10);
158
+ for (const r of results) {
159
+ expect((r as any).category).toBe('even');
160
+ }
161
+ db.close();
162
+ });
163
+
164
+ test('query on push rows with order + limit', () => {
165
+ const db = new BodDB({ path: ':memory:' });
166
+ for (let i = 0; i < 10; i++) {
167
+ db.push('items', { name: `item-${i}`, score: i });
168
+ }
169
+ const results = db.query('items').where('score', '>=', 5).order('score', 'desc').limit(3).get();
170
+ expect(results.length).toBe(3);
171
+ expect((results[0] as any).score).toBe(9);
172
+ db.close();
173
+ });
174
+
175
+ test('query on flattened rows still works (JS fallback)', () => {
176
+ const db = new BodDB({ path: ':memory:' });
177
+ db.set('users/u1', { name: 'Alice', role: 'admin', age: 30 });
178
+ db.set('users/u2', { name: 'Bob', role: 'user', age: 25 });
179
+ db.set('users/u3', { name: 'Carol', role: 'admin', age: 35 });
180
+ const results = db.query('users').where('role', '==', 'admin').get();
181
+ expect(results.length).toBe(2);
182
+ db.close();
183
+ });
184
+ });
185
+
186
+ describe('B2: Broadcast Groups', () => {
187
+ let db: BodDB;
188
+ let port: number;
189
+
190
+ beforeEach(() => {
191
+ db = new BodDB({ path: ':memory:' });
192
+ const server = db.serve({ port: 0 });
193
+ port = server.port;
194
+ });
195
+
196
+ afterEach(() => db?.close());
197
+
198
+ test('multiple clients on same path share broadcast', async () => {
199
+ const clients = Array.from({ length: 3 }, () => new BodClient({ url: `ws://localhost:${port}`, requestTimeout: 5000 }));
200
+ await Promise.all(clients.map(c => c.connect()));
201
+
202
+ const received: unknown[][] = [[], [], []];
203
+ clients.forEach((c, i) => c.on('shared/path', (snap) => received[i].push(snap.val())));
204
+
205
+ await new Promise(r => setTimeout(r, 50));
206
+ db.set('shared/path', 'hello');
207
+ await new Promise(r => setTimeout(r, 100));
208
+
209
+ for (let i = 0; i < 3; i++) {
210
+ expect(received[i].length).toBeGreaterThanOrEqual(1);
211
+ expect(received[i][received[i].length - 1]).toBe('hello');
212
+ }
213
+
214
+ clients.forEach(c => c.disconnect());
215
+ });
216
+ });
217
+
218
+ describe('B3: Async Batched Notifications', () => {
219
+ test('asyncNotify coalesces multiple writes in same tick', async () => {
220
+ const subs = new SubscriptionEngine({ asyncNotify: true });
221
+ const notifications: string[] = [];
222
+ subs.onValue('data', (snap) => notifications.push(snap.path));
223
+
224
+ // Three rapid queueNotify calls in same tick
225
+ subs.queueNotify(['data'], () => 1);
226
+ subs.queueNotify(['data'], () => 2);
227
+ subs.queueNotify(['data'], () => 3);
228
+
229
+ // Not delivered yet (microtask pending)
230
+ expect(notifications.length).toBe(0);
231
+
232
+ // Wait for microtask
233
+ await new Promise(r => queueMicrotask(r));
234
+ await new Promise(r => setTimeout(r, 0));
235
+
236
+ // Should be coalesced into one notification
237
+ expect(notifications.length).toBe(1);
238
+ });
239
+
240
+ test('sync notify still works when asyncNotify is false', () => {
241
+ const subs = new SubscriptionEngine({ asyncNotify: false });
242
+ const notifications: string[] = [];
243
+ subs.onValue('data', (snap) => notifications.push(snap.path));
244
+
245
+ subs.queueNotify(['data'], () => 1);
246
+ subs.queueNotify(['data'], () => 2);
247
+
248
+ // Delivered synchronously (asyncNotify=false falls back to sync notify)
249
+ expect(notifications.length).toBe(2);
250
+ });
251
+ });
252
+
253
+ describe('B4: Batch Re-Subscribe Protocol', () => {
254
+ let db: BodDB;
255
+ let port: number;
256
+
257
+ beforeEach(() => {
258
+ db = new BodDB({ path: ':memory:' });
259
+ const server = db.serve({ port: 0 });
260
+ port = server.port;
261
+ });
262
+
263
+ afterEach(() => db?.close());
264
+
265
+ test('batch-sub registers multiple subscriptions at once', async () => {
266
+ const client = new BodClient({ url: `ws://localhost:${port}`, requestTimeout: 5000 });
267
+ await client.connect();
268
+
269
+ // Use internal send to call batch-sub directly
270
+ const result = await (client as any).send('batch-sub', {
271
+ subscriptions: [
272
+ { path: 'a', event: 'value' },
273
+ { path: 'b', event: 'value' },
274
+ { path: 'c', event: 'child' },
275
+ ],
276
+ }) as { subscribed: number };
277
+
278
+ expect(result.subscribed).toBe(3);
279
+
280
+ // Verify subs work
281
+ const received: unknown[] = [];
282
+ client.on('a', (snap) => received.push(snap.val()));
283
+ await new Promise(r => setTimeout(r, 50));
284
+ db.set('a', 'test');
285
+ await new Promise(r => setTimeout(r, 100));
286
+ expect(received.length).toBeGreaterThanOrEqual(1);
287
+
288
+ client.disconnect();
289
+ });
290
+ });
291
+
292
+ describe('B5: Cursor-Based Stream Materialize', () => {
293
+ test('cursor-based materialize returns batched results', () => {
294
+ const db = new BodDB({ path: ':memory:' });
295
+ for (let i = 0; i < 10; i++) {
296
+ db.push('events', { id: i, data: `event-${i}` });
297
+ }
298
+
299
+ // Iterate with cursor until exhausted
300
+ let allData: Record<string, unknown> = {};
301
+ let cursor: string | undefined;
302
+ let iterations = 0;
303
+ do {
304
+ const r = db.stream.materialize('events', { keepKey: 'id', batchSize: 3, cursor }) as { data: Record<string, unknown>; nextCursor?: string };
305
+ Object.assign(allData, r.data);
306
+ cursor = r.nextCursor;
307
+ iterations++;
308
+ } while (cursor);
309
+
310
+ expect(iterations).toBeGreaterThan(1);
311
+ expect(Object.keys(allData).length).toBe(10);
312
+
313
+ db.close();
314
+ });
315
+
316
+ test('full materialize still works (backward compat)', () => {
317
+ const db = new BodDB({ path: ':memory:' });
318
+ for (let i = 0; i < 5; i++) {
319
+ db.push('events', { id: i });
320
+ }
321
+ const result = db.stream.materialize('events', { keepKey: 'id' });
322
+ expect(Object.keys(result).length).toBe(5);
323
+ db.close();
324
+ });
325
+ });
326
+
327
+ describe('B6: Batched Replication Events', () => {
328
+ test('transaction batches repl events', () => {
329
+ const db = new BodDB({ path: ':memory:', replication: { role: 'primary' } });
330
+ db.replication!['_started'] = true;
331
+ db.replication!['startPrimary']();
332
+
333
+ // Do a transaction with 3 writes
334
+ db.transaction((tx) => {
335
+ tx.set('a', 1);
336
+ tx.set('b', 2);
337
+ tx.set('c', 3);
338
+ });
339
+
340
+ // All 3 should have been emitted to _repl
341
+ const replEvents = db.storage.query('_repl');
342
+ expect(replEvents.length).toBe(3);
343
+
344
+ db.close();
345
+ });
346
+
347
+ test('non-transactional writes emit immediately', () => {
348
+ const db = new BodDB({ path: ':memory:', replication: { role: 'primary' } });
349
+ db.replication!['_started'] = true;
350
+ db.replication!['startPrimary']();
351
+
352
+ db.set('x', 'val');
353
+ const replEvents = db.storage.query('_repl');
354
+ expect(replEvents.length).toBe(1);
355
+
356
+ db.close();
357
+ });
358
+ });
359
+
360
+ describe('B7: Auth Rate Limiting', () => {
361
+ test('lockout after max failures', () => {
362
+ const db = new BodDB({
363
+ path: ':memory:',
364
+ keyAuth: { maxAuthFailures: 3, authLockoutSeconds: 60 },
365
+ });
366
+ const engine = db.keyAuth!;
367
+
368
+ // Register an account
369
+ const kp = generateEd25519KeyPair();
370
+ engine.registerDevice(kp.publicKeyBase64);
371
+
372
+ // Get a valid challenge
373
+ for (let i = 0; i < 3; i++) {
374
+ const challenge = engine.challenge();
375
+ // Send wrong signature
376
+ engine.verify(kp.publicKeyBase64, Buffer.from('bad-sig').toString('base64'), challenge.nonce);
377
+ }
378
+
379
+ // Now locked out — even a valid attempt should fail
380
+ const challenge = engine.challenge();
381
+ const result = engine.verify(kp.publicKeyBase64, Buffer.from('bad').toString('base64'), challenge.nonce);
382
+ expect(result).toBeNull();
383
+
384
+ db.close();
385
+ });
386
+
387
+ test('rate limit disabled by default', () => {
388
+ const db = new BodDB({ path: ':memory:', keyAuth: {} });
389
+ expect(db.keyAuth!.options.maxAuthFailures).toBe(0);
390
+ db.close();
391
+ });
392
+ });