@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 +1 -1
- package/admin/ui.html +28 -13
- package/package.json +2 -1
- package/src/server/ReplicationEngine.ts +2 -0
- package/src/server/StorageEngine.ts +12 -4
- package/src/server/Transport.ts +43 -0
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;
|
|
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
|
|
131
|
-
<div class="metric-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1549
|
-
|
|
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.
|
|
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
|
-
|
|
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;
|
package/src/server/Transport.ts
CHANGED
|
@@ -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));
|