@bod.ee/db 0.12.6 → 0.12.8

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/admin.ts CHANGED
@@ -30,7 +30,7 @@ export function startAdminUI(options?: { port?: number; serverUrl?: string }) {
30
30
  const url = new URL(req.url);
31
31
 
32
32
  // Proxy API calls to the BodDB server
33
- if (url.pathname.startsWith('/db/') || url.pathname.startsWith('/files/') || url.pathname.startsWith('/sse/')) {
33
+ if (url.pathname.startsWith('/db/') || url.pathname.startsWith('/files/') || url.pathname.startsWith('/sse/') || url.pathname.startsWith('/replication')) {
34
34
  const target = httpBase + url.pathname + url.search;
35
35
  return fetch(target, { method: req.method, headers: req.headers, body: req.body });
36
36
  }
package/admin/ui.html CHANGED
@@ -11,7 +11,6 @@
11
11
  #metrics-bar { display: flex; background: #0a0a0a; border-bottom: 1px solid #2a2a2a; flex-shrink: 0; align-items: stretch; width: 100%; }
12
12
  .metric-card { display: flex; flex-direction: column; padding: 5px 10px 4px; border-right: 1px solid #181818; min-width: 140px; flex-shrink: 0; gap: 1px; overflow: hidden; }
13
13
  .metric-card:last-child { border-right: none; width: auto; }
14
- .metric-right { margin-left: auto; }
15
14
  .metric-top { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
16
15
  .metric-label { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; }
17
16
  .metric-value { font-size: 13px; color: #4ec9b0; font-weight: bold; min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
@@ -31,15 +30,18 @@
31
30
  #tree-header span { color: #555; font-size: 11px; }
32
31
  #tree-container { flex: 1; overflow-y: auto; padding: 4px; }
33
32
  #tree-container details { margin-left: 12px; }
34
- #tree-container summary { cursor: pointer; padding: 2px 4px; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #4ec9b0; list-style: none; }
35
- #tree-container summary::before { content: '▶'; font-size: 10px; color: #666; margin-right: 5px; display: inline-block; transition: transform 0.15s; }
33
+ #tree-container summary { cursor: pointer; padding: 2px 4px; border-radius: 3px; color: #4ec9b0; list-style: none; display: flex; align-items: center; white-space: nowrap; overflow: hidden; }
34
+ #tree-container summary::before { content: '▶'; font-size: 10px; color: #666; margin-right: 5px; flex-shrink: 0; display: inline-block; transition: transform 0.15s; }
36
35
  details[open] > summary::before { transform: rotate(90deg); color: #aaa; }
36
+ #tree-container summary .tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
37
+ #tree-container summary .ttl-badge, #tree-container summary .count-badge { flex-shrink: 0; margin-left: 4px; }
37
38
  #tree-container summary:hover { background: #1e1e1e; }
38
39
  .tree-leaf { padding: 2px 4px 2px 16px; cursor: pointer; border-radius: 3px; color: #9cdcfe; display: flex; gap: 4px; align-items: baseline; overflow: hidden; }
39
40
  .tree-leaf:hover { background: #1e1e1e; }
40
41
  .tree-val { color: #ce9178; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
41
42
  .tree-key { color: #4ec9b0; flex-shrink: 0; }
42
43
  .ttl-badge { font-size: 9px; padding: 0 4px; border-radius: 3px; background: #4d3519; color: #d4a054; flex-shrink: 0; }
44
+ .count-badge { font-size: 9px; padding: 0 4px; border-radius: 3px; background: #1e2d3d; color: #569cd6; flex-shrink: 0; }
43
45
  @keyframes treeFlash { 0%,100% { background: transparent; } 30% { background: rgba(86,156,214,0.25); } }
44
46
  .flash { animation: treeFlash 1.2s ease-out; border-radius: 3px; }
45
47
 
@@ -127,16 +129,18 @@
127
129
  <div class="metric-top"><span class="metric-label">Ping</span><span class="metric-value" id="s-ping">—</span></div>
128
130
  <canvas class="metric-canvas" id="g-ping" width="100" height="28"></canvas>
129
131
  </div>
130
- <div class="metric-card" id="repl-card" style="border-left:1px solid #282828;display:none;width:180px">
131
- <div class="metric-top"><span class="metric-label">Replication</span><span class="metric-value dim" id="s-repl-role">—</span></div>
132
- <div style="margin-top:4px;font-size:10px" id="s-repl-sources"></div>
133
- </div>
134
- <div class="metric-card metric-right" style="border-left:1px solid #282828;justify-content:space-between">
132
+ <div style="margin-left:auto;display:flex;flex-shrink:0">
133
+ <div class="metric-card" id="repl-card" style="border-left:1px solid #282828;display:none;width:180px">
134
+ <div class="metric-top"><span class="metric-label">Replication</span><span class="metric-value dim" id="s-repl-role">—</span></div>
135
+ <div style="margin-top:4px;font-size:10px" id="s-repl-sources"></div>
136
+ </div>
137
+ <div class="metric-card" style="border-left:1px solid #282828;justify-content:space-between">
135
138
  <div class="metric-top"><span class="metric-label">Uptime</span><span class="metric-value dim" id="s-uptime">—</span></div>
136
139
  <div style="font-size:10px;color:#555;display:flex;justify-content:space-between"><span id="s-ts">—</span><span>v<span id="s-version">—</span></span></div>
137
140
  <div><span class="metric-label">WS<span id="ws-dot"></span></span> <span style="font-size:10px;color:#555"><span id="s-clients">0</span> clients · <span id="s-subs">0</span> subs</span></div>
138
141
  <div><button id="stats-toggle" class="sm" onclick="toggleStats()" title="Toggle server stats collection">Stats: ON</button></div>
139
142
  </div>
143
+ </div>
140
144
  </div>
141
145
 
142
146
  <div id="main">
@@ -1455,7 +1459,8 @@ function renderChildren(children, parentPath) {
1455
1459
  html += `<div class="tree-leaf" data-path="${escHtml(path)}" onclick="selectPath('${path.replace(/'/g, "\\'")}')"><span class="tree-key">${escHtml(ch.key)}</span>${ttlBadge}<span class="tree-val">${escHtml(String(display ?? ''))}</span></div>`;
1456
1460
  } else {
1457
1461
  const isOpen = _restoredOpenPaths.has(path);
1458
- html += `<details data-path="${escHtml(path)}"${isOpen ? ' open' : ''}><summary><span onclick="selectPath('${path.replace(/'/g, "\\'")}')">${escHtml(ch.key)}${ttlBadge}</span></summary><div class="tree-children" data-parent="${escHtml(path)}"></div></details>`;
1462
+ const countBadge = ch.count != null ? `<span class="count-badge">${ch.count}</span>` : '';
1463
+ html += `<details data-path="${escHtml(path)}"${isOpen ? ' open' : ''}><summary><span class="tree-label" onclick="selectPath('${path.replace(/'/g, "\\'")}')">${escHtml(ch.key)}</span>${ttlBadge}${countBadge}</summary><div class="tree-children" data-parent="${escHtml(path)}"></div></details>`;
1459
1464
  }
1460
1465
  }
1461
1466
  return html;
@@ -1534,8 +1539,6 @@ async function expandNode(details, isRefresh) {
1534
1539
  }
1535
1540
 
1536
1541
  async function refreshPath(path) {
1537
- // Re-fetch the nearest loaded ancestor and update its children in-place
1538
- // For simplicity, find the <details> or root and re-expand
1539
1542
  const parts = path.split('/');
1540
1543
  let target = '';
1541
1544
  // Walk up to find the deepest loaded ancestor
@@ -1545,8 +1548,17 @@ async function refreshPath(path) {
1545
1548
  }
1546
1549
 
1547
1550
  if (!target) {
1548
- // Refresh root
1549
- return loadTree([path]);
1551
+ // No loaded ancestor — try to update just the root-level item without full reload
1552
+ const topKey = parts[0];
1553
+ const container = document.getElementById('tree-container');
1554
+ const existing = container.querySelector(`[data-path="${CSS.escape(topKey)}"]`);
1555
+ if (!existing) {
1556
+ // Truly new top-level key — must reload root
1557
+ return loadTree([path]);
1558
+ }
1559
+ // Root item exists but children weren't loaded — just flash it
1560
+ flashPaths([path]);
1561
+ return;
1550
1562
  }
1551
1563
 
1552
1564
  // Re-fetch this node and all its children
@@ -1557,6 +1569,9 @@ async function refreshPath(path) {
1557
1569
  if (det && det.open) {
1558
1570
  await expandNode(det, true);
1559
1571
  flashPaths([path]);
1572
+ } else {
1573
+ // Node exists but is collapsed — just flash it
1574
+ flashPaths([path]);
1560
1575
  }
1561
1576
  }
1562
1577
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bod.ee/db",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,6 +23,7 @@
23
23
  "admin:remote": "bun run admin/proxy.ts",
24
24
  "serve": "bun run cli.ts",
25
25
  "start": "bun run cli.ts config.ts",
26
+ "start-admin": "bunx concurrently -n server,admin -c cyan,yellow \"bun run cli.ts config.ts\" \"bun run admin/admin.ts\"",
26
27
  "publish-lib": "bun publish --access public",
27
28
  "mcp": "bun run mcp.ts --stdio",
28
29
  "deploy": "bun run deploy/deploy.ts boddb deploy",
@@ -324,6 +324,8 @@ export class ReplicationEngine {
324
324
 
325
325
  /** Buffer replication events during transactions, emit immediately otherwise */
326
326
  private emit(ev: WriteEvent): void {
327
+ // _repl writes must never emit to _repl (infinite loop), regardless of config
328
+ if (ev.path.startsWith('_repl')) return;
327
329
  if (this._emitting) {
328
330
  this.log.debug('emit: skipped (re-entrant)', { path: ev.path });
329
331
  return;
@@ -137,7 +137,7 @@ export class StorageEngine {
137
137
  }
138
138
 
139
139
  /** Get immediate children of a path (one level deep). Returns { key, isLeaf, value? }[] */
140
- getShallow(path?: string): Array<{ key: string; isLeaf: boolean; value?: unknown; ttl?: number }> {
140
+ getShallow(path?: string): Array<{ key: string; isLeaf: boolean; value?: unknown; ttl?: number; count?: number }> {
141
141
  const prefix = path ? path + '/' : '';
142
142
  const end = prefix + '\uffff';
143
143
  const rows = (prefix
@@ -145,7 +145,7 @@ export class StorageEngine {
145
145
  : this.db.prepare('SELECT path, value, expires_at FROM nodes ORDER BY path').all()
146
146
  ) as Array<{ path: string; value: string; expires_at: number | null }>;
147
147
 
148
- const children: Array<{ key: string; isLeaf: boolean; value?: unknown; ttl?: number }> = [];
148
+ const children: Array<{ key: string; isLeaf: boolean; value?: unknown; ttl?: number; count?: number }> = [];
149
149
  const seen = new Set<string>();
150
150
  for (const row of rows) {
151
151
  const rest = row.path.slice(prefix.length);
@@ -158,10 +158,18 @@ export class StorageEngine {
158
158
  if (row.expires_at) entry.ttl = Math.max(0, row.expires_at - Math.floor(Date.now() / 1000));
159
159
  children.push(entry);
160
160
  } else {
161
- // Check if any child in this branch has TTL
161
+ // Check if any child in this branch has TTL; count direct children
162
162
  const branchPrefix = prefix + key + '/';
163
163
  const hasTTL = rows.some(r => r.path.startsWith(branchPrefix) && r.expires_at);
164
- children.push(hasTTL ? { key, isLeaf: false, ttl: -1 } : { key, isLeaf: false });
164
+ const directChildren = new Set<string>();
165
+ for (const r of rows) {
166
+ if (!r.path.startsWith(branchPrefix)) continue;
167
+ const seg = r.path.slice(branchPrefix.length).split('/')[0];
168
+ if (seg) directChildren.add(seg);
169
+ }
170
+ const entry: { key: string; isLeaf: boolean; ttl?: number; count?: number } = { key, isLeaf: false, count: directChildren.size };
171
+ if (hasTTL) entry.ttl = -1;
172
+ children.push(entry);
165
173
  }
166
174
  }
167
175
  return children;
@@ -253,6 +253,49 @@ export class Transport {
253
253
  })();
254
254
  }
255
255
 
256
+ // Replication REST routes
257
+ if (url.pathname.startsWith('/replication')) {
258
+ return (async () => {
259
+ const repl = this.db.replication;
260
+ if (!repl) {
261
+ if (req.method === 'GET' && url.pathname === '/replication') {
262
+ return Response.json({ ok: true, role: 'primary', started: false, seq: 0, topology: null, sources: [], synced: {} });
263
+ }
264
+ return Response.json({ ok: false, error: 'Replication not configured' }, { status: 503 });
265
+ }
266
+ if (req.method === 'GET' && url.pathname === '/replication') {
267
+ const s = repl.stats();
268
+ // Build synced snapshot: read local copies of each source's paths
269
+ const synced: Record<string, unknown> = {};
270
+ for (const src of (s.sources ?? [])) {
271
+ for (const p of (src.paths ?? [])) {
272
+ try { synced[p] = this.db.get(p); } catch {}
273
+ }
274
+ }
275
+ return Response.json({ ok: true, ...s, synced });
276
+ }
277
+ if (req.method === 'POST' && url.pathname === '/replication/source-write') {
278
+ try {
279
+ const body = await req.json() as { path: string; value: unknown };
280
+ await repl.proxyWrite({ op: 'set', path: body.path, value: body.value });
281
+ return Response.json({ ok: true });
282
+ } catch (e: any) {
283
+ return Response.json({ ok: false, error: e.message }, { status: 500 });
284
+ }
285
+ }
286
+ if (req.method === 'DELETE' && url.pathname.startsWith('/replication/source-delete/')) {
287
+ const path = url.pathname.slice('/replication/source-delete/'.length);
288
+ try {
289
+ await repl.proxyWrite({ op: 'delete', path });
290
+ return Response.json({ ok: true });
291
+ } catch (e: any) {
292
+ return Response.json({ ok: false, error: e.message }, { status: 500 });
293
+ }
294
+ }
295
+ return Response.json({ ok: false, error: 'Not found' }, { status: 404 });
296
+ })();
297
+ }
298
+
256
299
  // VFS REST routes
257
300
  if (this.db.vfs && url.pathname.startsWith('/files/')) {
258
301
  const vfsPath = normalizePath(url.pathname.slice(7));