@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,479 @@
1
+ import type { Server, ServerWebSocket } from 'bun';
2
+ import type { BodDB } from './BodDB.ts';
3
+ import type { RulesEngine } from './RulesEngine.ts';
4
+ import type { ClientMessage } from '../shared/protocol.ts';
5
+ import { Errors } from '../shared/errors.ts';
6
+ import { normalizePath, pathKey } from '../shared/pathUtils.ts';
7
+
8
+ interface WsData {
9
+ auth: Record<string, unknown> | null;
10
+ valueSubs: Map<string, () => void>;
11
+ childSubs: Map<string, () => void>;
12
+ streamSubs: Map<string, () => void>;
13
+ }
14
+
15
+ export class TransportOptions {
16
+ port: number = 4400;
17
+ auth?: (token: string) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
18
+ /** Map URL paths to local file paths, e.g. { '/admin': './admin/ui.html' } */
19
+ staticRoutes?: Record<string, string>;
20
+ }
21
+
22
+ export class Transport {
23
+ readonly options: TransportOptions;
24
+ private server: Server | null = null;
25
+ private _clients = new Set<ServerWebSocket<WsData>>();
26
+ get clientCount(): number { return this._clients.size; }
27
+
28
+ constructor(
29
+ private db: BodDB,
30
+ private rules: RulesEngine | null,
31
+ options?: Partial<TransportOptions>,
32
+ ) {
33
+ this.options = { ...new TransportOptions(), ...options };
34
+ }
35
+
36
+ start(): Server {
37
+ const self = this;
38
+ const extractAuth = async (req: Request): Promise<Record<string, unknown> | null> => {
39
+ const header = req.headers.get('authorization');
40
+ if (!header?.startsWith('Bearer ')) return null;
41
+ const token = header.slice(7);
42
+ if (self.options.auth) return await self.options.auth(token);
43
+ return { token };
44
+ };
45
+ this.server = Bun.serve({
46
+ port: this.options.port,
47
+ fetch(req, server) {
48
+ const url = new URL(req.url);
49
+
50
+ // WebSocket upgrade (only if client actually requests it)
51
+ if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
52
+ if (server.upgrade(req, { data: { auth: null, valueSubs: new Map(), childSubs: new Map(), streamSubs: new Map() } as WsData })) {
53
+ return;
54
+ }
55
+ }
56
+
57
+ // REST: GET /path → get
58
+ if (req.method === 'GET' && url.pathname.startsWith('/db/')) {
59
+ return (async () => {
60
+ const path = normalizePath(url.pathname.slice(4));
61
+ const auth = await extractAuth(req);
62
+ if (self.rules && !self.rules.check('read', path, auth)) {
63
+ return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
64
+ }
65
+ const data = self.db.get(path);
66
+ return Response.json({ ok: true, data });
67
+ })();
68
+ }
69
+
70
+ // REST: PUT /path → set
71
+ if (req.method === 'PUT' && url.pathname.startsWith('/db/')) {
72
+ return (async () => {
73
+ const path = normalizePath(url.pathname.slice(4));
74
+ const auth = await extractAuth(req);
75
+ if (self.rules && !self.rules.check('write', path, auth)) {
76
+ return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
77
+ }
78
+ const body = await req.json();
79
+ self.db.set(path, body);
80
+ return Response.json({ ok: true });
81
+ })();
82
+ }
83
+
84
+ // REST: DELETE /path → delete
85
+ if (req.method === 'DELETE' && url.pathname.startsWith('/db/')) {
86
+ return (async () => {
87
+ const path = normalizePath(url.pathname.slice(4));
88
+ const auth = await extractAuth(req);
89
+ if (self.rules && !self.rules.check('write', path, auth)) {
90
+ return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
91
+ }
92
+ self.db.delete(path);
93
+ return Response.json({ ok: true });
94
+ })();
95
+ }
96
+
97
+ // SSE: GET /sse/path?event=value|child
98
+ if (req.method === 'GET' && url.pathname.startsWith('/sse/')) {
99
+ return (async () => {
100
+ const path = normalizePath(url.pathname.slice(5));
101
+ const event = url.searchParams.get('event') || 'value';
102
+ const auth = await extractAuth(req);
103
+
104
+ if (self.rules && !self.rules.check('read', path, auth)) {
105
+ return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
106
+ }
107
+
108
+ let cleanup: (() => void) | null = null;
109
+ const stream = new ReadableStream({
110
+ start(controller) {
111
+ const encoder = new TextEncoder();
112
+ // Initial SSE comment to establish the stream
113
+ controller.enqueue(encoder.encode(': ok\n\n'));
114
+ const send = (data: string) => {
115
+ try { controller.enqueue(encoder.encode(`data: ${data}\n\n`)); } catch {}
116
+ };
117
+
118
+ if (event === 'child') {
119
+ cleanup = self.db.onChild(path, (e) => {
120
+ send(JSON.stringify({ type: 'child', path, key: e.key, data: e.val(), event: e.type }));
121
+ });
122
+ } else {
123
+ cleanup = self.db.on(path, (snap) => {
124
+ send(JSON.stringify({ type: 'value', path: snap.path, data: snap.val() }));
125
+ });
126
+ }
127
+ },
128
+ cancel() {
129
+ cleanup?.();
130
+ },
131
+ });
132
+
133
+ return new Response(stream, {
134
+ headers: {
135
+ 'Content-Type': 'text/event-stream',
136
+ 'Cache-Control': 'no-cache',
137
+ 'Connection': 'keep-alive',
138
+ },
139
+ });
140
+ })();
141
+ }
142
+
143
+ // Static routes
144
+ if (self.options.staticRoutes) {
145
+ const filePath = self.options.staticRoutes[url.pathname];
146
+ if (filePath) return new Response(Bun.file(filePath));
147
+ }
148
+
149
+ return new Response('Not Found', { status: 404 });
150
+ },
151
+ websocket: {
152
+ open(ws: ServerWebSocket<WsData>) { self._clients.add(ws); },
153
+ close(ws: ServerWebSocket<WsData>) {
154
+ self._clients.delete(ws);
155
+ // Cleanup all subscriptions
156
+ for (const off of ws.data.valueSubs.values()) off();
157
+ for (const off of ws.data.childSubs.values()) off();
158
+ for (const off of ws.data.streamSubs.values()) off();
159
+ ws.data.valueSubs.clear();
160
+ ws.data.childSubs.clear();
161
+ ws.data.streamSubs.clear();
162
+ },
163
+ async message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
164
+ let msg: ClientMessage;
165
+ try {
166
+ msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
167
+ } catch {
168
+ ws.send(JSON.stringify({ id: '?', ok: false, error: 'Invalid JSON', code: Errors.INTERNAL }));
169
+ return;
170
+ }
171
+
172
+ const { id } = msg;
173
+ const reply = (data: unknown) => ws.send(JSON.stringify({ id, ok: true, data }));
174
+ const error = (err: string, code: string) => ws.send(JSON.stringify({ id, ok: false, error: err, code }));
175
+
176
+ try {
177
+ switch (msg.op) {
178
+ case 'auth': {
179
+ if (!self.options.auth) {
180
+ ws.data.auth = { token: msg.token };
181
+ } else {
182
+ const result = await self.options.auth(msg.token);
183
+ if (!result) return error('Authentication failed', Errors.AUTH_REQUIRED);
184
+ ws.data.auth = result;
185
+ }
186
+ return reply(null);
187
+ }
188
+
189
+ case 'get': {
190
+ if (self.rules && !self.rules.check('read', msg.path || '', ws.data.auth)) {
191
+ return error('Permission denied', Errors.PERMISSION_DENIED);
192
+ }
193
+ if (msg.shallow) return reply(self.db.getShallow(msg.path || undefined));
194
+ return reply(self.db.get(msg.path));
195
+ }
196
+
197
+ case 'set': {
198
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth, self.db.get(msg.path), msg.value)) {
199
+ return error('Permission denied', Errors.PERMISSION_DENIED);
200
+ }
201
+ self.db.set(msg.path, msg.value, msg.ttl ? { ttl: msg.ttl } : undefined);
202
+ return reply(null);
203
+ }
204
+
205
+ case 'update': {
206
+ for (const path of Object.keys(msg.updates)) {
207
+ if (self.rules && !self.rules.check('write', path, ws.data.auth)) {
208
+ return error(`Permission denied for ${path}`, Errors.PERMISSION_DENIED);
209
+ }
210
+ }
211
+ self.db.update(msg.updates);
212
+ return reply(null);
213
+ }
214
+
215
+ case 'delete': {
216
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
217
+ return error('Permission denied', Errors.PERMISSION_DENIED);
218
+ }
219
+ self.db.delete(msg.path);
220
+ return reply(null);
221
+ }
222
+
223
+ case 'query': {
224
+ if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
225
+ return error('Permission denied', Errors.PERMISSION_DENIED);
226
+ }
227
+ let q = self.db.query(msg.path);
228
+ if (msg.filters) for (const f of msg.filters) q = q.where(f.field, f.op, f.value);
229
+ if (msg.order) q = q.order(msg.order.field, msg.order.dir);
230
+ if (msg.limit) q = q.limit(msg.limit);
231
+ if (msg.offset) q = q.offset(msg.offset);
232
+ return reply(q.get());
233
+ }
234
+
235
+ case 'sub': {
236
+ if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
237
+ return error('Permission denied', Errors.PERMISSION_DENIED);
238
+ }
239
+ const subKey = `${msg.event}:${msg.path}`;
240
+ if (msg.event === 'value') {
241
+ if (ws.data.valueSubs.has(subKey)) return reply(null); // already subscribed
242
+ const off = self.db.on(msg.path, (snap) => {
243
+ ws.send(JSON.stringify({ type: 'value', path: snap.path, data: snap.val() }));
244
+ });
245
+ ws.data.valueSubs.set(subKey, off);
246
+ } else if (msg.event === 'child') {
247
+ if (ws.data.childSubs.has(subKey)) return reply(null);
248
+ const subPath = msg.path;
249
+ const off = self.db.onChild(subPath, (event) => {
250
+ ws.send(JSON.stringify({ type: 'child', path: subPath, key: event.key, data: event.val(), event: event.type }));
251
+ });
252
+ ws.data.childSubs.set(subKey, off);
253
+ }
254
+ return reply(null);
255
+ }
256
+
257
+ case 'unsub': {
258
+ const unsubKey = `${msg.event}:${msg.path}`;
259
+ if (msg.event === 'value') {
260
+ ws.data.valueSubs.get(unsubKey)?.();
261
+ ws.data.valueSubs.delete(unsubKey);
262
+ } else if (msg.event === 'child') {
263
+ ws.data.childSubs.get(unsubKey)?.();
264
+ ws.data.childSubs.delete(unsubKey);
265
+ }
266
+ return reply(null);
267
+ }
268
+
269
+ case 'batch': {
270
+ const results: unknown[] = [];
271
+ self.db.transaction((tx) => {
272
+ for (const batchOp of msg.operations) {
273
+ switch (batchOp.op) {
274
+ case 'set':
275
+ if (self.rules && !self.rules.check('write', batchOp.path, ws.data.auth)) {
276
+ throw new Error(`Permission denied for ${batchOp.path}`);
277
+ }
278
+ tx.set(batchOp.path, batchOp.value);
279
+ break;
280
+ case 'update':
281
+ for (const path of Object.keys(batchOp.updates)) {
282
+ if (self.rules && !self.rules.check('write', path, ws.data.auth)) {
283
+ throw new Error(`Permission denied for ${path}`);
284
+ }
285
+ }
286
+ tx.update(batchOp.updates);
287
+ break;
288
+ case 'delete':
289
+ if (self.rules && !self.rules.check('write', batchOp.path, ws.data.auth)) {
290
+ throw new Error(`Permission denied for ${batchOp.path}`);
291
+ }
292
+ tx.delete(batchOp.path);
293
+ break;
294
+ case 'push': {
295
+ if (self.rules && !self.rules.check('write', batchOp.path, ws.data.auth)) {
296
+ throw new Error(`Permission denied for ${batchOp.path}`);
297
+ }
298
+ const key = self.db.push(batchOp.path, batchOp.value);
299
+ results.push(key);
300
+ break;
301
+ }
302
+ }
303
+ }
304
+ });
305
+ return reply(results.length ? results : null);
306
+ }
307
+
308
+ case 'push': {
309
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
310
+ return error('Permission denied', Errors.PERMISSION_DENIED);
311
+ }
312
+ const key = self.db.push(msg.path, msg.value, msg.idempotencyKey ? { idempotencyKey: msg.idempotencyKey } : undefined);
313
+ return reply(key);
314
+ }
315
+
316
+ case 'stream-read': {
317
+ if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
318
+ return error('Permission denied', Errors.PERMISSION_DENIED);
319
+ }
320
+ const events = self.db.stream.read(msg.path, msg.groupId, msg.limit);
321
+ return reply(events);
322
+ }
323
+
324
+ case 'stream-ack': {
325
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
326
+ return error('Permission denied', Errors.PERMISSION_DENIED);
327
+ }
328
+ self.db.stream.ack(msg.path, msg.groupId, msg.key);
329
+ return reply(null);
330
+ }
331
+
332
+ case 'stream-sub': {
333
+ if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
334
+ return error('Permission denied', Errors.PERMISSION_DENIED);
335
+ }
336
+ const streamKey = `${msg.path}:${msg.groupId}`;
337
+ // Unsub existing if any
338
+ ws.data.streamSubs.get(streamKey)?.();
339
+ const unsub = self.db.stream.subscribe(msg.path, msg.groupId, (events) => {
340
+ ws.send(JSON.stringify({ type: 'stream', path: msg.path, groupId: msg.groupId, events }));
341
+ });
342
+ ws.data.streamSubs.set(streamKey, unsub);
343
+ return reply(null);
344
+ }
345
+
346
+ case 'stream-unsub': {
347
+ const streamUnsubKey = `${msg.path}:${msg.groupId}`;
348
+ ws.data.streamSubs.get(streamUnsubKey)?.();
349
+ ws.data.streamSubs.delete(streamUnsubKey);
350
+ return reply(null);
351
+ }
352
+
353
+ // MQ ops
354
+ case 'mq-push': {
355
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
356
+ return error('Permission denied', Errors.PERMISSION_DENIED);
357
+ }
358
+ const mqKey = self.db.mq.push(msg.path, msg.value, msg.idempotencyKey ? { idempotencyKey: msg.idempotencyKey } : undefined);
359
+ return reply(mqKey);
360
+ }
361
+ case 'mq-fetch': {
362
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
363
+ return error('Permission denied', Errors.PERMISSION_DENIED);
364
+ }
365
+ return reply(self.db.mq.fetch(msg.path, msg.count));
366
+ }
367
+ case 'mq-ack': {
368
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
369
+ return error('Permission denied', Errors.PERMISSION_DENIED);
370
+ }
371
+ self.db.mq.ack(msg.path, msg.key);
372
+ return reply(null);
373
+ }
374
+ case 'mq-nack': {
375
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
376
+ return error('Permission denied', Errors.PERMISSION_DENIED);
377
+ }
378
+ self.db.mq.nack(msg.path, msg.key);
379
+ return reply(null);
380
+ }
381
+ case 'mq-peek': {
382
+ if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
383
+ return error('Permission denied', Errors.PERMISSION_DENIED);
384
+ }
385
+ return reply(self.db.mq.peek(msg.path, msg.count));
386
+ }
387
+ case 'mq-dlq': {
388
+ if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
389
+ return error('Permission denied', Errors.PERMISSION_DENIED);
390
+ }
391
+ return reply(self.db.mq.dlq(msg.path));
392
+ }
393
+ case 'mq-purge': {
394
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
395
+ return error('Permission denied', Errors.PERMISSION_DENIED);
396
+ }
397
+ return reply(self.db.mq.purge(msg.path, { all: msg.all }));
398
+ }
399
+
400
+ // FTS ops
401
+ case 'fts-search': {
402
+ if (self.rules && !self.rules.check('read', msg.path || '', ws.data.auth)) {
403
+ return error('Permission denied', Errors.PERMISSION_DENIED);
404
+ }
405
+ return reply(self.db.search({ text: msg.text, path: msg.path, limit: msg.limit }));
406
+ }
407
+ case 'fts-index': {
408
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
409
+ return error('Permission denied', Errors.PERMISSION_DENIED);
410
+ }
411
+ if (msg.fields) self.db.index(msg.path, msg.fields);
412
+ else if (msg.content) self.db.index(msg.path, msg.content);
413
+ return reply(null);
414
+ }
415
+
416
+ // Vector ops
417
+ case 'vector-search': {
418
+ if (self.rules && !self.rules.check('read', msg.path || '', ws.data.auth)) {
419
+ return error('Permission denied', Errors.PERMISSION_DENIED);
420
+ }
421
+ return reply(self.db.vectorSearch({ query: msg.query, path: msg.path, limit: msg.limit, threshold: msg.threshold }));
422
+ }
423
+ case 'vector-store': {
424
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
425
+ return error('Permission denied', Errors.PERMISSION_DENIED);
426
+ }
427
+ self.db.vectors!.store(msg.path, msg.embedding);
428
+ return reply(null);
429
+ }
430
+
431
+ // Stream extended ops
432
+ case 'stream-snapshot': {
433
+ if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
434
+ return error('Permission denied', Errors.PERMISSION_DENIED);
435
+ }
436
+ return reply(self.db.stream.snapshot(msg.path));
437
+ }
438
+ case 'stream-materialize': {
439
+ if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
440
+ return error('Permission denied', Errors.PERMISSION_DENIED);
441
+ }
442
+ return reply(self.db.stream.materialize(msg.path, msg.keepKey ? { keepKey: msg.keepKey } : undefined));
443
+ }
444
+ case 'stream-compact': {
445
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
446
+ return error('Permission denied', Errors.PERMISSION_DENIED);
447
+ }
448
+ return reply(self.db.stream.compact(msg.path, { maxAge: msg.maxAge, maxCount: msg.maxCount, keepKey: msg.keepKey }));
449
+ }
450
+ case 'stream-reset': {
451
+ if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
452
+ return error('Permission denied', Errors.PERMISSION_DENIED);
453
+ }
454
+ self.db.stream.reset(msg.path);
455
+ return reply(null);
456
+ }
457
+
458
+ default:
459
+ return error('Unknown operation', Errors.INTERNAL);
460
+ }
461
+ } catch (e: unknown) {
462
+ const message = e instanceof Error ? e.message : 'Internal error';
463
+ return error(message, Errors.INTERNAL);
464
+ }
465
+ },
466
+ },
467
+ });
468
+ return this.server;
469
+ }
470
+
471
+ stop() {
472
+ this.server?.stop(true);
473
+ this.server = null;
474
+ }
475
+
476
+ get port(): number | null {
477
+ return this.server?.port ?? null;
478
+ }
479
+ }
@@ -0,0 +1,115 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ export class VectorEngineOptions {
4
+ dimensions: number = 384;
5
+ metric: 'cosine' | 'euclidean' = 'cosine';
6
+ }
7
+
8
+ export class VectorEngine {
9
+ readonly options: VectorEngineOptions;
10
+ private db: Database;
11
+ private cache = new Map<string, Float32Array>();
12
+
13
+ constructor(db: Database, options?: Partial<VectorEngineOptions>) {
14
+ this.options = { ...new VectorEngineOptions(), ...options };
15
+ this.db = db;
16
+ this.init();
17
+ }
18
+
19
+ private init() {
20
+ this.db.run(`
21
+ CREATE TABLE IF NOT EXISTS _vectors (
22
+ path TEXT PRIMARY KEY,
23
+ embedding BLOB NOT NULL
24
+ )
25
+ `);
26
+ }
27
+
28
+ /** Store an embedding for a path */
29
+ store(path: string, embedding: number[]): void {
30
+ if (embedding.length !== this.options.dimensions) {
31
+ throw new Error(`Expected ${this.options.dimensions} dimensions, got ${embedding.length}`);
32
+ }
33
+ const buf = new Float32Array(embedding);
34
+ this.db.run('INSERT OR REPLACE INTO _vectors (path, embedding) VALUES (?, ?)', [path, Buffer.from(buf.buffer)]);
35
+ this.cache.set(path, buf);
36
+ }
37
+
38
+ /** Remove embedding for a path */
39
+ remove(path: string): void {
40
+ this.db.run('DELETE FROM _vectors WHERE path = ?', [path]);
41
+ this.cache.delete(path);
42
+ }
43
+
44
+ /** Get embedding for a path */
45
+ get(path: string): number[] | null {
46
+ const cached = this.cache.get(path);
47
+ if (cached) return Array.from(cached);
48
+
49
+ const row = this.db.prepare('SELECT embedding FROM _vectors WHERE path = ?').get(path) as { embedding: Buffer } | null;
50
+ if (!row) return null;
51
+ const arr = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
52
+ this.cache.set(path, arr);
53
+ return Array.from(arr);
54
+ }
55
+
56
+ /** Search for similar vectors (brute-force) */
57
+ search(options: {
58
+ query: number[];
59
+ prefix?: string;
60
+ limit?: number;
61
+ threshold?: number;
62
+ }): Array<{ path: string; score: number }> {
63
+ const { query, prefix, limit = 10, threshold } = options;
64
+ const queryArr = new Float32Array(query);
65
+
66
+ // Load all vectors (filtered by prefix if provided)
67
+ let sql = 'SELECT path, embedding FROM _vectors';
68
+ const params: unknown[] = [];
69
+ if (prefix) {
70
+ sql += ' WHERE path >= ? AND path < ?';
71
+ params.push(prefix + '/', prefix + '/\uffff');
72
+ }
73
+
74
+ const rows = this.db.prepare(sql).all(...params) as Array<{ path: string; embedding: Buffer }>;
75
+ const results: Array<{ path: string; score: number }> = [];
76
+
77
+ for (const row of rows) {
78
+ const emb = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
79
+ const score = this.options.metric === 'cosine'
80
+ ? cosineSimilarity(queryArr, emb)
81
+ : -euclideanDistance(queryArr, emb); // Negate so higher = better
82
+
83
+ if (threshold !== undefined && score < threshold) continue;
84
+ results.push({ path: row.path, score });
85
+ }
86
+
87
+ results.sort((a, b) => b.score - a.score);
88
+ return results.slice(0, limit);
89
+ }
90
+
91
+ /** Clear the in-memory cache */
92
+ clearCache(): void {
93
+ this.cache.clear();
94
+ }
95
+ }
96
+
97
+ function cosineSimilarity(a: Float32Array, b: Float32Array): number {
98
+ let dot = 0, normA = 0, normB = 0;
99
+ for (let i = 0; i < a.length; i++) {
100
+ dot += a[i] * b[i];
101
+ normA += a[i] * a[i];
102
+ normB += b[i] * b[i];
103
+ }
104
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
105
+ return denom === 0 ? 0 : dot / denom;
106
+ }
107
+
108
+ function euclideanDistance(a: Float32Array, b: Float32Array): number {
109
+ let sum = 0;
110
+ for (let i = 0; i < a.length; i++) {
111
+ const d = a[i] - b[i];
112
+ sum += d * d;
113
+ }
114
+ return Math.sqrt(sum);
115
+ }
@@ -0,0 +1,15 @@
1
+ export class BodError extends Error {
2
+ constructor(public code: string, message: string) {
3
+ super(message);
4
+ this.name = 'BodError';
5
+ }
6
+ }
7
+
8
+ export const Errors = {
9
+ NOT_FOUND: 'NOT_FOUND',
10
+ PERMISSION_DENIED: 'PERMISSION_DENIED',
11
+ INVALID_PATH: 'INVALID_PATH',
12
+ INVALID_VALUE: 'INVALID_VALUE',
13
+ AUTH_REQUIRED: 'AUTH_REQUIRED',
14
+ INTERNAL: 'INTERNAL',
15
+ } as const;