@bod.ee/db 0.7.0 → 0.8.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.
- package/.claude/settings.local.json +7 -1
- package/.claude/skills/config-file.md +7 -0
- package/.claude/skills/deploying-bod-db.md +34 -0
- package/.claude/skills/developing-bod-db.md +20 -2
- package/.claude/skills/using-bod-db.md +165 -0
- package/.github/workflows/test-and-publish.yml +111 -0
- package/CLAUDE.md +10 -1
- package/README.md +57 -2
- package/admin/proxy.ts +79 -0
- package/admin/rules.ts +1 -1
- package/admin/server.ts +134 -50
- package/admin/ui.html +729 -18
- package/cli.ts +10 -0
- package/client.ts +3 -2
- package/config.ts +1 -0
- package/deploy/boddb-il.yaml +14 -0
- package/deploy/prod-il.config.ts +19 -0
- package/deploy/prod.config.ts +1 -0
- package/index.ts +3 -0
- package/package.json +7 -2
- package/src/client/BodClient.ts +129 -6
- package/src/client/CachedClient.ts +228 -0
- package/src/server/BodDB.ts +145 -1
- package/src/server/ReplicationEngine.ts +332 -0
- package/src/server/StorageEngine.ts +19 -0
- package/src/server/Transport.ts +577 -360
- package/src/server/VFSEngine.ts +172 -0
- package/src/shared/protocol.ts +25 -4
- package/tests/cached-client.test.ts +143 -0
- package/tests/replication.test.ts +404 -0
- package/tests/vfs.test.ts +166 -0
package/admin/server.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { BodDB } from '../src/server/BodDB.ts';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import { statSync } from 'fs';
|
|
4
|
-
import { cpus, totalmem } from 'os';
|
|
5
3
|
import { rules } from './rules.ts';
|
|
6
4
|
|
|
7
5
|
const DB_PATH = process.env.DB_PATH ?? join(import.meta.dir, '../.tmp/bod-db-admin.sqlite');
|
|
@@ -10,7 +8,43 @@ const PORT = process.env.PORT ? Number(process.env.PORT) : 4400;
|
|
|
10
8
|
import { increment, serverTimestamp, arrayUnion, arrayRemove } from '../src/shared/transforms.ts';
|
|
11
9
|
|
|
12
10
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
|
|
13
|
-
const
|
|
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
|
+
});
|
|
14
48
|
console.log(`DB: ${DB_PATH}`);
|
|
15
49
|
|
|
16
50
|
// Seed only if empty
|
|
@@ -47,56 +81,21 @@ if (!db.get('users/alice')) {
|
|
|
47
81
|
db.mq.push('queues/jobs', { type: 'email', to: 'alice@example.com', subject: 'Welcome' });
|
|
48
82
|
db.mq.push('queues/jobs', { type: 'sms', to: '+1234567890', body: 'Your code is 1234' });
|
|
49
83
|
db.mq.push('queues/jobs', { type: 'webhook', url: 'https://example.com/hook', payload: { event: 'signup' } });
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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);
|
|
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');
|
|
65
90
|
}
|
|
66
|
-
lastOsCpus = cur;
|
|
67
|
-
return totalDelta > 0 ? +((1 - idleDelta / totalDelta) * 100).toFixed(1) : 0;
|
|
68
91
|
}
|
|
69
92
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
}
|
|
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
97
|
|
|
98
|
-
|
|
99
|
-
publishStats();
|
|
98
|
+
// Stats are now published automatically by BodDB when _admin has subscribers
|
|
100
99
|
|
|
101
100
|
// ── Server (WS + REST /db/* + UI) ─────────────────────────────────────────────
|
|
102
101
|
const UI_PATH = join(import.meta.dir, 'ui.html');
|
|
@@ -115,7 +114,7 @@ interface WsData {
|
|
|
115
114
|
const wsClients = new Set<ServerWebSocket<WsData>>();
|
|
116
115
|
const server = Bun.serve({
|
|
117
116
|
port: PORT,
|
|
118
|
-
fetch(req, server) {
|
|
117
|
+
async fetch(req, server) {
|
|
119
118
|
const url = new URL(req.url);
|
|
120
119
|
|
|
121
120
|
// WebSocket upgrade (must be before UI/REST handlers)
|
|
@@ -240,6 +239,91 @@ const server = Bun.serve({
|
|
|
240
239
|
})();
|
|
241
240
|
}
|
|
242
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
|
+
|
|
243
327
|
return new Response('Not found', { status: 404 });
|
|
244
328
|
},
|
|
245
329
|
websocket: {
|