@bod.ee/db 0.7.0 → 0.9.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/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 db = new BodDB({ path: DB_PATH, rules, sweepInterval: 60000, fts: {}, vectors: { dimensions: 384 } });
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
- // ── 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);
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
- 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
- }
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
- setInterval(publishStats, 1000);
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: {