@bod.ee/db 0.7.0 → 0.8.0
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 +7 -1
- package/.claude/skills/config-file.md +7 -0
- package/.claude/skills/deploying-bod-db.md +34 -0
- package/.claude/skills/developing-bod-db.md +20 -2
- package/.claude/skills/using-bod-db.md +165 -0
- package/.github/workflows/test-and-publish.yml +111 -0
- package/CLAUDE.md +10 -1
- package/README.md +57 -2
- package/admin/proxy.ts +79 -0
- package/admin/rules.ts +1 -1
- package/admin/server.ts +134 -50
- package/admin/ui.html +729 -18
- package/cli.ts +10 -0
- package/client.ts +3 -2
- package/config.ts +1 -0
- package/deploy/boddb-il.yaml +14 -0
- package/deploy/prod-il.config.ts +19 -0
- package/deploy/prod.config.ts +1 -0
- package/index.ts +3 -0
- package/package.json +7 -2
- package/src/client/BodClient.ts +129 -6
- package/src/client/CachedClient.ts +228 -0
- package/src/server/BodDB.ts +145 -1
- package/src/server/ReplicationEngine.ts +332 -0
- package/src/server/StorageEngine.ts +19 -0
- package/src/server/Transport.ts +577 -360
- package/src/server/VFSEngine.ts +172 -0
- package/src/shared/protocol.ts +25 -4
- package/tests/cached-client.test.ts +143 -0
- package/tests/replication.test.ts +404 -0
- package/tests/vfs.test.ts +166 -0
|
@@ -0,0 +1,404 @@
|
|
|
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 = 24400 + Math.floor(Math.random() * 1000);
|
|
7
|
+
|
|
8
|
+
describe('ReplicationEngine', () => {
|
|
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
|
+
function createPrimary() {
|
|
22
|
+
const port = getPort();
|
|
23
|
+
const db = new BodDB({
|
|
24
|
+
path: ':memory:',
|
|
25
|
+
sweepInterval: 0,
|
|
26
|
+
replication: { role: 'primary' },
|
|
27
|
+
});
|
|
28
|
+
db.replication!.start();
|
|
29
|
+
db.serve({ port });
|
|
30
|
+
instances.push(db);
|
|
31
|
+
return { db, port };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createReplica(primaryPort: number) {
|
|
35
|
+
const port = getPort();
|
|
36
|
+
const db = new BodDB({
|
|
37
|
+
path: ':memory:',
|
|
38
|
+
sweepInterval: 0,
|
|
39
|
+
replication: {
|
|
40
|
+
role: 'replica',
|
|
41
|
+
primaryUrl: `ws://localhost:${primaryPort}`,
|
|
42
|
+
replicaId: `test-replica-${port}`,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
db.serve({ port });
|
|
46
|
+
instances.push(db);
|
|
47
|
+
return { db, port };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 1. Primary emits events to _repl on set/delete/update/push
|
|
51
|
+
it('primary emits replication events on set/delete/update/push', () => {
|
|
52
|
+
const db = new BodDB({
|
|
53
|
+
path: ':memory:',
|
|
54
|
+
sweepInterval: 0,
|
|
55
|
+
replication: { role: 'primary' },
|
|
56
|
+
});
|
|
57
|
+
instances.push(db);
|
|
58
|
+
db.replication!.start();
|
|
59
|
+
|
|
60
|
+
db.set('users/u1', { name: 'Eli' });
|
|
61
|
+
db.update({ 'users/u2/age': 30 });
|
|
62
|
+
db.delete('users/u1');
|
|
63
|
+
db.push('logs', { msg: 'hello' });
|
|
64
|
+
|
|
65
|
+
const replData = db.get('_repl');
|
|
66
|
+
expect(replData).toBeTruthy();
|
|
67
|
+
const entries = Object.values(replData as Record<string, any>);
|
|
68
|
+
// update emits per-path set events: set + set(from update) + delete + push = 4
|
|
69
|
+
expect(entries.length).toBe(4);
|
|
70
|
+
expect(entries[0].op).toBe('set');
|
|
71
|
+
expect(entries[0].path).toBe('users/u1');
|
|
72
|
+
expect(entries[1].op).toBe('set');
|
|
73
|
+
expect(entries[1].path).toBe('users/u2/age');
|
|
74
|
+
expect(entries[2].op).toBe('delete');
|
|
75
|
+
expect(entries[3].op).toBe('push');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 2. Loop prevention — _replaying flag prevents re-emission
|
|
79
|
+
it('does not emit events when replaying', () => {
|
|
80
|
+
const db = new BodDB({
|
|
81
|
+
path: ':memory:',
|
|
82
|
+
sweepInterval: 0,
|
|
83
|
+
replication: { role: 'primary' },
|
|
84
|
+
});
|
|
85
|
+
instances.push(db);
|
|
86
|
+
db.replication!.start();
|
|
87
|
+
|
|
88
|
+
db.setReplaying(true);
|
|
89
|
+
db.set('users/u1', { name: 'Test' });
|
|
90
|
+
db.setReplaying(false);
|
|
91
|
+
|
|
92
|
+
const replData = db.get('_repl');
|
|
93
|
+
expect(replData).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 3. Excluded prefixes are not replicated
|
|
97
|
+
it('skips excluded prefixes', () => {
|
|
98
|
+
const db = new BodDB({
|
|
99
|
+
path: ':memory:',
|
|
100
|
+
sweepInterval: 0,
|
|
101
|
+
replication: { role: 'primary', excludePrefixes: ['_repl', '_streams', '_mq', 'internal'] },
|
|
102
|
+
});
|
|
103
|
+
instances.push(db);
|
|
104
|
+
db.replication!.start();
|
|
105
|
+
|
|
106
|
+
db.set('internal/config', { foo: 1 });
|
|
107
|
+
db.set('users/u1', { name: 'Eli' });
|
|
108
|
+
|
|
109
|
+
const replData = db.get('_repl') as Record<string, any>;
|
|
110
|
+
const entries = Object.values(replData);
|
|
111
|
+
expect(entries.length).toBe(1);
|
|
112
|
+
expect(entries[0].op).toBe('set');
|
|
113
|
+
expect(entries[0].path).toBe('users/u1');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 4. Integration: primary + replica bootstrap
|
|
117
|
+
it('replica receives data from primary via replication stream', async () => {
|
|
118
|
+
const { db: primary, port: pPort } = createPrimary();
|
|
119
|
+
|
|
120
|
+
primary.set('users/u1', { name: 'Eli' });
|
|
121
|
+
primary.set('users/u2', { name: 'Dan' });
|
|
122
|
+
|
|
123
|
+
const { db: replica } = createReplica(pPort);
|
|
124
|
+
await replica.replication!.start();
|
|
125
|
+
await wait(500);
|
|
126
|
+
|
|
127
|
+
expect(replica.get('users/u1')).toEqual({ name: 'Eli' });
|
|
128
|
+
expect(replica.get('users/u2')).toEqual({ name: 'Dan' });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// 5. Proxy test: write to replica goes to primary
|
|
132
|
+
it('write to replica is proxied to primary', async () => {
|
|
133
|
+
const { db: primary, port: pPort } = createPrimary();
|
|
134
|
+
const { db: replica, port: rPort } = createReplica(pPort);
|
|
135
|
+
await replica.replication!.start();
|
|
136
|
+
await wait(200);
|
|
137
|
+
|
|
138
|
+
const client = new BodClient({ url: `ws://localhost:${rPort}` });
|
|
139
|
+
clients.push(client);
|
|
140
|
+
await client.connect();
|
|
141
|
+
await client.set('items/i1', { price: 42 });
|
|
142
|
+
await wait(200);
|
|
143
|
+
|
|
144
|
+
expect(primary.get('items/i1')).toEqual({ price: 42 });
|
|
145
|
+
|
|
146
|
+
await wait(500);
|
|
147
|
+
expect(replica.get('items/i1')).toEqual({ price: 42 });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// 6. Push replication preserves keys
|
|
151
|
+
it('push events replicate with exact pushKey', async () => {
|
|
152
|
+
const { db: primary, port: pPort } = createPrimary();
|
|
153
|
+
const key = primary.push('events', { type: 'click' });
|
|
154
|
+
|
|
155
|
+
const { db: replica } = createReplica(pPort);
|
|
156
|
+
await replica.replication!.start();
|
|
157
|
+
await wait(500);
|
|
158
|
+
|
|
159
|
+
expect(replica.get(`events/${key}`)).toEqual({ type: 'click' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// 7. Transaction events are replicated
|
|
163
|
+
it('transaction write events are replicated', () => {
|
|
164
|
+
const db = new BodDB({
|
|
165
|
+
path: ':memory:',
|
|
166
|
+
sweepInterval: 0,
|
|
167
|
+
replication: { role: 'primary' },
|
|
168
|
+
});
|
|
169
|
+
instances.push(db);
|
|
170
|
+
db.replication!.start();
|
|
171
|
+
|
|
172
|
+
db.transaction(tx => {
|
|
173
|
+
tx.set('a/1', 'one');
|
|
174
|
+
tx.set('a/2', 'two');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const replData = db.get('_repl') as Record<string, any>;
|
|
178
|
+
const entries = Object.values(replData);
|
|
179
|
+
expect(entries.length).toBe(2);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// 8. Delete replication
|
|
183
|
+
it('delete events are replicated to replica', async () => {
|
|
184
|
+
const { db: primary, port: pPort } = createPrimary();
|
|
185
|
+
primary.set('tmp/x', 'hello');
|
|
186
|
+
|
|
187
|
+
const { db: replica } = createReplica(pPort);
|
|
188
|
+
await replica.replication!.start();
|
|
189
|
+
await wait(500);
|
|
190
|
+
expect(replica.get('tmp/x')).toBe('hello');
|
|
191
|
+
|
|
192
|
+
// Delete on primary
|
|
193
|
+
primary.delete('tmp/x');
|
|
194
|
+
await wait(500);
|
|
195
|
+
expect(replica.get('tmp/x')).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// 9. Update replication (flattened to per-path sets)
|
|
199
|
+
it('update events replicate as per-path sets', async () => {
|
|
200
|
+
const { db: primary, port: pPort } = createPrimary();
|
|
201
|
+
|
|
202
|
+
const { db: replica } = createReplica(pPort);
|
|
203
|
+
await replica.replication!.start();
|
|
204
|
+
await wait(200);
|
|
205
|
+
|
|
206
|
+
primary.update({ 'conf/a': 1, 'conf/b': 2 });
|
|
207
|
+
await wait(500);
|
|
208
|
+
|
|
209
|
+
expect(replica.get('conf/a')).toBe(1);
|
|
210
|
+
expect(replica.get('conf/b')).toBe(2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// 10. Multi-path update exclusion check (all paths checked, not just first)
|
|
214
|
+
it('update with mixed excluded/non-excluded paths emits only non-excluded', () => {
|
|
215
|
+
const db = new BodDB({
|
|
216
|
+
path: ':memory:',
|
|
217
|
+
sweepInterval: 0,
|
|
218
|
+
replication: { role: 'primary', excludePrefixes: ['_repl', '_streams', '_mq', '_internal'] },
|
|
219
|
+
});
|
|
220
|
+
instances.push(db);
|
|
221
|
+
db.replication!.start();
|
|
222
|
+
|
|
223
|
+
db.update({ '_internal/x': 1, 'data/y': 2 });
|
|
224
|
+
|
|
225
|
+
const replData = db.get('_repl') as Record<string, any>;
|
|
226
|
+
const entries = Object.values(replData);
|
|
227
|
+
expect(entries.length).toBe(1);
|
|
228
|
+
expect(entries[0].path).toBe('data/y');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// 11. Sweep fires delete events for expired paths
|
|
232
|
+
it('sweep expired paths are replicated as deletes', () => {
|
|
233
|
+
const db = new BodDB({
|
|
234
|
+
path: ':memory:',
|
|
235
|
+
sweepInterval: 0,
|
|
236
|
+
replication: { role: 'primary' },
|
|
237
|
+
});
|
|
238
|
+
instances.push(db);
|
|
239
|
+
db.replication!.start();
|
|
240
|
+
|
|
241
|
+
db.set('sessions/s1', { token: 'abc' }, { ttl: 1 });
|
|
242
|
+
// Force expiry by manipulating the DB directly
|
|
243
|
+
db.storage.db.prepare('UPDATE nodes SET expires_at = 1 WHERE path LIKE ?').run('sessions/s1%');
|
|
244
|
+
db.sweep();
|
|
245
|
+
|
|
246
|
+
const replData = db.get('_repl') as Record<string, any>;
|
|
247
|
+
const entries = Object.values(replData);
|
|
248
|
+
// set event + delete event(s) from sweep
|
|
249
|
+
const deleteEvents = entries.filter((e: any) => e.op === 'delete');
|
|
250
|
+
expect(deleteEvents.length).toBeGreaterThan(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// 12. Recursion guard — emit doesn't infinite loop
|
|
254
|
+
it('emit does not cause infinite recursion', () => {
|
|
255
|
+
const db = new BodDB({
|
|
256
|
+
path: ':memory:',
|
|
257
|
+
sweepInterval: 0,
|
|
258
|
+
replication: { role: 'primary' },
|
|
259
|
+
});
|
|
260
|
+
instances.push(db);
|
|
261
|
+
db.replication!.start();
|
|
262
|
+
|
|
263
|
+
db.set('users/u1', 'test');
|
|
264
|
+
expect(db.get('users/u1')).toBe('test');
|
|
265
|
+
const replData = db.get('_repl') as Record<string, any>;
|
|
266
|
+
expect(Object.values(replData).length).toBe(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// --- Source feed subscription tests ---
|
|
270
|
+
|
|
271
|
+
function createSourceLocal(sources: Array<{ url: string; paths: string[]; localPrefix?: string; excludePrefixes?: string[] }>) {
|
|
272
|
+
const port = getPort();
|
|
273
|
+
const db = new BodDB({
|
|
274
|
+
path: ':memory:',
|
|
275
|
+
sweepInterval: 0,
|
|
276
|
+
replication: { role: 'primary', sources },
|
|
277
|
+
});
|
|
278
|
+
db.serve({ port });
|
|
279
|
+
instances.push(db);
|
|
280
|
+
return { db, port };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
it('source only syncs matching paths', async () => {
|
|
284
|
+
const { db: remote, port: rPort } = createPrimary();
|
|
285
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
286
|
+
remote.set('orders/o1', { total: 100 });
|
|
287
|
+
|
|
288
|
+
const { db: local } = createSourceLocal([{ url: `ws://localhost:${rPort}`, paths: ['users'] }]);
|
|
289
|
+
await local.replication!.start();
|
|
290
|
+
await wait(500);
|
|
291
|
+
|
|
292
|
+
expect(local.get('users/u1')).toEqual({ name: 'Eli' });
|
|
293
|
+
expect(local.get('orders/o1')).toBeNull();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('source remaps paths with localPrefix', async () => {
|
|
297
|
+
const { db: remote, port: rPort } = createPrimary();
|
|
298
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
299
|
+
|
|
300
|
+
const { db: local } = createSourceLocal([{ url: `ws://localhost:${rPort}`, paths: ['users'], localPrefix: 'db-a' }]);
|
|
301
|
+
await local.replication!.start();
|
|
302
|
+
await wait(500);
|
|
303
|
+
|
|
304
|
+
expect(local.get('db-a/users/u1')).toEqual({ name: 'Eli' });
|
|
305
|
+
expect(local.get('users/u1')).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('multi-source syncs from multiple remotes', async () => {
|
|
309
|
+
const { db: remoteA, port: portA } = createPrimary();
|
|
310
|
+
const { db: remoteB, port: portB } = createPrimary();
|
|
311
|
+
remoteA.set('users/u1', { name: 'Eli' });
|
|
312
|
+
remoteB.set('products/p1', { price: 42 });
|
|
313
|
+
|
|
314
|
+
const { db: local } = createSourceLocal([
|
|
315
|
+
{ url: `ws://localhost:${portA}`, paths: ['users'], localPrefix: 'a' },
|
|
316
|
+
{ url: `ws://localhost:${portB}`, paths: ['products'], localPrefix: 'b' },
|
|
317
|
+
]);
|
|
318
|
+
await local.replication!.start();
|
|
319
|
+
await wait(500);
|
|
320
|
+
|
|
321
|
+
expect(local.get('a/users/u1')).toEqual({ name: 'Eli' });
|
|
322
|
+
expect(local.get('b/products/p1')).toEqual({ price: 42 });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('primary with sources: own writes emit, source data does not', async () => {
|
|
326
|
+
const { db: remote, port: rPort } = createPrimary();
|
|
327
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
328
|
+
|
|
329
|
+
const { db: local } = createSourceLocal([{ url: `ws://localhost:${rPort}`, paths: ['users'], localPrefix: 'ext' }]);
|
|
330
|
+
await local.replication!.start();
|
|
331
|
+
await wait(500);
|
|
332
|
+
|
|
333
|
+
expect(local.get('ext/users/u1')).toEqual({ name: 'Eli' });
|
|
334
|
+
|
|
335
|
+
local.set('mydata/x', 'hello');
|
|
336
|
+
const replData = local.get('_repl') as Record<string, any>;
|
|
337
|
+
const entries = Object.values(replData);
|
|
338
|
+
expect(entries.some((e: any) => e.path === 'mydata/x')).toBe(true);
|
|
339
|
+
expect(entries.some((e: any) => e.path === 'ext/users/u1')).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('source receives ongoing events after bootstrap', async () => {
|
|
343
|
+
const { db: remote, port: rPort } = createPrimary();
|
|
344
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
345
|
+
|
|
346
|
+
const { db: local } = createSourceLocal([{ url: `ws://localhost:${rPort}`, paths: ['users'] }]);
|
|
347
|
+
await local.replication!.start();
|
|
348
|
+
await wait(500);
|
|
349
|
+
|
|
350
|
+
expect(local.get('users/u1')).toEqual({ name: 'Eli' });
|
|
351
|
+
|
|
352
|
+
remote.set('users/u2', { name: 'Dan' });
|
|
353
|
+
await wait(500);
|
|
354
|
+
expect(local.get('users/u2')).toEqual({ name: 'Dan' });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('source excludePrefixes filters out matching paths', async () => {
|
|
358
|
+
const { db: remote, port: rPort } = createPrimary();
|
|
359
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
360
|
+
remote.set('users/internal/secret', { key: '123' });
|
|
361
|
+
|
|
362
|
+
const { db: local } = createSourceLocal([
|
|
363
|
+
{ url: `ws://localhost:${rPort}`, paths: ['users'], excludePrefixes: ['users/internal'] },
|
|
364
|
+
]);
|
|
365
|
+
await local.replication!.start();
|
|
366
|
+
await wait(500);
|
|
367
|
+
|
|
368
|
+
expect(local.get('users/u1')).toEqual({ name: 'Eli' });
|
|
369
|
+
expect(local.get('users/internal/secret')).toBeNull();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('source replicates delete events with remap', async () => {
|
|
373
|
+
const { db: remote, port: rPort } = createPrimary();
|
|
374
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
375
|
+
|
|
376
|
+
const { db: local } = createSourceLocal([{ url: `ws://localhost:${rPort}`, paths: ['users'], localPrefix: 'src' }]);
|
|
377
|
+
await local.replication!.start();
|
|
378
|
+
await wait(500);
|
|
379
|
+
|
|
380
|
+
expect(local.get('src/users/u1')).toEqual({ name: 'Eli' });
|
|
381
|
+
|
|
382
|
+
remote.delete('users/u1');
|
|
383
|
+
await wait(500);
|
|
384
|
+
expect(local.get('src/users/u1')).toBeNull();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('stop() disconnects source clients', async () => {
|
|
388
|
+
const { db: remote, port: rPort } = createPrimary();
|
|
389
|
+
remote.set('users/u1', { name: 'Eli' });
|
|
390
|
+
|
|
391
|
+
const { db: local } = createSourceLocal([{ url: `ws://localhost:${rPort}`, paths: ['users'] }]);
|
|
392
|
+
await local.replication!.start();
|
|
393
|
+
await wait(500);
|
|
394
|
+
|
|
395
|
+
expect(local.get('users/u1')).toEqual({ name: 'Eli' });
|
|
396
|
+
|
|
397
|
+
local.replication!.stop();
|
|
398
|
+
|
|
399
|
+
// After stop, ongoing writes should NOT sync
|
|
400
|
+
remote.set('users/u2', { name: 'Dan' });
|
|
401
|
+
await wait(500);
|
|
402
|
+
expect(local.get('users/u2')).toBeNull();
|
|
403
|
+
});
|
|
404
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'bun:test';
|
|
2
|
+
import { BodDB } from '../src/server/BodDB.ts';
|
|
3
|
+
import { BodClient } from '../src/client/BodClient.ts';
|
|
4
|
+
import { rmSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
const VFS_ROOT = '.tmp/test-vfs-' + Date.now();
|
|
7
|
+
let db: BodDB;
|
|
8
|
+
let client: BodClient;
|
|
9
|
+
let port: number;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
db = new BodDB({ path: ':memory:', vfs: { storageRoot: VFS_ROOT } });
|
|
13
|
+
const server = db.serve({ port: 0 });
|
|
14
|
+
port = server.port;
|
|
15
|
+
client = new BodClient({ url: `ws://localhost:${port}` });
|
|
16
|
+
await client.connect();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
client.disconnect();
|
|
21
|
+
db.close();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
try { rmSync(VFS_ROOT, { recursive: true }); } catch {}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('VFSEngine direct', () => {
|
|
29
|
+
it('write + read + stat round-trip', async () => {
|
|
30
|
+
const data = new TextEncoder().encode('hello vfs');
|
|
31
|
+
const stat = await db.vfs!.write('docs/readme.txt', data);
|
|
32
|
+
expect(stat.name).toBe('readme.txt');
|
|
33
|
+
expect(stat.path).toBe('docs/readme.txt');
|
|
34
|
+
expect(stat.size).toBe(9);
|
|
35
|
+
expect(stat.mime).toBe('text/plain');
|
|
36
|
+
expect(stat.isDir).toBe(false);
|
|
37
|
+
|
|
38
|
+
const read = await db.vfs!.read('docs/readme.txt');
|
|
39
|
+
expect(new TextDecoder().decode(read)).toBe('hello vfs');
|
|
40
|
+
|
|
41
|
+
const s = db.vfs!.stat('docs/readme.txt');
|
|
42
|
+
expect(s?.fileId).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('mkdir + list', () => {
|
|
46
|
+
db.vfs!.mkdir('projects');
|
|
47
|
+
db.vfs!.mkdir('projects/alpha');
|
|
48
|
+
const items = db.vfs!.list('projects');
|
|
49
|
+
expect(items.length).toBe(1);
|
|
50
|
+
expect(items[0].name).toBe('alpha');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('move renames without file I/O', async () => {
|
|
54
|
+
const data = new TextEncoder().encode('moveme');
|
|
55
|
+
await db.vfs!.write('a.txt', data);
|
|
56
|
+
const oldStat = db.vfs!.stat('a.txt')!;
|
|
57
|
+
const newStat = await db.vfs!.move('a.txt', 'b.txt');
|
|
58
|
+
expect(newStat.fileId).toBe(oldStat.fileId); // same file on disk
|
|
59
|
+
expect(newStat.path).toBe('b.txt');
|
|
60
|
+
expect(db.vfs!.stat('a.txt')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('delete removes file + metadata', async () => {
|
|
64
|
+
const data = new TextEncoder().encode('del');
|
|
65
|
+
await db.vfs!.write('tmp.txt', data);
|
|
66
|
+
await db.vfs!.remove('tmp.txt');
|
|
67
|
+
expect(db.vfs!.stat('tmp.txt')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('VFS REST transport', () => {
|
|
72
|
+
it('upload + download via REST', async () => {
|
|
73
|
+
const data = new TextEncoder().encode('rest-upload');
|
|
74
|
+
const uploadRes = await fetch(`http://localhost:${port}/files/docs/test.txt`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
body: data,
|
|
77
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
78
|
+
});
|
|
79
|
+
const uploadJson = await uploadRes.json() as any;
|
|
80
|
+
expect(uploadJson.ok).toBe(true);
|
|
81
|
+
expect(uploadJson.data.size).toBe(11);
|
|
82
|
+
|
|
83
|
+
const dlRes = await fetch(`http://localhost:${port}/files/docs/test.txt`);
|
|
84
|
+
expect(dlRes.ok).toBe(true);
|
|
85
|
+
const dlData = new Uint8Array(await dlRes.arrayBuffer());
|
|
86
|
+
expect(new TextDecoder().decode(dlData)).toBe('rest-upload');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('stat + list + delete via REST', async () => {
|
|
90
|
+
await fetch(`http://localhost:${port}/files/a/b.txt`, { method: 'POST', body: new Uint8Array([1, 2, 3]) });
|
|
91
|
+
|
|
92
|
+
const statRes = await fetch(`http://localhost:${port}/files/a/b.txt?stat=1`);
|
|
93
|
+
const statJson = await statRes.json() as any;
|
|
94
|
+
expect(statJson.ok).toBe(true);
|
|
95
|
+
expect(statJson.data.name).toBe('b.txt');
|
|
96
|
+
|
|
97
|
+
const listRes = await fetch(`http://localhost:${port}/files/a?list=1`);
|
|
98
|
+
const listJson = await listRes.json() as any;
|
|
99
|
+
expect(listJson.data.length).toBe(1);
|
|
100
|
+
|
|
101
|
+
const delRes = await fetch(`http://localhost:${port}/files/a/b.txt`, { method: 'DELETE' });
|
|
102
|
+
expect((await delRes.json() as any).ok).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('mkdir + move via REST', async () => {
|
|
106
|
+
const mkdirRes = await fetch(`http://localhost:${port}/files/newdir?mkdir=1`, { method: 'POST' });
|
|
107
|
+
expect((await mkdirRes.json() as any).ok).toBe(true);
|
|
108
|
+
|
|
109
|
+
await fetch(`http://localhost:${port}/files/x.txt`, { method: 'POST', body: new Uint8Array([9]) });
|
|
110
|
+
const moveRes = await fetch(`http://localhost:${port}/files/x.txt?move=y.txt`, { method: 'PUT' });
|
|
111
|
+
const moveJson = await moveRes.json() as any;
|
|
112
|
+
expect(moveJson.ok).toBe(true);
|
|
113
|
+
expect(moveJson.data.path).toBe('y.txt');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('VFS Client SDK', () => {
|
|
118
|
+
it('vfs().upload + download via REST', async () => {
|
|
119
|
+
const vfs = client.vfs();
|
|
120
|
+
const data = new TextEncoder().encode('client-upload');
|
|
121
|
+
const stat = await vfs.upload('sdk/file.txt', data, 'text/plain');
|
|
122
|
+
expect(stat.size).toBe(13);
|
|
123
|
+
|
|
124
|
+
const dl = await vfs.download('sdk/file.txt');
|
|
125
|
+
expect(new TextDecoder().decode(dl)).toBe('client-upload');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('vfs().uploadWS + downloadWS via WS chunks', async () => {
|
|
129
|
+
const vfs = client.vfs();
|
|
130
|
+
const data = new TextEncoder().encode('ws-chunked-data');
|
|
131
|
+
const stat = await vfs.uploadWS('ws/file.bin', data);
|
|
132
|
+
expect(stat.size).toBe(data.byteLength);
|
|
133
|
+
|
|
134
|
+
const dl = await vfs.downloadWS('ws/file.bin');
|
|
135
|
+
expect(new TextDecoder().decode(dl)).toBe('ws-chunked-data');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('vfs().stat + list + mkdir + delete + move', async () => {
|
|
139
|
+
const vfs = client.vfs();
|
|
140
|
+
await vfs.mkdir('testdir');
|
|
141
|
+
await vfs.upload('testdir/a.txt', new TextEncoder().encode('a'));
|
|
142
|
+
|
|
143
|
+
const stat = await vfs.stat('testdir/a.txt');
|
|
144
|
+
expect(stat?.name).toBe('a.txt');
|
|
145
|
+
|
|
146
|
+
const list = await vfs.list('testdir');
|
|
147
|
+
expect(list.length).toBe(1);
|
|
148
|
+
|
|
149
|
+
const moved = await vfs.move('testdir/a.txt', 'testdir/b.txt');
|
|
150
|
+
expect(moved.path).toBe('testdir/b.txt');
|
|
151
|
+
|
|
152
|
+
await vfs.delete('testdir/b.txt');
|
|
153
|
+
const stat2 = await vfs.stat('testdir/b.txt');
|
|
154
|
+
expect(stat2).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('VFS metadata gets subscriptions for free', () => {
|
|
159
|
+
it('subscribing to _vfs path fires on file write', async () => {
|
|
160
|
+
const events: unknown[] = [];
|
|
161
|
+
db.on('_vfs/watched', (snap) => events.push(snap.val()));
|
|
162
|
+
await db.vfs!.write('watched/hello.txt', new TextEncoder().encode('hi'));
|
|
163
|
+
// Subscription fires synchronously for local ops
|
|
164
|
+
expect(events.length).toBeGreaterThan(0);
|
|
165
|
+
});
|
|
166
|
+
});
|