@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,92 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+ import { increment, serverTimestamp, arrayUnion, arrayRemove, ref } from '../src/shared/transforms.ts';
4
+
5
+ describe('Transforms & Sentinels', () => {
6
+ test('increment creates/increments a number', () => {
7
+ const db = new BodDB({ sweepInterval: 0 });
8
+ db.set('counter', 0);
9
+ db.set('counter', increment(5));
10
+ expect(db.get('counter')).toBe(5);
11
+ db.set('counter', increment(-2));
12
+ expect(db.get('counter')).toBe(3);
13
+ // Increment on missing value starts from 0
14
+ db.set('new_counter', increment(1));
15
+ expect(db.get('new_counter')).toBe(1);
16
+ db.close();
17
+ });
18
+
19
+ test('serverTimestamp sets current time', () => {
20
+ const db = new BodDB({ sweepInterval: 0 });
21
+ const before = Date.now();
22
+ db.set('ts', serverTimestamp());
23
+ const after = Date.now();
24
+ const val = db.get('ts') as number;
25
+ expect(val).toBeGreaterThanOrEqual(before);
26
+ expect(val).toBeLessThanOrEqual(after);
27
+ db.close();
28
+ });
29
+
30
+ test('arrayUnion adds unique items', () => {
31
+ const db = new BodDB({ sweepInterval: 0 });
32
+ db.set('tags', ['a', 'b']);
33
+ db.set('tags', arrayUnion('b', 'c'));
34
+ expect(db.get('tags')).toEqual(['a', 'b', 'c']);
35
+ db.close();
36
+ });
37
+
38
+ test('arrayRemove removes items', () => {
39
+ const db = new BodDB({ sweepInterval: 0 });
40
+ db.set('tags', ['a', 'b', 'c']);
41
+ db.set('tags', arrayRemove('b'));
42
+ expect(db.get('tags')).toEqual(['a', 'c']);
43
+ db.close();
44
+ });
45
+
46
+ test('nested sentinels in objects', () => {
47
+ const db = new BodDB({ sweepInterval: 0 });
48
+ db.set('user/stats', { views: 10, likes: 5 });
49
+ db.set('user/stats', { views: increment(1), likes: increment(2) });
50
+ expect(db.get('user/stats')).toEqual({ views: 11, likes: 7 });
51
+ db.close();
52
+ });
53
+
54
+ test('sentinels work with merge', () => {
55
+ const db = new BodDB({ sweepInterval: 0 });
56
+ db.set('item', { count: 3, name: 'test' });
57
+ db.update({ 'item': { count: increment(1) } });
58
+ expect(db.get('item/count')).toBe(4);
59
+ expect(db.get('item/name')).toBe('test');
60
+ db.close();
61
+ });
62
+
63
+ test('ref sentinel stores _ref marker', () => {
64
+ const db = new BodDB({ sweepInterval: 0 });
65
+ db.set('users/u1', { name: 'Alice' });
66
+ db.set('posts/p1', { author: ref('users/u1'), title: 'Hello' });
67
+ const post = db.get('posts/p1') as Record<string, unknown>;
68
+ expect(post.author).toEqual({ _ref: 'users/u1' });
69
+ db.close();
70
+ });
71
+
72
+ test('ref resolution via storage.get with resolve option', () => {
73
+ const db = new BodDB({ sweepInterval: 0 });
74
+ db.set('users/u1', { name: 'Alice' });
75
+ db.set('posts/p1', { author: ref('users/u1'), title: 'Hello' });
76
+ const post = db.storage.get('posts/p1', { resolve: true }) as Record<string, unknown>;
77
+ expect(post.author).toEqual({ name: 'Alice' });
78
+ expect(post.title).toBe('Hello');
79
+ db.close();
80
+ });
81
+
82
+ test('ref resolution with specific fields', () => {
83
+ const db = new BodDB({ sweepInterval: 0 });
84
+ db.set('users/u1', { name: 'Alice' });
85
+ db.set('cats/c1', { name: 'Whiskers' });
86
+ db.set('posts/p1', { author: ref('users/u1'), cat: ref('cats/c1') });
87
+ const post = db.storage.get('posts/p1', { resolve: ['author'] }) as Record<string, unknown>;
88
+ expect(post.author).toEqual({ name: 'Alice' });
89
+ expect(post.cat).toEqual({ _ref: 'cats/c1' }); // Not resolved
90
+ db.close();
91
+ });
92
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+
4
+ let db: BodDB;
5
+
6
+ function setup(rules?: Record<string, any>) {
7
+ db = new BodDB({ rules, port: 0 }); // port 0 = random
8
+ const server = db.serve();
9
+ return server.port;
10
+ }
11
+
12
+ afterEach(() => {
13
+ db?.close();
14
+ });
15
+
16
+ function ws(port: number): Promise<WebSocket> {
17
+ return new Promise((resolve, reject) => {
18
+ const socket = new WebSocket(`ws://localhost:${port}`);
19
+ socket.onopen = () => resolve(socket);
20
+ socket.onerror = (e) => reject(e);
21
+ });
22
+ }
23
+
24
+ function send(socket: WebSocket, msg: Record<string, unknown>): Promise<Record<string, unknown>> {
25
+ return new Promise((resolve) => {
26
+ const handler = (e: MessageEvent) => {
27
+ const data = JSON.parse(e.data);
28
+ if (data.id === msg.id) {
29
+ socket.removeEventListener('message', handler);
30
+ resolve(data);
31
+ }
32
+ };
33
+ socket.addEventListener('message', handler);
34
+ socket.send(JSON.stringify(msg));
35
+ });
36
+ }
37
+
38
+ describe('Transport — WebSocket', () => {
39
+ test('CRUD via WebSocket', async () => {
40
+ const port = setup();
41
+ const socket = await ws(port);
42
+
43
+ // Set
44
+ const setRes = await send(socket, { id: '1', op: 'set', path: 'users/u1', value: { name: 'Alice', role: 'admin' } });
45
+ expect(setRes.ok).toBe(true);
46
+
47
+ // Get
48
+ const getRes = await send(socket, { id: '2', op: 'get', path: 'users/u1' });
49
+ expect(getRes.ok).toBe(true);
50
+ expect(getRes.data).toEqual({ name: 'Alice', role: 'admin' });
51
+
52
+ // Update
53
+ const updRes = await send(socket, { id: '3', op: 'update', updates: { 'users/u1/name': 'Bob' } });
54
+ expect(updRes.ok).toBe(true);
55
+
56
+ const getRes2 = await send(socket, { id: '4', op: 'get', path: 'users/u1/name' });
57
+ expect(getRes2.data).toBe('Bob');
58
+
59
+ // Delete
60
+ const delRes = await send(socket, { id: '5', op: 'delete', path: 'users/u1' });
61
+ expect(delRes.ok).toBe(true);
62
+
63
+ const getRes3 = await send(socket, { id: '6', op: 'get', path: 'users/u1' });
64
+ expect(getRes3.data).toBeNull();
65
+
66
+ socket.close();
67
+ });
68
+
69
+ test('query via WebSocket', async () => {
70
+ const port = setup();
71
+ const socket = await ws(port);
72
+
73
+ await send(socket, { id: '1', op: 'set', path: 'users/u1', value: { name: 'Alice', role: 'admin' } });
74
+ await send(socket, { id: '2', op: 'set', path: 'users/u2', value: { name: 'Bob', role: 'user' } });
75
+
76
+ const qRes = await send(socket, {
77
+ id: '3', op: 'query', path: 'users',
78
+ filters: [{ field: 'role', op: '==', value: 'admin' }],
79
+ });
80
+ expect(qRes.ok).toBe(true);
81
+ expect((qRes.data as any[]).length).toBe(1);
82
+ expect((qRes.data as any[])[0].name).toBe('Alice');
83
+
84
+ socket.close();
85
+ });
86
+
87
+ test('value subscription via WebSocket', async () => {
88
+ const port = setup();
89
+ const socket = await ws(port);
90
+
91
+ // Subscribe
92
+ await send(socket, { id: '1', op: 'sub', path: 'users/u1', event: 'value' });
93
+
94
+ // Collect push messages
95
+ const pushes: any[] = [];
96
+ socket.addEventListener('message', (e: MessageEvent) => {
97
+ const data = JSON.parse(e.data);
98
+ if (data.type === 'value') pushes.push(data);
99
+ });
100
+
101
+ // Trigger a write from server side
102
+ db.set('users/u1', { name: 'Alice' });
103
+ await Bun.sleep(50);
104
+
105
+ expect(pushes.length).toBe(1);
106
+ expect(pushes[0].data).toEqual({ name: 'Alice' });
107
+
108
+ // Unsub
109
+ await send(socket, { id: '2', op: 'unsub', path: 'users/u1', event: 'value' });
110
+ db.set('users/u1', { name: 'Bob' });
111
+ await Bun.sleep(50);
112
+
113
+ expect(pushes.length).toBe(1); // no new push
114
+
115
+ socket.close();
116
+ });
117
+
118
+ test('rules block unauthorized write', async () => {
119
+ const port = setup({
120
+ 'secret': {
121
+ read: () => false,
122
+ write: () => false,
123
+ },
124
+ });
125
+ const socket = await ws(port);
126
+
127
+ const res = await send(socket, { id: '1', op: 'set', path: 'secret/data', value: 'hack' });
128
+ expect(res.ok).toBe(false);
129
+ expect(res.code).toBe('PERMISSION_DENIED');
130
+
131
+ socket.close();
132
+ });
133
+
134
+ test('auth flow', async () => {
135
+ db = new BodDB({
136
+ rules: {
137
+ 'private': {
138
+ write: (ctx) => !!ctx.auth,
139
+ },
140
+ },
141
+ port: 0,
142
+ auth: (token) => token === 'valid' ? { uid: 'u1' } : null,
143
+ });
144
+ const server = db.serve();
145
+ const socket = await ws(server.port);
146
+
147
+ // Write without auth
148
+ const res1 = await send(socket, { id: '1', op: 'set', path: 'private/data', value: 'test' });
149
+ expect(res1.ok).toBe(false);
150
+
151
+ // Auth with bad token
152
+ const authBad = await send(socket, { id: '2', op: 'auth', token: 'invalid' });
153
+ expect(authBad.ok).toBe(false);
154
+
155
+ // Auth with good token
156
+ const authOk = await send(socket, { id: '3', op: 'auth', token: 'valid' });
157
+ expect(authOk.ok).toBe(true);
158
+
159
+ // Write after auth
160
+ const res2 = await send(socket, { id: '4', op: 'set', path: 'private/data', value: 'test' });
161
+ expect(res2.ok).toBe(true);
162
+
163
+ socket.close();
164
+ });
165
+ });
166
+
167
+ describe('Transport — REST', () => {
168
+ test('GET /db/path', async () => {
169
+ const port = setup();
170
+ db.set('users/u1', { name: 'Alice' });
171
+
172
+ const res = await fetch(`http://localhost:${port}/db/users/u1`);
173
+ const json = await res.json();
174
+ expect(json.ok).toBe(true);
175
+ expect(json.data).toEqual({ name: 'Alice' });
176
+ });
177
+
178
+ test('PUT /db/path', async () => {
179
+ const port = setup();
180
+
181
+ const res = await fetch(`http://localhost:${port}/db/items/a`, {
182
+ method: 'PUT',
183
+ body: JSON.stringify({ v: 1 }),
184
+ headers: { 'Content-Type': 'application/json' },
185
+ });
186
+ const json = await res.json();
187
+ expect(json.ok).toBe(true);
188
+ expect(db.get('items/a')).toEqual({ v: 1 });
189
+ });
190
+
191
+ test('DELETE /db/path', async () => {
192
+ const port = setup();
193
+ db.set('tmp/data', 'hello');
194
+
195
+ const res = await fetch(`http://localhost:${port}/db/tmp/data`, { method: 'DELETE' });
196
+ const json = await res.json();
197
+ expect(json.ok).toBe(true);
198
+ expect(db.get('tmp/data')).toBeNull();
199
+ });
200
+
201
+ test('REST rules enforcement', async () => {
202
+ const port = setup({
203
+ 'locked': { read: () => false, write: () => false },
204
+ });
205
+
206
+ const res = await fetch(`http://localhost:${port}/db/locked/path`);
207
+ expect(res.status).toBe(403);
208
+ });
209
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+
4
+ describe('TTL / Auto-Expiry', () => {
5
+ /** Helper: force-expire all entries at a path by setting expires_at to 1 (way in the past) */
6
+ function forceExpire(db: BodDB, path: string) {
7
+ db.storage.db.run('UPDATE nodes SET expires_at = 1 WHERE path = ? OR path LIKE ?', [path, path + '/%']);
8
+ }
9
+
10
+ test('set with ttl marks for expiry', () => {
11
+ const db = new BodDB({ sweepInterval: 0 });
12
+ db.set('temp', 'hello', { ttl: 60 });
13
+ expect(db.get('temp')).toBe('hello');
14
+
15
+ // Not expired yet (60s TTL)
16
+ const swept = db.sweep();
17
+ expect(swept).toEqual([]);
18
+ expect(db.get('temp')).toBe('hello');
19
+ db.close();
20
+ });
21
+
22
+ test('expired entries are swept', () => {
23
+ const db = new BodDB({ sweepInterval: 0 });
24
+ db.set('temp', 'hello', { ttl: 60 });
25
+ expect(db.get('temp')).toBe('hello');
26
+
27
+ forceExpire(db, 'temp');
28
+ const swept = db.sweep();
29
+ expect(swept.length).toBeGreaterThan(0);
30
+ expect(db.get('temp')).toBeNull();
31
+ db.close();
32
+ });
33
+
34
+ test('non-ttl entries are not swept', () => {
35
+ const db = new BodDB({ sweepInterval: 0 });
36
+ db.set('permanent', 'stays');
37
+ db.set('temp', 'goes', { ttl: 60 });
38
+
39
+ forceExpire(db, 'temp');
40
+ db.sweep();
41
+ expect(db.get('permanent')).toBe('stays');
42
+ expect(db.get('temp')).toBeNull();
43
+ db.close();
44
+ });
45
+
46
+ test('ttl on object sets expiry on all leaves', () => {
47
+ const db = new BodDB({ sweepInterval: 0 });
48
+ db.set('session/data', { user: 'alice', token: 'abc' }, { ttl: 60 });
49
+ expect(db.get('session/data')).toEqual({ user: 'alice', token: 'abc' });
50
+
51
+ forceExpire(db, 'session/data');
52
+ db.sweep();
53
+ expect(db.get('session/data')).toBeNull();
54
+ db.close();
55
+ });
56
+
57
+ test('sweep triggers subscription notifications', () => {
58
+ const db = new BodDB({ sweepInterval: 0 });
59
+ db.set('temp', 'hello', { ttl: 60 });
60
+
61
+ const events: unknown[] = [];
62
+ db.on('temp', (snap) => events.push(snap.val()));
63
+
64
+ forceExpire(db, 'temp');
65
+ db.sweep();
66
+ expect(events.length).toBeGreaterThan(0);
67
+ expect(events[events.length - 1]).toBeNull();
68
+ db.close();
69
+ });
70
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+
4
+ describe('VectorEngine', () => {
5
+ const dims = 4; // Small for tests
6
+
7
+ test('store and search vectors', () => {
8
+ const db = new BodDB({ sweepInterval: 0, vectors: { dimensions: dims } });
9
+ db.set('docs/d1', { text: 'hello' });
10
+ db.set('docs/d2', { text: 'world' });
11
+ db.set('docs/d3', { text: 'other' });
12
+
13
+ db.vectors!.store('docs/d1', [1, 0, 0, 0]);
14
+ db.vectors!.store('docs/d2', [0.9, 0.1, 0, 0]);
15
+ db.vectors!.store('docs/d3', [0, 0, 1, 0]);
16
+
17
+ const results = db.vectorSearch({ query: [1, 0, 0, 0] });
18
+ expect(results.length).toBe(3);
19
+ expect(results[0].path).toBe('docs/d1'); // Most similar
20
+ expect(results[0].score).toBeCloseTo(1.0, 3);
21
+ expect(results[1].path).toBe('docs/d2'); // Second most similar
22
+ db.close();
23
+ });
24
+
25
+ test('search with prefix filter', () => {
26
+ const db = new BodDB({ sweepInterval: 0, vectors: { dimensions: dims } });
27
+ db.vectors!.store('cats/c1', [1, 0, 0, 0]);
28
+ db.vectors!.store('dogs/d1', [0.9, 0.1, 0, 0]);
29
+
30
+ const results = db.vectorSearch({ query: [1, 0, 0, 0], path: 'cats' });
31
+ expect(results.length).toBe(1);
32
+ expect(results[0].path).toBe('cats/c1');
33
+ db.close();
34
+ });
35
+
36
+ test('search with threshold', () => {
37
+ const db = new BodDB({ sweepInterval: 0, vectors: { dimensions: dims } });
38
+ db.vectors!.store('a', [1, 0, 0, 0]);
39
+ db.vectors!.store('b', [0, 1, 0, 0]); // Orthogonal, similarity ~0
40
+
41
+ const results = db.vectorSearch({ query: [1, 0, 0, 0], threshold: 0.5 });
42
+ expect(results.length).toBe(1);
43
+ expect(results[0].path).toBe('a');
44
+ db.close();
45
+ });
46
+
47
+ test('search with limit', () => {
48
+ const db = new BodDB({ sweepInterval: 0, vectors: { dimensions: dims } });
49
+ for (let i = 0; i < 10; i++) {
50
+ db.vectors!.store(`item/${i}`, [Math.random(), Math.random(), Math.random(), Math.random()]);
51
+ }
52
+
53
+ const results = db.vectorSearch({ query: [1, 0, 0, 0], limit: 3 });
54
+ expect(results.length).toBe(3);
55
+ db.close();
56
+ });
57
+
58
+ test('throws without vectors config', () => {
59
+ const db = new BodDB({ sweepInterval: 0 });
60
+ expect(() => db.vectorSearch({ query: [1, 0, 0, 0] })).toThrow('Vectors not configured');
61
+ db.close();
62
+ });
63
+
64
+ test('dimension mismatch throws', () => {
65
+ const db = new BodDB({ sweepInterval: 0, vectors: { dimensions: dims } });
66
+ expect(() => db.vectors!.store('a', [1, 0])).toThrow('Expected 4 dimensions');
67
+ db.close();
68
+ });
69
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Enable latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "ESNext",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+
22
+ // Some stricter flags (disabled by default)
23
+ "noUnusedLocals": false,
24
+ "noUnusedParameters": false,
25
+ "noPropertyAccessFromIndexSignature": false
26
+ }
27
+ }