@bod.ee/db 0.7.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.
Files changed (65) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/.claude/skills/config-file.md +54 -0
  3. package/.claude/skills/deploying-bod-db.md +29 -0
  4. package/.claude/skills/developing-bod-db.md +127 -0
  5. package/.claude/skills/using-bod-db.md +403 -0
  6. package/CLAUDE.md +110 -0
  7. package/README.md +252 -0
  8. package/admin/rules.ts +12 -0
  9. package/admin/server.ts +523 -0
  10. package/admin/ui.html +2281 -0
  11. package/cli.ts +177 -0
  12. package/client.ts +2 -0
  13. package/config.ts +20 -0
  14. package/deploy/.env.example +1 -0
  15. package/deploy/base.yaml +18 -0
  16. package/deploy/boddb-logs.yaml +10 -0
  17. package/deploy/boddb.yaml +10 -0
  18. package/deploy/demo.html +196 -0
  19. package/deploy/deploy.ts +32 -0
  20. package/deploy/prod-logs.config.ts +15 -0
  21. package/deploy/prod.config.ts +15 -0
  22. package/index.ts +20 -0
  23. package/mcp.ts +78 -0
  24. package/package.json +29 -0
  25. package/react.ts +1 -0
  26. package/src/client/BodClient.ts +515 -0
  27. package/src/react/hooks.ts +121 -0
  28. package/src/server/BodDB.ts +319 -0
  29. package/src/server/ExpressionRules.ts +250 -0
  30. package/src/server/FTSEngine.ts +76 -0
  31. package/src/server/FileAdapter.ts +116 -0
  32. package/src/server/MCPAdapter.ts +409 -0
  33. package/src/server/MQEngine.ts +286 -0
  34. package/src/server/QueryEngine.ts +45 -0
  35. package/src/server/RulesEngine.ts +108 -0
  36. package/src/server/StorageEngine.ts +464 -0
  37. package/src/server/StreamEngine.ts +320 -0
  38. package/src/server/SubscriptionEngine.ts +120 -0
  39. package/src/server/Transport.ts +479 -0
  40. package/src/server/VectorEngine.ts +115 -0
  41. package/src/shared/errors.ts +15 -0
  42. package/src/shared/pathUtils.ts +94 -0
  43. package/src/shared/protocol.ts +59 -0
  44. package/src/shared/transforms.ts +99 -0
  45. package/tests/batch.test.ts +60 -0
  46. package/tests/bench.ts +205 -0
  47. package/tests/e2e.test.ts +284 -0
  48. package/tests/expression-rules.test.ts +114 -0
  49. package/tests/file-adapter.test.ts +57 -0
  50. package/tests/fts.test.ts +58 -0
  51. package/tests/mq-flow.test.ts +204 -0
  52. package/tests/mq.test.ts +326 -0
  53. package/tests/push.test.ts +55 -0
  54. package/tests/query.test.ts +60 -0
  55. package/tests/rules.test.ts +78 -0
  56. package/tests/sse.test.ts +78 -0
  57. package/tests/storage.test.ts +199 -0
  58. package/tests/stream.test.ts +385 -0
  59. package/tests/stress.test.ts +202 -0
  60. package/tests/subscriptions.test.ts +86 -0
  61. package/tests/transforms.test.ts +92 -0
  62. package/tests/transport.test.ts +209 -0
  63. package/tests/ttl.test.ts +70 -0
  64. package/tests/vector.test.ts +69 -0
  65. package/tsconfig.json +27 -0
@@ -0,0 +1,284 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+ import { BodClient } from '../src/client/BodClient.ts';
4
+
5
+ let db: BodDB;
6
+ let client: BodClient;
7
+
8
+ function setup(rules?: Record<string, any>, auth?: any) {
9
+ db = new BodDB({ rules, port: 0, auth });
10
+ const server = db.serve();
11
+ client = new BodClient({ url: `ws://localhost:${server.port}`, reconnect: false });
12
+ return server.port;
13
+ }
14
+
15
+ afterEach(() => {
16
+ client?.disconnect();
17
+ db?.close();
18
+ });
19
+
20
+ describe('E2E: BodClient CRUD', () => {
21
+ test('set and get', async () => {
22
+ setup();
23
+ await client.connect();
24
+
25
+ await client.set('users/u1', { name: 'Alice', role: 'admin' });
26
+ const val = await client.get('users/u1');
27
+ expect(val).toEqual({ name: 'Alice', role: 'admin' });
28
+ });
29
+
30
+ test('get leaf path', async () => {
31
+ setup();
32
+ await client.connect();
33
+
34
+ await client.set('users/u1', { name: 'Alice' });
35
+ expect(await client.get('users/u1/name')).toBe('Alice');
36
+ });
37
+
38
+ test('update multi-path', async () => {
39
+ setup();
40
+ await client.connect();
41
+
42
+ await client.set('users/u1', { name: 'Alice' });
43
+ await client.update({ 'users/u1/name': 'Bob', 'flags/dirty': true });
44
+ expect(await client.get('users/u1/name')).toBe('Bob');
45
+ expect(await client.get('flags/dirty')).toBe(true);
46
+ });
47
+
48
+ test('delete', async () => {
49
+ setup();
50
+ await client.connect();
51
+
52
+ await client.set('tmp/data', 'hello');
53
+ await client.delete('tmp/data');
54
+ expect(await client.get('tmp/data')).toBeNull();
55
+ });
56
+
57
+ test('get nonexistent returns null', async () => {
58
+ setup();
59
+ await client.connect();
60
+
61
+ expect(await client.get('nope/nothing')).toBeNull();
62
+ });
63
+ });
64
+
65
+ describe('E2E: BodClient Query', () => {
66
+ test('fluent query with filters', async () => {
67
+ setup();
68
+ await client.connect();
69
+
70
+ await client.set('users/u1', { name: 'Alice', role: 'admin' });
71
+ await client.set('users/u2', { name: 'Bob', role: 'user' });
72
+ await client.set('users/u3', { name: 'Charlie', role: 'admin' });
73
+
74
+ const result = await client.query('users')
75
+ .where('role', '==', 'admin')
76
+ .order('name')
77
+ .get() as any[];
78
+
79
+ expect(result.length).toBe(2);
80
+ expect(result[0].name).toBe('Alice');
81
+ expect(result[1].name).toBe('Charlie');
82
+ });
83
+
84
+ test('query with limit and offset', async () => {
85
+ setup();
86
+ await client.connect();
87
+
88
+ await client.set('items/a', { v: 1 });
89
+ await client.set('items/b', { v: 2 });
90
+ await client.set('items/c', { v: 3 });
91
+
92
+ const result = await client.query('items')
93
+ .order('v')
94
+ .offset(1)
95
+ .limit(1)
96
+ .get() as any[];
97
+
98
+ expect(result.length).toBe(1);
99
+ expect(result[0].v).toBe(2);
100
+ });
101
+ });
102
+
103
+ describe('E2E: BodClient Subscriptions', () => {
104
+ test('value subscription receives pushes', async () => {
105
+ setup();
106
+ await client.connect();
107
+
108
+ const events: unknown[] = [];
109
+ client.on('users/u1', (snap) => events.push(snap.val()));
110
+
111
+ // Wait for sub to register
112
+ await Bun.sleep(30);
113
+
114
+ // Write from server side
115
+ db.set('users/u1', { name: 'Alice' });
116
+ await Bun.sleep(50);
117
+
118
+ expect(events.length).toBe(1);
119
+ expect(events[0]).toEqual({ name: 'Alice' });
120
+ });
121
+
122
+ test('value snapshot properties', async () => {
123
+ setup();
124
+ await client.connect();
125
+
126
+ let snap: any;
127
+ client.on('users/u1', (s) => { snap = s; });
128
+ await Bun.sleep(30);
129
+
130
+ db.set('users/u1', { name: 'Alice' });
131
+ await Bun.sleep(50);
132
+
133
+ expect(snap.path).toBe('users/u1');
134
+ expect(snap.key).toBe('u1');
135
+ expect(snap.exists()).toBe(true);
136
+ expect(snap.val()).toEqual({ name: 'Alice' });
137
+ });
138
+
139
+ test('child subscription receives events', async () => {
140
+ setup();
141
+ await client.connect();
142
+
143
+ const events: Array<{ type: string; key: string }> = [];
144
+ client.onChild('users', (e) => events.push({ type: e.type, key: e.key }));
145
+ await Bun.sleep(30);
146
+
147
+ db.set('users/u1', { name: 'Alice' });
148
+ db.set('users/u2', { name: 'Bob' });
149
+ await Bun.sleep(50);
150
+
151
+ expect(events.length).toBe(2);
152
+ expect(events[0]).toEqual({ type: 'added', key: 'u1' });
153
+ expect(events[1]).toEqual({ type: 'added', key: 'u2' });
154
+ });
155
+
156
+ test('unsubscribe stops pushes', async () => {
157
+ setup();
158
+ await client.connect();
159
+
160
+ const events: unknown[] = [];
161
+ const off = client.on('users/u1', (snap) => events.push(snap.val()));
162
+ await Bun.sleep(30);
163
+
164
+ db.set('users/u1', { name: 'Alice' });
165
+ await Bun.sleep(50);
166
+ expect(events.length).toBe(1);
167
+
168
+ off();
169
+ await Bun.sleep(30);
170
+
171
+ db.set('users/u1', { name: 'Bob' });
172
+ await Bun.sleep(50);
173
+ expect(events.length).toBe(1); // no new event
174
+ });
175
+
176
+ test('multiple clients receive updates', async () => {
177
+ db = new BodDB({ port: 0 });
178
+ const server = db.serve();
179
+ const port = server.port;
180
+
181
+ const c1 = new BodClient({ url: `ws://localhost:${port}`, reconnect: false });
182
+ const c2 = new BodClient({ url: `ws://localhost:${port}`, reconnect: false });
183
+ await c1.connect();
184
+ await c2.connect();
185
+
186
+ const e1: unknown[] = [];
187
+ const e2: unknown[] = [];
188
+ c1.on('data', (s) => e1.push(s.val()));
189
+ c2.on('data', (s) => e2.push(s.val()));
190
+ await Bun.sleep(30);
191
+
192
+ // c1 writes, both should receive
193
+ await c1.set('data', 'hello');
194
+ await Bun.sleep(50);
195
+
196
+ expect(e1.length).toBe(1);
197
+ expect(e2.length).toBe(1);
198
+ expect(e1[0]).toBe('hello');
199
+ expect(e2[0]).toBe('hello');
200
+
201
+ c1.disconnect();
202
+ c2.disconnect();
203
+ // Use the module-level client variable for cleanup
204
+ client = c1;
205
+ });
206
+ });
207
+
208
+ describe('E2E: Auth', () => {
209
+ test('auth token flow', async () => {
210
+ setup(
211
+ { 'private': { write: (ctx: any) => !!ctx.auth } },
212
+ (token: string) => token === 'secret' ? { uid: 'u1' } : null,
213
+ );
214
+
215
+ client = new BodClient({
216
+ url: `ws://localhost:${db.serve ? (db as any).transport?.port : 4400}`,
217
+ reconnect: false,
218
+ auth: () => 'secret',
219
+ });
220
+
221
+ // Re-create client pointing to actual port
222
+ const port = (db as any).transport?.port;
223
+ client = new BodClient({
224
+ url: `ws://localhost:${port}`,
225
+ reconnect: false,
226
+ auth: () => 'secret',
227
+ });
228
+ await client.connect();
229
+
230
+ // Should succeed after auth
231
+ await client.set('private/data', 'works');
232
+ expect(await client.get('private/data')).toBe('works');
233
+ });
234
+
235
+ test('bad auth rejects', async () => {
236
+ db = new BodDB({ port: 0, auth: (token) => token === 'good' ? { uid: 'u1' } : null });
237
+ const server = db.serve();
238
+
239
+ client = new BodClient({
240
+ url: `ws://localhost:${server.port}`,
241
+ reconnect: false,
242
+ auth: () => 'bad',
243
+ });
244
+
245
+ await expect(client.connect()).rejects.toThrow('Authentication failed');
246
+ });
247
+ });
248
+
249
+ describe('E2E: Reconnection', () => {
250
+ test('reconnects and re-subscribes', async () => {
251
+ db = new BodDB({ port: 0 });
252
+ const server = db.serve();
253
+ const port = server.port;
254
+
255
+ client = new BodClient({
256
+ url: `ws://localhost:${port}`,
257
+ reconnect: true,
258
+ reconnectInterval: 100,
259
+ });
260
+ await client.connect();
261
+
262
+ const events: unknown[] = [];
263
+ client.on('data', (s) => events.push(s.val()));
264
+ await Bun.sleep(30);
265
+
266
+ // Write before disconnect
267
+ db.set('data', 'before');
268
+ await Bun.sleep(50);
269
+ expect(events.length).toBe(1);
270
+
271
+ // Restart server on same port
272
+ db.stop();
273
+ await Bun.sleep(50);
274
+ db.serve({ port });
275
+ await Bun.sleep(300); // wait for reconnect
276
+
277
+ // Write after reconnect
278
+ db.set('data', 'after');
279
+ await Bun.sleep(100);
280
+
281
+ expect(events.length).toBe(2);
282
+ expect(events[1]).toBe('after');
283
+ });
284
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { compileRule } from '../src/server/ExpressionRules.ts';
3
+ import { RulesEngine } from '../src/server/RulesEngine.ts';
4
+
5
+ describe('ExpressionRules — compileRule', () => {
6
+ test('boolean literal', () => {
7
+ expect(compileRule(true)({} as any)).toBe(true);
8
+ expect(compileRule(false)({} as any)).toBe(false);
9
+ });
10
+
11
+ test('string true/false', () => {
12
+ expect(compileRule('true')({} as any)).toBe(true);
13
+ expect(compileRule('false')({} as any)).toBe(false);
14
+ });
15
+
16
+ test('auth.uid === $uid', () => {
17
+ const fn = compileRule("auth.uid === $uid");
18
+ expect(fn({ auth: { uid: 'u1' }, params: { uid: 'u1' }, data: null, newData: null })).toBe(true);
19
+ expect(fn({ auth: { uid: 'u2' }, params: { uid: 'u1' }, data: null, newData: null })).toBe(false);
20
+ });
21
+
22
+ test('null auth → false for auth checks', () => {
23
+ const fn = compileRule("auth.uid === 'u1'");
24
+ expect(fn({ auth: null, params: {}, data: null, newData: null })).toBe(false);
25
+ });
26
+
27
+ test('auth.role == admin string', () => {
28
+ const fn = compileRule("auth.role == 'admin'");
29
+ expect(fn({ auth: { role: 'admin' }, params: {}, data: null, newData: null })).toBe(true);
30
+ expect(fn({ auth: { role: 'user' }, params: {}, data: null, newData: null })).toBe(false);
31
+ });
32
+
33
+ test('logical AND', () => {
34
+ const fn = compileRule("auth.role == 'admin' && auth.active == true");
35
+ expect(fn({ auth: { role: 'admin', active: true }, params: {}, data: null, newData: null })).toBe(true);
36
+ expect(fn({ auth: { role: 'admin', active: false }, params: {}, data: null, newData: null })).toBe(false);
37
+ });
38
+
39
+ test('logical OR', () => {
40
+ const fn = compileRule("auth.role == 'admin' || auth.role == 'mod'");
41
+ expect(fn({ auth: { role: 'mod' }, params: {}, data: null, newData: null })).toBe(true);
42
+ expect(fn({ auth: { role: 'user' }, params: {}, data: null, newData: null })).toBe(false);
43
+ });
44
+
45
+ test('negation', () => {
46
+ const fn = compileRule("!auth.banned");
47
+ expect(fn({ auth: { banned: false }, params: {}, data: null, newData: null })).toBe(true);
48
+ expect(fn({ auth: { banned: true }, params: {}, data: null, newData: null })).toBe(false);
49
+ });
50
+
51
+ test('numeric comparison', () => {
52
+ const fn = compileRule("auth.level >= 5");
53
+ expect(fn({ auth: { level: 5 }, params: {}, data: null, newData: null })).toBe(true);
54
+ expect(fn({ auth: { level: 3 }, params: {}, data: null, newData: null })).toBe(false);
55
+ });
56
+
57
+ test('parenthesized expression', () => {
58
+ const fn = compileRule("(auth.role == 'admin') || (auth.uid === $uid)");
59
+ expect(fn({ auth: { role: 'user', uid: 'u1' }, params: { uid: 'u1' }, data: null, newData: null })).toBe(true);
60
+ expect(fn({ auth: { role: 'user', uid: 'u2' }, params: { uid: 'u1' }, data: null, newData: null })).toBe(false);
61
+ });
62
+
63
+ test('data and newData access', () => {
64
+ const fn = compileRule("newData.status != 'deleted'");
65
+ expect(fn({ auth: null, params: {}, data: null, newData: { status: 'active' } })).toBe(true);
66
+ expect(fn({ auth: null, params: {}, data: null, newData: { status: 'deleted' } })).toBe(false);
67
+ });
68
+
69
+ test('null comparison', () => {
70
+ const fn = compileRule("auth != null");
71
+ expect(fn({ auth: { uid: 'u1' }, params: {}, data: null, newData: null })).toBe(true);
72
+ expect(fn({ auth: null, params: {}, data: null, newData: null })).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe('RulesEngine with expression strings', () => {
77
+ test('expression rules work in RulesEngine', () => {
78
+ const r = new RulesEngine({
79
+ rules: {
80
+ 'users/$uid': {
81
+ read: true,
82
+ write: "auth.uid === $uid",
83
+ },
84
+ },
85
+ });
86
+ expect(r.check('read', 'users/u1', null)).toBe(true);
87
+ expect(r.check('write', 'users/u1', { uid: 'u1' })).toBe(true);
88
+ expect(r.check('write', 'users/u1', { uid: 'u2' })).toBe(false);
89
+ });
90
+
91
+ test('mixed function + expression rules', () => {
92
+ const r = new RulesEngine({
93
+ rules: {
94
+ 'public': { read: true, write: false },
95
+ 'admin': {
96
+ read: (ctx) => ctx.auth?.role === 'admin',
97
+ write: "auth.role == 'admin'",
98
+ },
99
+ },
100
+ });
101
+ expect(r.check('read', 'public/data', null)).toBe(true);
102
+ expect(r.check('write', 'public/data', null)).toBe(false);
103
+ expect(r.check('write', 'admin/config', { role: 'admin' })).toBe(true);
104
+ expect(r.check('write', 'admin/config', { role: 'user' })).toBe(false);
105
+ });
106
+
107
+ test('boolean false blocks access', () => {
108
+ const r = new RulesEngine({
109
+ rules: { 'locked': { read: false, write: false } },
110
+ });
111
+ expect(r.check('read', 'locked/path', null)).toBe(false);
112
+ expect(r.check('write', 'locked/path', null)).toBe(false);
113
+ });
114
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+ import { FileAdapter } from '../src/server/FileAdapter.ts';
4
+ import { mkdtemp, writeFile, rm } from 'fs/promises';
5
+ import { join } from 'path';
6
+ import { tmpdir } from 'os';
7
+
8
+ describe('FileAdapter', () => {
9
+ let tmpDir: string;
10
+ let db: BodDB;
11
+ let adapter: FileAdapter;
12
+
13
+ beforeAll(async () => {
14
+ tmpDir = await mkdtemp(join(tmpdir(), 'zuzdb-fa-'));
15
+ await writeFile(join(tmpDir, 'hello.txt'), 'Hello World');
16
+ await writeFile(join(tmpDir, 'data.json'), '{"key": "value"}');
17
+
18
+ db = new BodDB({ sweepInterval: 0 });
19
+ adapter = new FileAdapter(db, { root: tmpDir, watch: false, metadata: true, indexContent: false });
20
+ await adapter.start();
21
+ });
22
+
23
+ afterAll(async () => {
24
+ adapter.stop();
25
+ db.close();
26
+ await rm(tmpDir, { recursive: true });
27
+ });
28
+
29
+ test('scans directory and stores metadata', () => {
30
+ const hello = db.get('files/hello.txt') as Record<string, unknown>;
31
+ expect(hello).not.toBeNull();
32
+ expect(hello.mime).toBe('text/plain');
33
+ expect(typeof hello.size).toBe('number');
34
+ expect(typeof hello.mtime).toBe('number');
35
+ });
36
+
37
+ test('stores metadata for json files', () => {
38
+ const data = db.get('files/data.json') as Record<string, unknown>;
39
+ expect(data).not.toBeNull();
40
+ expect(data.mime).toBe('application/json');
41
+ });
42
+
43
+ test('readContent reads file from disk', async () => {
44
+ const content = await adapter.readContent('hello.txt');
45
+ expect(content).toBe('Hello World');
46
+ });
47
+
48
+ test('writeContent writes to disk and syncs', async () => {
49
+ await adapter.writeContent('new.txt', 'New content');
50
+ const content = await adapter.readContent('new.txt');
51
+ expect(content).toBe('New content');
52
+
53
+ const meta = db.get('files/new.txt') as Record<string, unknown>;
54
+ expect(meta).not.toBeNull();
55
+ expect(meta.mime).toBe('text/plain');
56
+ });
57
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+
4
+ describe('FTS5 Full-Text Search', () => {
5
+ test('index and search text', () => {
6
+ const db = new BodDB({ sweepInterval: 0, fts: {} });
7
+ db.set('articles/a1', { title: 'Hello World', body: 'This is a test article' });
8
+ db.set('articles/a2', { title: 'Goodbye', body: 'Another article about testing' });
9
+ db.index('articles/a1', 'Hello World This is a test article');
10
+ db.index('articles/a2', 'Goodbye Another article about testing');
11
+
12
+ const results = db.search({ text: 'article' });
13
+ expect(results.length).toBe(2);
14
+ expect(results.some(r => r.path === 'articles/a1')).toBe(true);
15
+ db.close();
16
+ });
17
+
18
+ test('search with path prefix filter', () => {
19
+ const db = new BodDB({ sweepInterval: 0, fts: {} });
20
+ db.set('posts/p1', { text: 'hello' });
21
+ db.set('comments/c1', { text: 'hello there' });
22
+ db.index('posts/p1', 'hello');
23
+ db.index('comments/c1', 'hello there');
24
+
25
+ const results = db.search({ text: 'hello', path: 'posts' });
26
+ expect(results.length).toBe(1);
27
+ expect(results[0].path).toBe('posts/p1');
28
+ db.close();
29
+ });
30
+
31
+ test('search with limit', () => {
32
+ const db = new BodDB({ sweepInterval: 0, fts: {} });
33
+ for (let i = 0; i < 10; i++) {
34
+ db.set(`items/i${i}`, { text: `item ${i}` });
35
+ db.index(`items/i${i}`, `item ${i}`);
36
+ }
37
+
38
+ const results = db.search({ text: 'item', limit: 3 });
39
+ expect(results.length).toBe(3);
40
+ db.close();
41
+ });
42
+
43
+ test('index by fields', () => {
44
+ const db = new BodDB({ sweepInterval: 0, fts: {} });
45
+ db.set('docs/d1', { title: 'TypeScript Guide', body: 'Learn TS' });
46
+ db.index('docs/d1', ['title', 'body']);
47
+
48
+ const results = db.search({ text: 'TypeScript' });
49
+ expect(results.length).toBe(1);
50
+ db.close();
51
+ });
52
+
53
+ test('throws without fts config', () => {
54
+ const db = new BodDB({ sweepInterval: 0 });
55
+ expect(() => db.search({ text: 'test' })).toThrow('FTS not configured');
56
+ db.close();
57
+ });
58
+ });