@bod.ee/db 0.9.0 → 0.10.1
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/.claude/settings.local.json +7 -1
- package/.claude/skills/config-file.md +1 -0
- package/.claude/skills/developing-bod-db.md +11 -5
- package/.claude/skills/using-bod-db.md +125 -5
- package/CLAUDE.md +11 -6
- package/README.md +3 -3
- package/admin/admin.ts +57 -0
- package/admin/demo.config.ts +132 -0
- package/admin/rules.ts +4 -1
- package/admin/ui.html +530 -6
- package/bun.lock +33 -0
- package/cli.ts +4 -43
- package/client.ts +5 -3
- package/config.ts +10 -3
- package/index.ts +5 -0
- package/package.json +8 -2
- package/src/client/BodClient.ts +220 -2
- package/src/client/{CachedClient.ts → BodClientCached.ts} +115 -6
- package/src/server/BodDB.ts +24 -8
- package/src/server/KeyAuthEngine.ts +481 -0
- package/src/server/ReplicationEngine.ts +1 -1
- package/src/server/RulesEngine.ts +4 -2
- package/src/server/Transport.ts +213 -0
- package/src/server/VFSEngine.ts +78 -7
- package/src/shared/keyAuth.browser.ts +80 -0
- package/src/shared/keyAuth.ts +177 -0
- package/src/shared/protocol.ts +28 -1
- package/tests/cached-client.test.ts +123 -7
- package/tests/keyauth.test.ts +1010 -0
- package/admin/server.ts +0 -607
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`);
|