@bod.ee/db 0.9.1 → 0.10.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 +7 -1
- package/.claude/skills/config-file.md +1 -0
- package/.claude/skills/developing-bod-db.md +11 -5
- package/.claude/skills/using-bod-db.md +125 -5
- package/CLAUDE.md +11 -6
- package/README.md +3 -3
- package/admin/admin.ts +57 -0
- package/admin/demo.config.ts +132 -0
- package/admin/rules.ts +4 -1
- package/admin/ui.html +530 -6
- package/bun.lock +33 -0
- package/cli.ts +4 -43
- package/client.ts +5 -3
- package/config.ts +10 -3
- package/index.ts +5 -0
- package/package.json +8 -2
- package/src/client/BodClient.ts +220 -2
- package/src/client/{CachedClient.ts → BodClientCached.ts} +115 -6
- package/src/server/BodDB.ts +24 -8
- package/src/server/KeyAuthEngine.ts +481 -0
- package/src/server/ReplicationEngine.ts +1 -1
- package/src/server/RulesEngine.ts +4 -2
- package/src/server/Transport.ts +213 -0
- package/src/server/VFSEngine.ts +78 -7
- package/src/shared/keyAuth.browser.ts +80 -0
- package/src/shared/keyAuth.ts +177 -0
- package/src/shared/protocol.ts +28 -1
- package/tests/cached-client.test.ts +123 -7
- package/tests/keyauth.test.ts +1010 -0
- package/admin/server.ts +0 -607
package/src/shared/protocol.ts
CHANGED
|
@@ -38,7 +38,34 @@ export type ClientMessage =
|
|
|
38
38
|
| { id: string; op: 'vfs-list'; path: string }
|
|
39
39
|
| { id: string; op: 'vfs-delete'; path: string }
|
|
40
40
|
| { id: string; op: 'vfs-mkdir'; path: string }
|
|
41
|
-
| { id: string; op: 'vfs-move'; path: string; dst: string }
|
|
41
|
+
| { id: string; op: 'vfs-move'; path: string; dst: string }
|
|
42
|
+
// KeyAuth ops
|
|
43
|
+
| { id: string; op: 'auth-challenge' }
|
|
44
|
+
| { id: string; op: 'auth-verify'; publicKey: string; signature: string; nonce: string }
|
|
45
|
+
| { id: string; op: 'auth-link-device'; accountFingerprint: string; password: string; devicePublicKey: string; deviceName?: string }
|
|
46
|
+
| { id: string; op: 'auth-change-password'; fingerprint: string; oldPassword: string; newPassword: string }
|
|
47
|
+
| { id: string; op: 'auth-create-account'; password: string; roles?: string[]; displayName?: string }
|
|
48
|
+
| { id: string; op: 'auth-register-device'; publicKey: string; displayName?: string }
|
|
49
|
+
| { id: string; op: 'auth-revoke-session'; sid: string }
|
|
50
|
+
| { id: string; op: 'auth-revoke-device'; accountFingerprint: string; deviceFingerprint: string }
|
|
51
|
+
| { id: string; op: 'auth-create-role'; role: { id: string; name: string; permissions: Array<{ path: string; read?: boolean; write?: boolean }> } }
|
|
52
|
+
| { id: string; op: 'auth-delete-role'; roleId: string }
|
|
53
|
+
| { id: string; op: 'auth-update-roles'; accountFingerprint: string; roles: string[] }
|
|
54
|
+
| { id: string; op: 'auth-list-accounts' }
|
|
55
|
+
| { id: string; op: 'auth-list-roles' }
|
|
56
|
+
| { id: string; op: 'auth-list-devices'; accountFingerprint: string }
|
|
57
|
+
| { id: string; op: 'auth-list-sessions'; accountFingerprint?: string }
|
|
58
|
+
| { id: string; op: 'auth-request-approval'; publicKey: string }
|
|
59
|
+
| { id: string; op: 'auth-approve-device'; requestId: string }
|
|
60
|
+
| { id: string; op: 'auth-poll-approval'; requestId: string }
|
|
61
|
+
| { id: string; op: 'auth-has-accounts' }
|
|
62
|
+
| { id: string; op: 'auth-account-info'; accountFingerprint?: string }
|
|
63
|
+
| { id: string; op: 'auth-list-account-fingerprints' }
|
|
64
|
+
// Admin ops
|
|
65
|
+
| { id: string; op: 'transform'; path: string; type: string; value?: unknown }
|
|
66
|
+
| { id: string; op: 'set-ttl'; path: string; value: unknown; ttl: number }
|
|
67
|
+
| { id: string; op: 'sweep' }
|
|
68
|
+
| { id: string; op: 'get-rules' };
|
|
42
69
|
|
|
43
70
|
// Server → Client messages
|
|
44
71
|
export type ServerMessage =
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
2
|
import { BodDB } from '../src/server/BodDB.ts';
|
|
3
3
|
import { BodClient, ValueSnapshot } from '../src/client/BodClient.ts';
|
|
4
|
-
import {
|
|
4
|
+
import { BodClientCached } from '../src/client/BodClientCached.ts';
|
|
5
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
7
|
+
import { join } from 'path';
|
|
5
8
|
|
|
6
9
|
let db: BodDB;
|
|
7
10
|
let client: BodClient;
|
|
8
|
-
let cached:
|
|
11
|
+
let cached: BodClientCached;
|
|
12
|
+
const vfsRoot = mkdtempSync(join(tmpdir(), 'boddb-vfs-test-'));
|
|
9
13
|
|
|
10
14
|
beforeAll(async () => {
|
|
11
|
-
db = new BodDB({ port: 0 });
|
|
15
|
+
db = new BodDB({ port: 0, vfs: { storageRoot: vfsRoot } });
|
|
12
16
|
const server = db.serve();
|
|
13
17
|
client = new BodClient({ url: `ws://localhost:${server.port}`, reconnect: false });
|
|
14
18
|
await client.connect();
|
|
15
|
-
cached = new
|
|
19
|
+
cached = new BodClientCached(client, { enabled: true });
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
afterAll(() => {
|
|
19
23
|
client.disconnect();
|
|
20
24
|
db.close();
|
|
25
|
+
try { rmSync(vfsRoot, { recursive: true }); } catch {}
|
|
21
26
|
});
|
|
22
27
|
|
|
23
|
-
describe('
|
|
28
|
+
describe('BodClientCached', () => {
|
|
24
29
|
test('get() fetches from network on first call', async () => {
|
|
25
30
|
db.set('cache/test1', { name: 'hello' });
|
|
26
31
|
const val = await cached.get('cache/test1');
|
|
@@ -89,7 +94,7 @@ describe('CachedClient', () => {
|
|
|
89
94
|
});
|
|
90
95
|
|
|
91
96
|
test('disabled mode is pure passthrough', async () => {
|
|
92
|
-
const passthrough = new
|
|
97
|
+
const passthrough = new BodClientCached(client, { enabled: false });
|
|
93
98
|
db.set('cache/pt', 'val');
|
|
94
99
|
const v1 = await passthrough.get('cache/pt');
|
|
95
100
|
expect(v1).toBe('val');
|
|
@@ -99,7 +104,7 @@ describe('CachedClient', () => {
|
|
|
99
104
|
});
|
|
100
105
|
|
|
101
106
|
test('memory eviction respects maxMemoryEntries', async () => {
|
|
102
|
-
const small = new
|
|
107
|
+
const small = new BodClientCached(client, { maxMemoryEntries: 3 });
|
|
103
108
|
for (let i = 0; i < 5; i++) {
|
|
104
109
|
db.set(`cache/evict/${i}`, i);
|
|
105
110
|
await small.get(`cache/evict/${i}`);
|
|
@@ -141,3 +146,114 @@ describe('CachedClient', () => {
|
|
|
141
146
|
off();
|
|
142
147
|
});
|
|
143
148
|
});
|
|
149
|
+
|
|
150
|
+
describe('CachedVFSClient', () => {
|
|
151
|
+
test('list() caches and returns from memory on second call', async () => {
|
|
152
|
+
const vfs = cached.vfs();
|
|
153
|
+
// Upload a file to have something to list
|
|
154
|
+
await vfs.upload('vfs-test/a.txt', new TextEncoder().encode('hello'), 'text/plain');
|
|
155
|
+
|
|
156
|
+
const list1 = await vfs.list('vfs-test');
|
|
157
|
+
expect(list1.length).toBeGreaterThan(0);
|
|
158
|
+
|
|
159
|
+
// Second call returns from cache (stale-while-revalidate)
|
|
160
|
+
const list2 = await vfs.list('vfs-test');
|
|
161
|
+
expect(list2).toEqual(list1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('stat() caches and returns from memory on second call', async () => {
|
|
165
|
+
const vfs = cached.vfs();
|
|
166
|
+
const stat1 = await vfs.stat('vfs-test/a.txt');
|
|
167
|
+
expect(stat1).not.toBeNull();
|
|
168
|
+
expect(stat1!.name).toBe('a.txt');
|
|
169
|
+
|
|
170
|
+
const stat2 = await vfs.stat('vfs-test/a.txt');
|
|
171
|
+
expect(stat2).toEqual(stat1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('delete() invalidates stat + parent list cache', async () => {
|
|
175
|
+
const vfs = cached.vfs();
|
|
176
|
+
await vfs.upload('vfs-test/del.txt', new TextEncoder().encode('bye'), 'text/plain');
|
|
177
|
+
|
|
178
|
+
// Prime caches
|
|
179
|
+
await vfs.stat('vfs-test/del.txt');
|
|
180
|
+
await vfs.list('vfs-test');
|
|
181
|
+
|
|
182
|
+
await vfs.delete('vfs-test/del.txt');
|
|
183
|
+
|
|
184
|
+
// stat cache should be invalidated — fresh fetch returns null
|
|
185
|
+
const stat = await vfs.stat('vfs-test/del.txt');
|
|
186
|
+
expect(stat).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('move() invalidates both src and dst dirs', async () => {
|
|
190
|
+
const vfs = cached.vfs();
|
|
191
|
+
await vfs.upload('vfs-test/mv-src.txt', new TextEncoder().encode('data'), 'text/plain');
|
|
192
|
+
await vfs.mkdir('vfs-test/subdir');
|
|
193
|
+
|
|
194
|
+
// Prime list caches
|
|
195
|
+
await vfs.list('vfs-test');
|
|
196
|
+
await vfs.list('vfs-test/subdir');
|
|
197
|
+
|
|
198
|
+
await vfs.move('vfs-test/mv-src.txt', 'vfs-test/subdir/mv-dst.txt');
|
|
199
|
+
|
|
200
|
+
// Fresh list should reflect the move
|
|
201
|
+
const rootList = await vfs.list('vfs-test');
|
|
202
|
+
const names = rootList.map(s => s.name);
|
|
203
|
+
expect(names).not.toContain('mv-src.txt');
|
|
204
|
+
|
|
205
|
+
const subList = await vfs.list('vfs-test/subdir');
|
|
206
|
+
const subNames = subList.map(s => s.name);
|
|
207
|
+
expect(subNames).toContain('mv-dst.txt');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('mkdir() invalidates parent list and own list cache', async () => {
|
|
211
|
+
const vfs = cached.vfs();
|
|
212
|
+
// Prime parent list cache
|
|
213
|
+
await vfs.list('vfs-test');
|
|
214
|
+
|
|
215
|
+
await vfs.mkdir('vfs-test/newdir');
|
|
216
|
+
|
|
217
|
+
// Parent list should be invalidated — fresh fetch includes newdir
|
|
218
|
+
const parentList = await vfs.list('vfs-test');
|
|
219
|
+
expect(parentList.map(s => s.name)).toContain('newdir');
|
|
220
|
+
|
|
221
|
+
// The dir's own list cache should also be cleared (was stale empty)
|
|
222
|
+
const dirList = await vfs.list('vfs-test/newdir');
|
|
223
|
+
expect(dirList).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('stats tracks hits, misses and invalidations', async () => {
|
|
227
|
+
const fresh = new BodClientCached(client, { enabled: true });
|
|
228
|
+
const vfs = fresh.vfs();
|
|
229
|
+
|
|
230
|
+
await vfs.upload('vfs-stats/f.txt', new TextEncoder().encode('x'), 'text/plain');
|
|
231
|
+
await vfs.list('vfs-stats'); // miss
|
|
232
|
+
await vfs.list('vfs-stats'); // hit
|
|
233
|
+
|
|
234
|
+
const s = vfs.stats;
|
|
235
|
+
expect(s.misses).toBeGreaterThanOrEqual(1);
|
|
236
|
+
expect(s.hits).toBeGreaterThanOrEqual(1);
|
|
237
|
+
expect(s.hitRate).not.toBe('n/a');
|
|
238
|
+
expect(s.invalidations).toBeGreaterThanOrEqual(1); // from upload
|
|
239
|
+
fresh.close();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('server-side _vfs event invalidates list cache', async () => {
|
|
243
|
+
const vfs = cached.vfs();
|
|
244
|
+
|
|
245
|
+
// Prime list cache
|
|
246
|
+
await vfs.list('vfs-test');
|
|
247
|
+
|
|
248
|
+
// Upload via raw client (simulates another device)
|
|
249
|
+
await client.vfs().upload('vfs-test/remote.txt', new TextEncoder().encode('remote'), 'text/plain');
|
|
250
|
+
|
|
251
|
+
// Wait for _vfs event to propagate
|
|
252
|
+
await new Promise(r => setTimeout(r, 200));
|
|
253
|
+
|
|
254
|
+
// List should now show the new file (cache was invalidated by push event)
|
|
255
|
+
const list = await vfs.list('vfs-test');
|
|
256
|
+
const names = list.map(s => s.name);
|
|
257
|
+
expect(names).toContain('remote.txt');
|
|
258
|
+
});
|
|
259
|
+
});
|