@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,523 @@
1
+ import { BodDB } from '../src/server/BodDB.ts';
2
+ import { join } from 'path';
3
+ import { statSync } from 'fs';
4
+ import { cpus, totalmem } from 'os';
5
+ import { rules } from './rules.ts';
6
+
7
+ const DB_PATH = process.env.DB_PATH ?? join(import.meta.dir, '../.tmp/bod-db-admin.sqlite');
8
+ const PORT = process.env.PORT ? Number(process.env.PORT) : 4400;
9
+
10
+ import { increment, serverTimestamp, arrayUnion, arrayRemove } from '../src/shared/transforms.ts';
11
+
12
+ const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
13
+ const db = new BodDB({ path: DB_PATH, rules, sweepInterval: 60000, fts: {}, vectors: { dimensions: 384 } });
14
+ console.log(`DB: ${DB_PATH}`);
15
+
16
+ // Seed only if empty
17
+ if (!db.get('users/alice')) {
18
+ db.set('users/alice', { name: 'Alice', age: 30, role: 'admin' });
19
+ db.set('users/bob', { name: 'Bob', age: 25, role: 'user' });
20
+ db.set('users/carol', { name: 'Carol', age: 28, role: 'user' });
21
+ db.set('settings/theme', 'dark');
22
+ db.set('settings/lang', 'en');
23
+ // Seed push, FTS, and vector data
24
+ db.push('logs', { level: 'info', msg: 'Server started', ts: Date.now() });
25
+ db.set('counters/likes', 0);
26
+ db.set('counters/views', 0);
27
+ // FTS seed
28
+ db.index('users/alice', 'Alice is an admin user who manages the system');
29
+ db.index('users/bob', 'Bob is a regular user who writes articles');
30
+ db.index('users/carol', 'Carol is a user interested in design and UX');
31
+ db.set('_fts/users_alice', { path: 'users/alice', content: 'Alice is an admin user who manages the system', indexedAt: Date.now() });
32
+ db.set('_fts/users_bob', { path: 'users/bob', content: 'Bob is a regular user who writes articles', indexedAt: Date.now() });
33
+ db.set('_fts/users_carol', { path: 'users/carol', content: 'Carol is a user interested in design and UX', indexedAt: Date.now() });
34
+ // Vector seed (384d — simple deterministic embeddings for demo)
35
+ const makeEmb = (seed: number) => Array.from({length: 384}, (_, i) => Math.sin((i + seed) * 0.1) * 0.5);
36
+ db.vectors!.store('users/alice', makeEmb(0));
37
+ db.vectors!.store('users/bob', makeEmb(10));
38
+ db.vectors!.store('users/carol', makeEmb(20));
39
+ db.set('_vectors/users_alice', { path: 'users/alice', dimensions: 384, storedAt: Date.now() });
40
+ db.set('_vectors/users_bob', { path: 'users/bob', dimensions: 384, storedAt: Date.now() });
41
+ db.set('_vectors/users_carol', { path: 'users/carol', dimensions: 384, storedAt: Date.now() });
42
+ // Stream seed
43
+ db.push('events/orders', { orderId: 'o1', amount: 99, status: 'completed' });
44
+ db.push('events/orders', { orderId: 'o2', amount: 42, status: 'pending' });
45
+ db.push('events/orders', { orderId: 'o3', amount: 150, status: 'completed' });
46
+ // MQ seed
47
+ db.mq.push('queues/jobs', { type: 'email', to: 'alice@example.com', subject: 'Welcome' });
48
+ db.mq.push('queues/jobs', { type: 'sms', to: '+1234567890', body: 'Your code is 1234' });
49
+ db.mq.push('queues/jobs', { type: 'webhook', url: 'https://example.com/hook', payload: { event: 'signup' } });
50
+ }
51
+
52
+ // ── Stats written into BodDB ───────────────────────────────────────────────────
53
+ let lastCpuUsage = process.cpuUsage();
54
+ let lastCpuTime = performance.now();
55
+ let lastOsCpus = cpus();
56
+
57
+ function systemCpuPercent(): number {
58
+ const cur = cpus();
59
+ let idleDelta = 0, totalDelta = 0;
60
+ for (let i = 0; i < cur.length; i++) {
61
+ const prev = lastOsCpus[i]?.times ?? cur[i].times;
62
+ const c = cur[i].times;
63
+ idleDelta += c.idle - prev.idle;
64
+ totalDelta += (c.user + c.nice + c.sys + c.irq + c.idle) - (prev.user + prev.nice + prev.sys + prev.irq + prev.idle);
65
+ }
66
+ lastOsCpus = cur;
67
+ return totalDelta > 0 ? +((1 - idleDelta / totalDelta) * 100).toFixed(1) : 0;
68
+ }
69
+
70
+ function publishStats() {
71
+ if (!db.subs.subscriberCount('_admin')) return;
72
+ const now = performance.now();
73
+ const cpu = process.cpuUsage();
74
+ const elapsedUs = (now - lastCpuTime) * 1000;
75
+ const cpuPercent = +((cpu.user - lastCpuUsage.user + cpu.system - lastCpuUsage.system) / elapsedUs * 100).toFixed(1);
76
+ lastCpuUsage = cpu; lastCpuTime = now;
77
+
78
+ const mem = process.memoryUsage();
79
+ const nodeCount = (db.storage.db.query('SELECT COUNT(*) as n FROM nodes WHERE mq_status IS NULL').get() as any).n;
80
+ let dbSizeMb = 0;
81
+ try { dbSizeMb = +(statSync(DB_PATH).size / 1024 / 1024).toFixed(2); } catch {}
82
+
83
+ db.set('_admin/stats', {
84
+ process: {
85
+ cpuPercent,
86
+ heapUsedMb: +(mem.heapUsed / 1024 / 1024).toFixed(2),
87
+ rssMb: +(mem.rss / 1024 / 1024).toFixed(2),
88
+ uptimeSec: Math.floor(process.uptime()),
89
+ },
90
+ db: { nodeCount, sizeMb: dbSizeMb },
91
+ system: { cpuCores: cpus().length, totalMemMb: +(totalmem() / 1024 / 1024).toFixed(0), cpuPercent: systemCpuPercent() },
92
+ subs: db.subs.subscriberCount(),
93
+ clients: wsClients.size,
94
+ ts: Date.now(),
95
+ });
96
+ }
97
+
98
+ setInterval(publishStats, 1000);
99
+ publishStats();
100
+
101
+ // ── Server (WS + REST /db/* + UI) ─────────────────────────────────────────────
102
+ const UI_PATH = join(import.meta.dir, 'ui.html');
103
+ import type { Server, ServerWebSocket } from 'bun';
104
+ import type { ClientMessage } from '../src/shared/protocol.ts';
105
+ import { Errors } from '../src/shared/errors.ts';
106
+ import { normalizePath, reconstruct } from '../src/shared/pathUtils.ts';
107
+
108
+ interface WsData {
109
+ auth: Record<string, unknown> | null;
110
+ valueSubs: Map<string, () => void>;
111
+ childSubs: Map<string, () => void>;
112
+ streamSubs: Map<string, () => void>;
113
+ }
114
+
115
+ const wsClients = new Set<ServerWebSocket<WsData>>();
116
+ const server = Bun.serve({
117
+ port: PORT,
118
+ fetch(req, server) {
119
+ const url = new URL(req.url);
120
+
121
+ // WebSocket upgrade (must be before UI/REST handlers)
122
+ if (req.headers.get('upgrade') === 'websocket') {
123
+ if (server.upgrade(req, { data: { auth: null, valueSubs: new Map(), childSubs: new Map(), streamSubs: new Map() } as WsData })) return;
124
+ return new Response('WS upgrade failed', { status: 400 });
125
+ }
126
+
127
+ // Serve UI
128
+ if (url.pathname === '/' || url.pathname === '/ui.html') {
129
+ return new Response(Bun.file(UI_PATH));
130
+ }
131
+
132
+ // REST: GET /rules
133
+ if (req.method === 'GET' && url.pathname === '/rules') {
134
+ const summary = Object.entries(rules).map(([pattern, rule]) => ({
135
+ pattern,
136
+ read: rule.read === undefined ? null : rule.read === false ? false : rule.read === true ? true : rule.read.toString(),
137
+ write: rule.write === undefined ? null : rule.write === false ? false : rule.write === true ? true : rule.write.toString(),
138
+ }));
139
+ return Response.json({ ok: true, rules: summary });
140
+ }
141
+
142
+ // REST: GET /db/<path>?shallow=true — shallow returns only immediate child keys
143
+ if (req.method === 'GET' && url.pathname.startsWith('/db/')) {
144
+ const path = normalizePath(url.pathname.slice(4));
145
+ const shallow = url.searchParams.get('shallow') === 'true';
146
+
147
+ if (shallow) {
148
+ return Response.json({ ok: true, children: db.getShallow(path || undefined) });
149
+ }
150
+
151
+ if (!path) {
152
+ const rows = db.storage.db.query("SELECT path, value FROM nodes ORDER BY path").all() as Array<{ path: string; value: string }>;
153
+ const data = rows.length ? reconstruct('', rows) : {};
154
+ return Response.json({ ok: true, data });
155
+ }
156
+ return Response.json({ ok: true, data: db.get(path) });
157
+ }
158
+
159
+ // REST: PUT /db/<path>
160
+ if (req.method === 'PUT' && url.pathname.startsWith('/db/')) {
161
+ const path = normalizePath(url.pathname.slice(4));
162
+ return (async () => {
163
+ db.set(path, await req.json());
164
+ return Response.json({ ok: true });
165
+ })();
166
+ }
167
+
168
+ // REST: DELETE /db/<path>
169
+ if (req.method === 'DELETE' && url.pathname.startsWith('/db/')) {
170
+ const path = normalizePath(url.pathname.slice(4));
171
+ db.delete(path);
172
+ return Response.json({ ok: true });
173
+ }
174
+
175
+ // REST: POST /transform — apply a transform sentinel
176
+ if (req.method === 'POST' && url.pathname === '/transform') {
177
+ return (async () => {
178
+ const { path, type, value } = await req.json() as { path: string; type: string; value?: unknown };
179
+ let sentinel: unknown;
180
+ switch (type) {
181
+ case 'increment': sentinel = increment(value as number); break;
182
+ case 'serverTimestamp': sentinel = serverTimestamp(); break;
183
+ case 'arrayUnion': sentinel = arrayUnion(...(Array.isArray(value) ? value : [value])); break;
184
+ case 'arrayRemove': sentinel = arrayRemove(...(Array.isArray(value) ? value : [value])); break;
185
+ default: return Response.json({ ok: false, error: `Unknown transform: ${type}` }, { status: 400 });
186
+ }
187
+ db.set(path, sentinel);
188
+ return Response.json({ ok: true, data: db.get(path) });
189
+ })();
190
+ }
191
+
192
+ // REST: POST /set-ttl — set a value with TTL
193
+ if (req.method === 'POST' && url.pathname === '/set-ttl') {
194
+ return (async () => {
195
+ const { path, value, ttl } = await req.json() as { path: string; value: unknown; ttl: number };
196
+ db.set(path, value, { ttl });
197
+ return Response.json({ ok: true });
198
+ })();
199
+ }
200
+
201
+ // REST: POST /sweep — manual TTL sweep
202
+ if (req.method === 'POST' && url.pathname === '/sweep') {
203
+ const expired = db.sweep();
204
+ return Response.json({ ok: true, expired });
205
+ }
206
+
207
+ // REST: POST /fts/index — index text for FTS
208
+ if (req.method === 'POST' && url.pathname === '/fts/index') {
209
+ return (async () => {
210
+ const { path, content } = await req.json() as { path: string; content: string };
211
+ db.index(path, content);
212
+ return Response.json({ ok: true });
213
+ })();
214
+ }
215
+
216
+ // REST: GET /fts/search?text=...&path=...&limit=...
217
+ if (req.method === 'GET' && url.pathname === '/fts/search') {
218
+ const text = url.searchParams.get('text') ?? '';
219
+ const pathFilter = url.searchParams.get('path') ?? undefined;
220
+ const limit = url.searchParams.get('limit') ? Number(url.searchParams.get('limit')) : undefined;
221
+ const results = db.search({ text, path: pathFilter, limit });
222
+ return Response.json({ ok: true, data: results });
223
+ }
224
+
225
+ // REST: POST /vectors/store — store an embedding
226
+ if (req.method === 'POST' && url.pathname === '/vectors/store') {
227
+ return (async () => {
228
+ const { path, embedding } = await req.json() as { path: string; embedding: number[] };
229
+ db.vectors!.store(path, embedding);
230
+ return Response.json({ ok: true });
231
+ })();
232
+ }
233
+
234
+ // REST: POST /vectors/search — vector similarity search
235
+ if (req.method === 'POST' && url.pathname === '/vectors/search') {
236
+ return (async () => {
237
+ const { query, path: pathFilter, limit, threshold } = await req.json() as { query: number[]; path?: string; limit?: number; threshold?: number };
238
+ const results = db.vectorSearch({ query, path: pathFilter, limit, threshold });
239
+ return Response.json({ ok: true, data: results });
240
+ })();
241
+ }
242
+
243
+ return new Response('Not found', { status: 404 });
244
+ },
245
+ websocket: {
246
+ open(ws: ServerWebSocket<WsData>) {
247
+ wsClients.add(ws);
248
+ console.log(`[WS] client connected (${wsClients.size} total)`);
249
+ },
250
+ close(ws: ServerWebSocket<WsData>) {
251
+ wsClients.delete(ws);
252
+ const vn = ws.data.valueSubs.size, cn = ws.data.childSubs.size, sn = ws.data.streamSubs.size;
253
+ for (const off of ws.data.valueSubs.values()) off();
254
+ for (const off of ws.data.childSubs.values()) off();
255
+ for (const off of ws.data.streamSubs.values()) off();
256
+ console.log(`[WS] client disconnected (${wsClients.size} total) — cleaned up ${vn + cn + sn} subs`);
257
+ },
258
+ async message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
259
+ let msg: ClientMessage;
260
+ try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); }
261
+ catch { ws.send(JSON.stringify({ id: '?', ok: false, error: 'Invalid JSON', code: Errors.INTERNAL })); return; }
262
+
263
+ const { id } = msg;
264
+ const reply = (data: unknown) => ws.send(JSON.stringify({ id, ok: true, data }));
265
+ const error = (err: string, code: string) => {
266
+ console.warn(`[WS] error op=${msg.op} — ${err}`);
267
+ ws.send(JSON.stringify({ id, ok: false, error: err, code }));
268
+ };
269
+
270
+ const checkRule = (op: 'read' | 'write', path: string, newData?: unknown) => {
271
+ if (!db.rules.check(op, path, ws.data.auth, db.get(path), newData))
272
+ throw new Error(`Permission denied: ${op} ${path}`);
273
+ };
274
+
275
+ try {
276
+ switch (msg.op) {
277
+ case 'auth': {
278
+ if (ADMIN_TOKEN && msg.token === ADMIN_TOKEN) {
279
+ ws.data.auth = { role: 'admin' };
280
+ console.log(`[AUTH] authenticated as admin`);
281
+ return reply({ role: 'admin' });
282
+ }
283
+ if (typeof msg.token === 'string' && msg.token.startsWith('user:')) {
284
+ const uid = msg.token.slice(5);
285
+ ws.data.auth = { role: 'user', uid };
286
+ console.log(`[AUTH] authenticated as user:${uid}`);
287
+ return reply({ role: 'user', uid });
288
+ }
289
+ ws.data.auth = null;
290
+ return error('Invalid token', Errors.PERMISSION_DENIED);
291
+ }
292
+ case 'get': {
293
+ if (msg.shallow) return reply(db.getShallow(msg.path || undefined));
294
+ checkRule('read', msg.path); return reply(db.get(msg.path));
295
+ }
296
+ case 'set': checkRule('write', msg.path, msg.value); if (!msg.path.startsWith('_admin') && !msg.path.startsWith('stress/')) console.log(`[DB] set ${msg.path}`); db.set(msg.path, msg.value); return reply(null);
297
+ case 'update': {
298
+ for (const [p, v] of Object.entries(msg.updates)) checkRule('write', p, v);
299
+ console.log(`[DB] update ${Object.keys(msg.updates).join(', ')}`); db.update(msg.updates); return reply(null);
300
+ }
301
+ case 'delete': checkRule('write', msg.path); console.log(`[DB] delete ${msg.path}`); db.delete(msg.path); return reply(null);
302
+ case 'query': {
303
+ let q = db.query(msg.path);
304
+ if (msg.filters) for (const f of msg.filters) q = q.where(f.field, f.op, f.value);
305
+ if (msg.order) q = q.order(msg.order.field, msg.order.dir);
306
+ if (msg.limit) q = q.limit(msg.limit);
307
+ if (msg.offset) q = q.offset(msg.offset);
308
+ const result = q.get();
309
+ console.log(`[DB] query ${msg.path} → ${Array.isArray(result) ? result.length + ' rows' : typeof result}`);
310
+ return reply(result);
311
+ }
312
+ case 'sub': {
313
+ const key = `${msg.event}:${msg.path}`;
314
+ if (msg.event === 'value') {
315
+ if (ws.data.valueSubs.has(key)) return reply(null);
316
+ const off = db.on(msg.path, (snap) => ws.send(JSON.stringify({ type: 'value', path: snap.path, data: snap.val() })));
317
+ ws.data.valueSubs.set(key, off);
318
+ } else if (msg.event === 'child') {
319
+ if (ws.data.childSubs.has(key)) return reply(null);
320
+ const off = db.onChild(msg.path, (ev) => ws.send(JSON.stringify({ type: 'child', path: msg.path, key: ev.key, data: ev.val(), event: ev.type })));
321
+ ws.data.childSubs.set(key, off);
322
+ }
323
+ if (!msg.path.startsWith('_admin')) console.log(`[SUB] +${msg.event} ${msg.path}`);
324
+ return reply(null);
325
+ }
326
+ case 'unsub': {
327
+ const key = `${msg.event}:${msg.path}`;
328
+ if (msg.event === 'value') { ws.data.valueSubs.get(key)?.(); ws.data.valueSubs.delete(key); }
329
+ else { ws.data.childSubs.get(key)?.(); ws.data.childSubs.delete(key); }
330
+ if (!msg.path.startsWith('_admin')) console.log(`[SUB] -${msg.event} ${msg.path}`);
331
+ return reply(null);
332
+ }
333
+ case 'batch': {
334
+ const results: unknown[] = [];
335
+ db.transaction((tx) => {
336
+ for (const batchOp of msg.operations) {
337
+ switch (batchOp.op) {
338
+ case 'set': checkRule('write', batchOp.path, batchOp.value); tx.set(batchOp.path, batchOp.value); break;
339
+ case 'update':
340
+ for (const p of Object.keys(batchOp.updates)) checkRule('write', p);
341
+ tx.update(batchOp.updates); break;
342
+ case 'delete': checkRule('write', batchOp.path); tx.delete(batchOp.path); break;
343
+ case 'push': {
344
+ checkRule('write', batchOp.path);
345
+ const key = db.push(batchOp.path, batchOp.value);
346
+ results.push(key);
347
+ break;
348
+ }
349
+ }
350
+ }
351
+ });
352
+ console.log(`[DB] batch ${msg.operations.length} ops`);
353
+ return reply(results.length ? results : null);
354
+ }
355
+ case 'push': {
356
+ checkRule('write', msg.path);
357
+ const key = db.push(msg.path, msg.value, (msg as any).idempotencyKey ? { idempotencyKey: (msg as any).idempotencyKey } : undefined);
358
+ console.log(`[DB] push ${msg.path} → ${key}`);
359
+ return reply(key);
360
+ }
361
+ case 'stream-read': {
362
+ const { path: srPath, groupId, limit: srLimit } = msg as any;
363
+ const events = db.stream.read(srPath, groupId, srLimit);
364
+ console.log(`[STREAM] read ${srPath}:${groupId} → ${events.length} events`);
365
+ return reply(events);
366
+ }
367
+ case 'stream-ack': {
368
+ const { path: saPath, groupId: saGroup, key: saKey } = msg as any;
369
+ db.stream.ack(saPath, saGroup, saKey);
370
+ console.log(`[STREAM] ack ${saPath}:${saGroup} → ${saKey}`);
371
+ return reply(null);
372
+ }
373
+ case 'stream-sub': {
374
+ const { path: ssPath, groupId: ssGroup } = msg as any;
375
+ const streamKey = `${ssPath}:${ssGroup}`;
376
+ ws.data.streamSubs.get(streamKey)?.();
377
+ const unsub = db.stream.subscribe(ssPath, ssGroup, (events) => {
378
+ ws.send(JSON.stringify({ type: 'stream', path: ssPath, groupId: ssGroup, events }));
379
+ });
380
+ ws.data.streamSubs.set(streamKey, unsub);
381
+ console.log(`[STREAM] +sub ${ssPath}:${ssGroup}`);
382
+ return reply(null);
383
+ }
384
+ case 'stream-unsub': {
385
+ const { path: suPath, groupId: suGroup } = msg as any;
386
+ const suKey = `${suPath}:${suGroup}`;
387
+ ws.data.streamSubs.get(suKey)?.();
388
+ ws.data.streamSubs.delete(suKey);
389
+ console.log(`[STREAM] -sub ${suPath}:${suGroup}`);
390
+ return reply(null);
391
+ }
392
+ case 'stream-compact': {
393
+ const { path: scPath, maxAge, maxCount, keepKey } = msg as any;
394
+ const opts: Record<string, unknown> = {};
395
+ if (maxAge != null) opts.maxAge = maxAge;
396
+ if (maxCount != null) opts.maxCount = maxCount;
397
+ if (keepKey) opts.keepKey = keepKey;
398
+ const result = db.stream.compact(scPath, opts as any);
399
+ console.log(`[STREAM] compact ${scPath} → deleted ${result.deleted}, snapshot ${result.snapshotSize} keys`);
400
+ return reply(result);
401
+ }
402
+ case 'stream-reset': {
403
+ const { path: srPath } = msg as any;
404
+ db.stream.reset(srPath);
405
+ console.log(`[STREAM] reset ${srPath}`);
406
+ return reply(null);
407
+ }
408
+ case 'stream-snapshot': {
409
+ const { path: snPath } = msg as any;
410
+ const snap = db.stream.snapshot(snPath);
411
+ return reply(snap);
412
+ }
413
+ case 'stream-materialize': {
414
+ const { path: smPath, keepKey: smKey } = msg as any;
415
+ const view = db.stream.materialize(smPath, smKey ? { keepKey: smKey } : undefined);
416
+ return reply(view);
417
+ }
418
+ // Admin-specific ops
419
+ case 'transform': {
420
+ const { path: tPath, type, value: tVal } = msg as any;
421
+ let sentinel: unknown;
422
+ switch (type) {
423
+ case 'increment': sentinel = increment(tVal as number); break;
424
+ case 'serverTimestamp': sentinel = serverTimestamp(); break;
425
+ case 'arrayUnion': sentinel = arrayUnion(...(Array.isArray(tVal) ? tVal : [tVal])); break;
426
+ case 'arrayRemove': sentinel = arrayRemove(...(Array.isArray(tVal) ? tVal : [tVal])); break;
427
+ default: return error(`Unknown transform: ${type}`, Errors.INTERNAL);
428
+ }
429
+ db.set(tPath, sentinel);
430
+ return reply(db.get(tPath));
431
+ }
432
+ case 'set-ttl': {
433
+ const { path: ttlPath, value: ttlVal, ttl } = msg as any;
434
+ db.set(ttlPath, ttlVal, { ttl });
435
+ return reply(null);
436
+ }
437
+ case 'sweep': {
438
+ const expired = db.sweep();
439
+ return reply(expired);
440
+ }
441
+ case 'fts-index': {
442
+ const { path: fPath, content } = msg as any;
443
+ db.index(fPath, content);
444
+ db.set(`_fts/${fPath.replace(/\//g, '_')}`, { path: fPath, content, indexedAt: Date.now() });
445
+ return reply(null);
446
+ }
447
+ case 'fts-search': {
448
+ const { text, path: fPrefix, limit: fLimit } = msg as any;
449
+ return reply(db.search({ text, path: fPrefix, limit: fLimit }));
450
+ }
451
+ case 'vec-store': {
452
+ const { path: vPath, embedding } = msg as any;
453
+ db.vectors!.store(vPath, embedding);
454
+ db.set(`_vectors/${vPath.replace(/\//g, '_')}`, { path: vPath, dimensions: embedding.length, storedAt: Date.now() });
455
+ return reply(null);
456
+ }
457
+ case 'vec-search': {
458
+ const { query: vQuery, path: vPrefix, limit: vLimit, threshold } = msg as any;
459
+ return reply(db.vectorSearch({ query: vQuery, path: vPrefix, limit: vLimit, threshold }));
460
+ }
461
+ // MQ ops
462
+ case 'mq-push': {
463
+ const { path: mqPath, value: mqVal, idempotencyKey: mqIdem } = msg as any;
464
+ const mqKey = db.mq.push(mqPath, mqVal, mqIdem ? { idempotencyKey: mqIdem } : undefined);
465
+ console.log(`[MQ] push ${mqPath} → ${mqKey}`);
466
+ return reply(mqKey);
467
+ }
468
+ case 'mq-fetch': {
469
+ const { path: mqfPath, count: mqfCount } = msg as any;
470
+ const msgs = db.mq.fetch(mqfPath, mqfCount);
471
+ console.log(`[MQ] fetch ${mqfPath} → ${msgs.length} msgs`);
472
+ return reply(msgs);
473
+ }
474
+ case 'mq-ack': {
475
+ const { path: mqaPath, key: mqaKey } = msg as any;
476
+ db.mq.ack(mqaPath, mqaKey);
477
+ console.log(`[MQ] ack ${mqaPath}/${mqaKey}`);
478
+ return reply(null);
479
+ }
480
+ case 'mq-nack': {
481
+ const { path: mqnPath, key: mqnKey } = msg as any;
482
+ db.mq.nack(mqnPath, mqnKey);
483
+ console.log(`[MQ] nack ${mqnPath}/${mqnKey}`);
484
+ return reply(null);
485
+ }
486
+ case 'mq-peek': {
487
+ const { path: mqpPath, count: mqpCount } = msg as any;
488
+ const peeked = db.mq.peek(mqpPath, mqpCount);
489
+ console.log(`[MQ] peek ${mqpPath} → ${peeked.length} msgs`);
490
+ return reply(peeked);
491
+ }
492
+ case 'mq-dlq': {
493
+ const { path: mqdPath } = msg as any;
494
+ const dlqMsgs = db.mq.dlq(mqdPath);
495
+ console.log(`[MQ] dlq ${mqdPath} → ${dlqMsgs.length} msgs`);
496
+ return reply(dlqMsgs);
497
+ }
498
+ case 'mq-purge': {
499
+ const { path: mqpurgePath, all: mqpurgeAll } = msg as any;
500
+ const purged = db.mq.purge(mqpurgePath, { all: mqpurgeAll });
501
+ console.log(`[MQ] purge ${mqpurgePath} → ${purged} deleted`);
502
+ return reply(purged);
503
+ }
504
+ case 'get-rules': {
505
+ const summary = Object.entries(rules).map(([pattern, rule]) => ({
506
+ pattern,
507
+ read: rule.read === undefined ? null : rule.read === false ? false : rule.read === true ? true : rule.read.toString(),
508
+ write: rule.write === undefined ? null : rule.write === false ? false : rule.write === true ? true : rule.write.toString(),
509
+ }));
510
+ return reply(summary);
511
+ }
512
+ default: return error('Unknown op', Errors.INTERNAL);
513
+ }
514
+ } catch (e: unknown) {
515
+ return error(e instanceof Error ? e.message : 'Internal error', Errors.INTERNAL);
516
+ }
517
+ },
518
+ },
519
+ });
520
+
521
+ console.log(`BodDB Admin UI → http://localhost:${server.port}`);
522
+ if (ADMIN_TOKEN) console.log(`Auth: ADMIN_TOKEN set — use "user:<uid>" for user tokens`);
523
+ else console.log(`Auth: ADMIN_TOKEN not set — admin auth disabled, user:<uid> tokens still work`);