@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,78 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { RulesEngine } from '../src/server/RulesEngine.ts';
3
+
4
+ describe('RulesEngine', () => {
5
+ test('no rules = open access', () => {
6
+ const r = new RulesEngine();
7
+ expect(r.check('read', 'users/u1', null)).toBe(true);
8
+ expect(r.check('write', 'users/u1', null)).toBe(true);
9
+ });
10
+
11
+ test('static true/false rules', () => {
12
+ const r = new RulesEngine({
13
+ rules: {
14
+ 'public': { read: () => true, write: () => false },
15
+ },
16
+ });
17
+ expect(r.check('read', 'public/data', null)).toBe(true);
18
+ expect(r.check('write', 'public/data', null)).toBe(false);
19
+ });
20
+
21
+ test('wildcard capture', () => {
22
+ const r = new RulesEngine({
23
+ rules: {
24
+ 'users/$uid': {
25
+ write: (ctx) => ctx.auth?.uid === ctx.params.uid,
26
+ },
27
+ },
28
+ });
29
+ expect(r.check('write', 'users/u1', { uid: 'u1' })).toBe(true);
30
+ expect(r.check('write', 'users/u1', { uid: 'u2' })).toBe(false);
31
+ expect(r.check('write', 'users/u1', null)).toBe(false);
32
+ });
33
+
34
+ test('most specific rule wins', () => {
35
+ const r = new RulesEngine({
36
+ rules: {
37
+ 'data': { read: () => false },
38
+ 'data/public': { read: () => true },
39
+ },
40
+ });
41
+ expect(r.check('read', 'data/public/stuff', null)).toBe(true);
42
+ expect(r.check('read', 'data/private', null)).toBe(false);
43
+ });
44
+
45
+ test('rule with auth check', () => {
46
+ const r = new RulesEngine({
47
+ rules: {
48
+ 'admin': {
49
+ read: (ctx) => ctx.auth?.role === 'admin',
50
+ write: (ctx) => ctx.auth?.role === 'admin',
51
+ },
52
+ },
53
+ });
54
+ expect(r.check('read', 'admin/config', { role: 'admin' })).toBe(true);
55
+ expect(r.check('read', 'admin/config', { role: 'user' })).toBe(false);
56
+ expect(r.check('read', 'admin/config', null)).toBe(false);
57
+ });
58
+
59
+ test('rule exception returns deny', () => {
60
+ const r = new RulesEngine({
61
+ rules: {
62
+ 'buggy': {
63
+ read: () => { throw new Error('oops'); },
64
+ },
65
+ },
66
+ });
67
+ expect(r.check('read', 'buggy/path', null)).toBe(false);
68
+ });
69
+
70
+ test('unmatched op defaults to open', () => {
71
+ const r = new RulesEngine({
72
+ rules: {
73
+ 'users': { read: () => true }, // no write rule
74
+ },
75
+ });
76
+ expect(r.check('write', 'users/u1', null)).toBe(true);
77
+ });
78
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import { BodDB } from '../src/server/BodDB.ts';
3
+
4
+ let db: BodDB;
5
+
6
+ afterEach(() => {
7
+ db?.close();
8
+ });
9
+
10
+ describe('SSE Transport', () => {
11
+ test('GET /sse/path streams value events', async () => {
12
+ db = new BodDB({ port: 0 });
13
+ const server = db.serve();
14
+ const port = server.port;
15
+
16
+ const controller = new AbortController();
17
+ const res = await fetch(`http://localhost:${port}/sse/users/u1`, { signal: controller.signal });
18
+ expect(res.headers.get('content-type')).toBe('text/event-stream');
19
+
20
+ const reader = res.body!.getReader();
21
+ const decoder = new TextDecoder();
22
+
23
+ // Read initial SSE comment
24
+ await reader.read();
25
+
26
+ // Write after SSE connection is established
27
+ setTimeout(() => db.set('users/u1', { name: 'Alice' }), 50);
28
+
29
+ const { value } = await reader.read();
30
+ const text = decoder.decode(value);
31
+ expect(text).toContain('data: ');
32
+
33
+ const jsonStr = text.split('data: ')[1].split('\n')[0];
34
+ const event = JSON.parse(jsonStr);
35
+ expect(event.type).toBe('value');
36
+ expect(event.data).toEqual({ name: 'Alice' });
37
+
38
+ controller.abort();
39
+ });
40
+
41
+ test('GET /sse/path?event=child streams child events', async () => {
42
+ db = new BodDB({ port: 0 });
43
+ const server = db.serve();
44
+ const port = server.port;
45
+
46
+ const controller = new AbortController();
47
+ const res = await fetch(`http://localhost:${port}/sse/users?event=child`, { signal: controller.signal });
48
+
49
+ const reader = res.body!.getReader();
50
+ const decoder = new TextDecoder();
51
+
52
+ // Read initial SSE comment
53
+ await reader.read();
54
+
55
+ setTimeout(() => db.set('users/u1', { name: 'Alice' }), 50);
56
+
57
+ const { value } = await reader.read();
58
+ const text = decoder.decode(value);
59
+ const jsonStr = text.split('data: ')[1].split('\n')[0];
60
+ const event = JSON.parse(jsonStr);
61
+ expect(event.type).toBe('child');
62
+ expect(event.key).toBe('u1');
63
+
64
+ controller.abort();
65
+ });
66
+
67
+ test('SSE respects rules', async () => {
68
+ db = new BodDB({
69
+ port: 0,
70
+ rules: { 'secret': { read: false } },
71
+ });
72
+ const server = db.serve();
73
+ const port = server.port;
74
+
75
+ const res = await fetch(`http://localhost:${port}/sse/secret/data`);
76
+ expect(res.status).toBe(403);
77
+ });
78
+ });
@@ -0,0 +1,199 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { StorageEngine } from '../src/server/StorageEngine.ts';
3
+
4
+ function createEngine() {
5
+ return new StorageEngine({ path: ':memory:' });
6
+ }
7
+
8
+ describe('StorageEngine', () => {
9
+ test('set and get a primitive', () => {
10
+ const s = createEngine();
11
+ s.set('foo', 'bar');
12
+ expect(s.get('foo')).toBe('bar');
13
+ s.close();
14
+ });
15
+
16
+ test('set and get a nested object', () => {
17
+ const s = createEngine();
18
+ s.set('users/u1', { name: 'Alice', settings: { theme: 'dark' } });
19
+ expect(s.get('users/u1')).toEqual({ name: 'Alice', settings: { theme: 'dark' } });
20
+ expect(s.get('users/u1/name')).toBe('Alice');
21
+ expect(s.get('users/u1/settings/theme')).toBe('dark');
22
+ s.close();
23
+ });
24
+
25
+ test('deeply nested objects (4 levels)', () => {
26
+ const s = createEngine();
27
+ s.set('a/b/c/d', { e: 'deep' });
28
+ expect(s.get('a/b/c/d/e')).toBe('deep');
29
+ expect(s.get('a')).toEqual({ b: { c: { d: { e: 'deep' } } } });
30
+ s.close();
31
+ });
32
+
33
+ test('overwrite replaces subtree', () => {
34
+ const s = createEngine();
35
+ s.set('users/u1', { name: 'Alice', age: 30 });
36
+ s.set('users/u1', { name: 'Bob' });
37
+ expect(s.get('users/u1')).toEqual({ name: 'Bob' });
38
+ s.close();
39
+ });
40
+
41
+ test('delete removes exact and subtree', () => {
42
+ const s = createEngine();
43
+ s.set('users/u1', { name: 'Alice', settings: { theme: 'dark' } });
44
+ s.delete('users/u1');
45
+ expect(s.get('users/u1')).toBeNull();
46
+ expect(s.get('users/u1/name')).toBeNull();
47
+ s.close();
48
+ });
49
+
50
+ test('multi-path update is atomic', () => {
51
+ const s = createEngine();
52
+ s.update({ 'users/u1/name': 'Alice', 'users/u2/name': 'Bob', 'counter': 42 });
53
+ expect(s.get('users/u1/name')).toBe('Alice');
54
+ expect(s.get('users/u2/name')).toBe('Bob');
55
+ expect(s.get('counter')).toBe(42);
56
+ s.close();
57
+ });
58
+
59
+ test('update merges — does not destroy untouched keys', () => {
60
+ const s = createEngine();
61
+ s.set('users/u1', { name: 'Alice', age: 30, settings: { theme: 'dark' } });
62
+ s.update({ 'users/u1': { name: 'Bob' } });
63
+ expect(s.get('users/u1')).toEqual({ name: 'Bob', age: 30, settings: { theme: 'dark' } });
64
+ s.close();
65
+ });
66
+
67
+ test('update merge replaces nested subtree for touched key', () => {
68
+ const s = createEngine();
69
+ s.set('users/u1', { name: 'Alice', settings: { theme: 'dark', lang: 'en' } });
70
+ s.update({ 'users/u1': { settings: { theme: 'light' } } });
71
+ // settings is replaced entirely (shallow merge), but name is untouched
72
+ expect(s.get('users/u1')).toEqual({ name: 'Alice', settings: { theme: 'light' } });
73
+ s.close();
74
+ });
75
+
76
+ test('update merge with null deletes specific key', () => {
77
+ const s = createEngine();
78
+ s.set('users/u1', { name: 'Alice', age: 30 });
79
+ s.update({ 'users/u1': { age: null } });
80
+ expect(s.get('users/u1')).toEqual({ name: 'Alice' });
81
+ s.close();
82
+ });
83
+
84
+ test('update with primitive value replaces (no merge)', () => {
85
+ const s = createEngine();
86
+ s.set('counter', 10);
87
+ s.update({ 'counter': 20 });
88
+ expect(s.get('counter')).toBe(20);
89
+ s.close();
90
+ });
91
+
92
+ test('merge standalone method', () => {
93
+ const s = createEngine();
94
+ s.set('config', { a: 1, b: 2, c: 3 });
95
+ s.merge('config', { b: 20, d: 4 });
96
+ expect(s.get('config')).toEqual({ a: 1, b: 20, c: 3, d: 4 });
97
+ s.close();
98
+ });
99
+
100
+ test('update with null deletes path', () => {
101
+ const s = createEngine();
102
+ s.set('users/u1/name', 'Alice');
103
+ s.update({ 'users/u1/name': null });
104
+ expect(s.get('users/u1/name')).toBeNull();
105
+ s.close();
106
+ });
107
+
108
+ test('get nonexistent returns null', () => {
109
+ const s = createEngine();
110
+ expect(s.get('nope')).toBeNull();
111
+ s.close();
112
+ });
113
+
114
+ test('get parent reconstructs children', () => {
115
+ const s = createEngine();
116
+ s.set('users/u1/name', 'Alice');
117
+ s.set('users/u2/name', 'Bob');
118
+ expect(s.get('users')).toEqual({ u1: { name: 'Alice' }, u2: { name: 'Bob' } });
119
+ s.close();
120
+ });
121
+
122
+ test('set array value', () => {
123
+ const s = createEngine();
124
+ s.set('tags', ['a', 'b', 'c']);
125
+ expect(s.get('tags')).toEqual(['a', 'b', 'c']);
126
+ s.close();
127
+ });
128
+
129
+ test('set number and boolean', () => {
130
+ const s = createEngine();
131
+ s.set('count', 42);
132
+ s.set('active', true);
133
+ expect(s.get('count')).toBe(42);
134
+ expect(s.get('active')).toBe(true);
135
+ s.close();
136
+ });
137
+
138
+ test('exists returns correct values', () => {
139
+ const s = createEngine();
140
+ expect(s.exists('nope')).toBe(false);
141
+ s.set('users/u1', { name: 'Alice' });
142
+ expect(s.exists('users/u1')).toBe(true);
143
+ expect(s.exists('users')).toBe(true);
144
+ expect(s.exists('users/u2')).toBe(false);
145
+ s.close();
146
+ });
147
+
148
+ test('rejects empty path', () => {
149
+ const s = createEngine();
150
+ expect(() => s.get('')).toThrow('Path cannot be empty');
151
+ s.close();
152
+ });
153
+
154
+ test('createIndex with valid field', () => {
155
+ const s = createEngine();
156
+ s.set('users/u1', { name: 'Alice', role: 'admin' });
157
+ expect(() => s.createIndex('users', 'role')).not.toThrow();
158
+ s.close();
159
+ });
160
+
161
+ test('createIndex rejects invalid field', () => {
162
+ const s = createEngine();
163
+ expect(() => s.createIndex('users', 'role; DROP TABLE nodes')).toThrow('Invalid index field');
164
+ s.close();
165
+ });
166
+ });
167
+
168
+ describe('StorageEngine.query', () => {
169
+ test('query with filter', () => {
170
+ const s = createEngine();
171
+ s.set('users/u1', { name: 'Alice', role: 'admin' });
172
+ s.set('users/u2', { name: 'Bob', role: 'user' });
173
+ s.set('users/u3', { name: 'Charlie', role: 'admin' });
174
+
175
+ const admins = s.query('users', [{ field: 'role', op: '==', value: 'admin' }]);
176
+ expect(admins.length).toBe(2);
177
+ expect(admins.map(a => a._key).sort()).toEqual(['u1', 'u3']);
178
+ s.close();
179
+ });
180
+
181
+ test('query with order and limit', () => {
182
+ const s = createEngine();
183
+ s.set('users/u1', { name: 'Charlie' });
184
+ s.set('users/u2', { name: 'Alice' });
185
+ s.set('users/u3', { name: 'Bob' });
186
+
187
+ const result = s.query('users', undefined, { field: 'name', dir: 'asc' }, 2);
188
+ expect(result.length).toBe(2);
189
+ expect(result[0].name).toBe('Alice');
190
+ expect(result[1].name).toBe('Bob');
191
+ s.close();
192
+ });
193
+
194
+ test('query empty path returns empty', () => {
195
+ const s = createEngine();
196
+ expect(s.query('nope')).toEqual([]);
197
+ s.close();
198
+ });
199
+ });