@bod.ee/db 0.9.1 → 0.10.2

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