@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.
- package/.claude/settings.local.json +5 -1
- package/.claude/skills/config-file.md +3 -1
- package/.claude/skills/developing-bod-db.md +10 -1
- package/.claude/skills/using-bod-db.md +18 -1
- package/CLAUDE.md +12 -3
- package/admin/admin.ts +9 -0
- package/admin/demo.config.ts +1 -0
- package/admin/ui.html +67 -37
- package/docs/keyauth-integration.md +141 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +30 -6
- package/src/client/BodClientCached.ts +91 -3
- package/src/server/BodDB.ts +113 -42
- package/src/server/KeyAuthEngine.ts +32 -7
- package/src/server/ReplicationEngine.ts +31 -1
- package/src/server/StorageEngine.ts +117 -6
- package/src/server/StreamEngine.ts +20 -2
- package/src/server/SubscriptionEngine.ts +122 -35
- package/src/server/Transport.ts +125 -15
- package/src/server/VFSEngine.ts +27 -0
- package/src/shared/keyAuth.ts +4 -4
- package/src/shared/logger.ts +68 -0
- package/src/shared/protocol.ts +4 -1
- package/tests/optimization.test.ts +392 -0
|
@@ -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
|
+
});
|