@bod.ee/db 0.12.6 → 0.13.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.
Files changed (45) hide show
  1. package/CLAUDE.md +2 -1
  2. package/admin/admin.ts +24 -4
  3. package/admin/bun.lock +248 -0
  4. package/admin/index.html +12 -0
  5. package/admin/package.json +22 -0
  6. package/admin/src/App.tsx +23 -0
  7. package/admin/src/client/ZuzClient.ts +183 -0
  8. package/admin/src/client/types.ts +28 -0
  9. package/admin/src/components/MetricsBar.tsx +167 -0
  10. package/admin/src/components/Sparkline.tsx +72 -0
  11. package/admin/src/components/TreePane.tsx +287 -0
  12. package/admin/src/components/tabs/Advanced.tsx +222 -0
  13. package/admin/src/components/tabs/AuthRules.tsx +104 -0
  14. package/admin/src/components/tabs/Cache.tsx +113 -0
  15. package/admin/src/components/tabs/KeyAuth.tsx +462 -0
  16. package/admin/src/components/tabs/MessageQueue.tsx +237 -0
  17. package/admin/src/components/tabs/Query.tsx +75 -0
  18. package/admin/src/components/tabs/ReadWrite.tsx +177 -0
  19. package/admin/src/components/tabs/Replication.tsx +94 -0
  20. package/admin/src/components/tabs/Streams.tsx +329 -0
  21. package/admin/src/components/tabs/StressTests.tsx +209 -0
  22. package/admin/src/components/tabs/Subscriptions.tsx +69 -0
  23. package/admin/src/components/tabs/TabPane.tsx +151 -0
  24. package/admin/src/components/tabs/VFS.tsx +435 -0
  25. package/admin/src/components/tabs/View.tsx +14 -0
  26. package/admin/src/components/tabs/utils.ts +25 -0
  27. package/admin/src/context/DbContext.tsx +33 -0
  28. package/admin/src/context/StatsContext.tsx +56 -0
  29. package/admin/src/main.tsx +10 -0
  30. package/admin/src/styles.css +96 -0
  31. package/admin/tsconfig.app.json +21 -0
  32. package/admin/tsconfig.json +7 -0
  33. package/admin/tsconfig.node.json +15 -0
  34. package/admin/vite.config.ts +42 -0
  35. package/deploy/base.yaml +1 -1
  36. package/deploy/prod-il.config.ts +5 -2
  37. package/deploy/prod.config.ts +5 -2
  38. package/package.json +5 -1
  39. package/src/server/BodDB.ts +62 -5
  40. package/src/server/ReplicationEngine.ts +149 -34
  41. package/src/server/StorageEngine.ts +12 -4
  42. package/src/server/StreamEngine.ts +2 -2
  43. package/src/server/Transport.ts +60 -0
  44. package/tests/replication.test.ts +162 -1
  45. package/admin/ui.html +0 -3547
@@ -0,0 +1,33 @@
1
+ import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
2
+ import { ZuzClient } from '../client/ZuzClient';
3
+
4
+ declare global {
5
+ interface Window { __BODDB_URL__?: string; }
6
+ const __BODDB_WS_URL__: string | undefined;
7
+ }
8
+
9
+ const url = window.__BODDB_URL__ || new URLSearchParams(location.search).get('url') ||
10
+ (typeof __BODDB_WS_URL__ !== 'undefined' ? __BODDB_WS_URL__ : null) ||
11
+ `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;
12
+
13
+ export const db = new ZuzClient(url);
14
+
15
+ interface DbContextValue {
16
+ connected: boolean;
17
+ }
18
+
19
+ const DbContext = createContext<DbContextValue>({ connected: false });
20
+
21
+ export function DbProvider({ children }: { children: ReactNode }) {
22
+ const [connected, setConnected] = useState(db.connected);
23
+
24
+ useEffect(() => {
25
+ db.onStatusChange = setConnected;
26
+ db.connect();
27
+ return () => { db.onStatusChange = undefined; };
28
+ }, []);
29
+
30
+ return <DbContext.Provider value={{ connected }}>{children}</DbContext.Provider>;
31
+ }
32
+
33
+ export function useDb() { return useContext(DbContext); }
@@ -0,0 +1,56 @@
1
+ import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
2
+ import { db } from './DbContext';
3
+ import { ValueSnapshot } from '../client/types';
4
+
5
+ export interface StatsData {
6
+ process: { cpuPercent: number; heapUsedMb: number; rssMb: number; uptimeSec: number };
7
+ db: { nodeCount: number; sizeMb?: number; path?: string };
8
+ system?: { cpuCores: number; cpuPercent: number; totalMemMb: number };
9
+ clients?: number;
10
+ subs?: number;
11
+ ts: number;
12
+ version?: string;
13
+ repl?: {
14
+ role: string;
15
+ sources: Array<{ connected: boolean; paths: string[]; localPrefix?: string; pending: number; eventsApplied: number }>;
16
+ };
17
+ }
18
+
19
+ interface StatsContextValue {
20
+ stats: StatsData | null;
21
+ statsEnabled: boolean;
22
+ toggleStats: () => void;
23
+ }
24
+
25
+ const StatsContext = createContext<StatsContextValue>({
26
+ stats: null, statsEnabled: true, toggleStats: () => {},
27
+ });
28
+
29
+ export function StatsProvider({ children }: { children: ReactNode }) {
30
+ const [stats, setStats] = useState<StatsData | null>(null);
31
+ const [statsEnabled, setStatsEnabled] = useState(true);
32
+
33
+ useEffect(() => {
34
+ const unsub = db.on('_admin/stats', (snap: ValueSnapshot) => {
35
+ const s = snap.val() as StatsData | null;
36
+ if (s) setStats(s);
37
+ });
38
+ return () => { unsub(); };
39
+ }, []);
40
+
41
+ const toggleStats = async () => {
42
+ try {
43
+ const result = await db._send('admin-stats-toggle', { enabled: !statsEnabled }) as { enabled: boolean };
44
+ setStatsEnabled(result.enabled);
45
+ if (!result.enabled) setStats(null);
46
+ } catch {}
47
+ };
48
+
49
+ return (
50
+ <StatsContext.Provider value={{ stats, statsEnabled, toggleStats }}>
51
+ {children}
52
+ </StatsContext.Provider>
53
+ );
54
+ }
55
+
56
+ export function useStats() { return useContext(StatsContext); }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import './styles.css';
4
+ import App from './App';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,96 @@
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+ body { font-family: monospace; font-size: 13px; background: #0d0d0d; color: #d4d4d4; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
3
+
4
+ /* Metrics bar */
5
+ #metrics-bar { display: flex; background: #0a0a0a; border-bottom: 1px solid #2a2a2a; flex-shrink: 0; align-items: stretch; width: 100%; }
6
+ .metric-card { display: flex; flex-direction: column; padding: 5px 10px; border-right: 1px solid #181818; width: 160px; flex-shrink: 0; gap: 3px; overflow: hidden; }
7
+ .metric-card:last-child { border-right: none; width: auto; flex: 1; }
8
+ .metric-top { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
9
+ .metric-label { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; }
10
+ .metric-value { font-size: 13px; color: #4ec9b0; font-weight: bold; min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
11
+ .metric-value.warn { color: #ce9178; }
12
+ .metric-value.dim { color: #888; }
13
+ .metric-canvas { display: block; margin-top: 2px; width: 100%; height: 28px; }
14
+ #ws-dot { width: 7px; height: 7px; border-radius: 50%; background: #555; display: inline-block; margin-left: 4px; vertical-align: middle; }
15
+ #ws-dot.live { background: #4ec9b0; animation: pulse 1.5s infinite; }
16
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
17
+
18
+ /* Main layout */
19
+ #main { display: flex; flex: 1; overflow: hidden; }
20
+
21
+ /* Tree */
22
+ #tree-pane { width: 28%; border-right: 1px solid #2a2a2a; display: flex; flex-direction: column; min-width: 180px; }
23
+ #tree-header { padding: 6px 10px; background: #161616; border-bottom: 1px solid #2a2a2a; display: flex; justify-content: space-between; align-items: center; }
24
+ #tree-header span { color: #555; font-size: 11px; }
25
+ #tree-container { flex: 1; overflow-y: auto; padding: 4px; }
26
+ #tree-container details { margin-left: 12px; }
27
+ #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; }
28
+ #tree-container summary::before { content: '▶'; font-size: 10px; color: #666; margin-right: 5px; flex-shrink: 0; display: inline-block; transition: transform 0.15s; }
29
+ details[open] > summary::before { transform: rotate(90deg); color: #aaa; }
30
+ #tree-container summary .tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
31
+ #tree-container summary .ttl-badge, #tree-container summary .count-badge { flex-shrink: 0; margin-left: 4px; }
32
+ #tree-container summary:hover { background: #1e1e1e; }
33
+ .tree-leaf { padding: 2px 4px 2px 16px; cursor: pointer; border-radius: 3px; color: #9cdcfe; display: flex; gap: 4px; align-items: baseline; overflow: hidden; }
34
+ .tree-leaf:hover { background: #1e1e1e; }
35
+ .tree-val { color: #ce9178; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
36
+ .tree-key { color: #4ec9b0; flex-shrink: 0; }
37
+ .ttl-badge { font-size: 9px; padding: 0 4px; border-radius: 3px; background: #4d3519; color: #d4a054; flex-shrink: 0; }
38
+ .count-badge { font-size: 9px; padding: 0 4px; border-radius: 3px; background: #1e2d3d; color: #569cd6; flex-shrink: 0; }
39
+ @keyframes treeFlash { 0%,100% { background: transparent; } 30% { background: rgba(86,156,214,0.25); } }
40
+ .flash { animation: treeFlash 1.2s ease-out; border-radius: 3px; }
41
+
42
+ /* Right pane */
43
+ #right-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
44
+ .tabs { display: flex; background: #161616; border-bottom: 1px solid #2a2a2a; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
45
+ .tabs::-webkit-scrollbar { display: none; }
46
+ .tab { padding: 7px 16px; cursor: pointer; border-bottom: 2px solid transparent; color: #666; font-size: 12px; white-space: nowrap; flex-shrink: 0; }
47
+ .tab.active { color: #fff; border-bottom-color: #569cd6; }
48
+ .panel { display: none; flex: 1; overflow-y: auto; padding: 12px; flex-direction: column; gap: 10px; }
49
+ .panel.active { display: flex; }
50
+
51
+ /* Controls */
52
+ label { color: #666; font-size: 10px; margin-bottom: 2px; display: block; text-transform: uppercase; letter-spacing: 0.3px; }
53
+ input[type=text], textarea, select { width: 100%; background: #1a1a1a; border: 1px solid #333; color: #d4d4d4; padding: 5px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; }
54
+ input[type=text]:focus, textarea:focus { outline: none; border-color: #569cd6; }
55
+ textarea { resize: vertical; min-height: 80px; }
56
+ .row { display: flex; gap: 6px; align-items: flex-end; }
57
+ .row input { flex: 1; }
58
+ button { padding: 5px 11px; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 12px; background: #264f78; color: #ccc; white-space: nowrap; }
59
+ button:hover { background: #3a6fa8; color: #fff; }
60
+ button.danger { background: #4a1e1e; color: #f48771; }
61
+ button.danger:hover { background: #7a2e2e; }
62
+ button.success { background: #1e4a1e; color: #4ec9b0; }
63
+ button.success:hover { background: #2e7a2e; }
64
+ button.sub { background: #3a2e5a; color: #c5b8f0; }
65
+ button.sub:hover { background: #5a4a8a; }
66
+ button.sm { padding: 3px 7px; font-size: 11px; }
67
+ button.icon { display:inline-flex;align-items:center;justify-content:center;padding:3px 5px;vertical-align:middle; }
68
+ .result { background: #111; border: 1px solid #222; border-radius: 3px; padding: 8px; overflow: auto; max-height: 280px; white-space: pre; color: #9cdcfe; font-size: 12px; }
69
+ .status { font-size: 11px; padding: 3px 8px; border-radius: 3px; }
70
+ .status.ok { background: #1a3a1a; color: #4ec9b0; }
71
+ .status.err { background: #3a1a1a; color: #f48771; }
72
+
73
+ /* Query filters */
74
+ .filter-row { display: flex; gap: 5px; align-items: center; margin-bottom: 4px; }
75
+ .filter-row input { flex: 2; }
76
+ .filter-row select { flex: 1; }
77
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
78
+ th { background: #1a1a1a; padding: 4px 8px; text-align: left; color: #666; border-bottom: 1px solid #222; }
79
+ td { padding: 4px 8px; border-bottom: 1px solid #1a1a1a; color: #d4d4d4; word-break: break-all; }
80
+ tr:hover td { background: #161616; }
81
+
82
+ /* Subscriptions */
83
+ .sub-item { background: #111; border: 1px solid #222; border-radius: 3px; padding: 8px; }
84
+ .sub-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; color: #4ec9b0; }
85
+ .sub-log { max-height: 130px; overflow-y: auto; font-size: 11px; background: #0a0a0a; padding: 5px; border-radius: 2px; }
86
+ .sub-log div { padding: 2px 0; border-bottom: 1px solid #161616; color: #9cdcfe; }
87
+ .sub-log div span { color: #555; margin-right: 6px; }
88
+
89
+ /* Stress test */
90
+ .stress-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
91
+ .stress-card { background: #111; border: 1px solid #222; border-radius: 3px; padding: 10px; }
92
+ .stress-card h4 { color: #888; font-size: 11px; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.5px; }
93
+ .stress-result { margin-top: 8px; font-size: 11px; background: #0a0a0a; border-radius: 2px; padding: 6px; color: #4ec9b0; min-height: 60px; white-space: pre-wrap; }
94
+ .progress-bar { height: 4px; background: #222; border-radius: 2px; margin-top: 6px; overflow: hidden; }
95
+ .progress-fill { height: 100%; background: #569cd6; border-radius: 2px; width: 0%; transition: width 0.1s; }
96
+ input[type=number] { width: 80px; background: #1a1a1a; border: 1px solid #333; color: #d4d4d4; padding: 4px 6px; border-radius: 3px; font-family: monospace; font-size: 12px; }
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2020",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "skipLibCheck": true,
9
+ "moduleResolution": "bundler",
10
+ "allowImportingTsExtensions": true,
11
+ "isolatedModules": true,
12
+ "moduleDetection": "force",
13
+ "noEmit": true,
14
+ "jsx": "react-jsx",
15
+ "strict": true,
16
+ "noUnusedLocals": false,
17
+ "noUnusedParameters": false,
18
+ "noFallthroughCasesInSwitch": true
19
+ },
20
+ "include": ["src"]
21
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true
13
+ },
14
+ "include": ["vite.config.ts"]
15
+ }
@@ -0,0 +1,42 @@
1
+ import { defineConfig, Plugin } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import { resolve, dirname } from 'path';
4
+ import { spawnSync } from 'child_process';
5
+
6
+ const BODDB_URL = process.env.BODDB_URL ?? 'http://localhost:4444';
7
+
8
+ function openFolderPlugin(): Plugin {
9
+ return {
10
+ name: 'open-folder',
11
+ configureServer(server) {
12
+ server.middlewares.use('/open-folder', (req, res) => {
13
+ const url = new URL(req.url!, `http://localhost`);
14
+ const filePath = url.searchParams.get('path');
15
+ if (filePath) {
16
+ if (process.platform === 'darwin') spawnSync('open', ['-R', resolve(filePath)]);
17
+ else if (process.platform === 'win32') spawnSync('explorer', ['/select,', resolve(filePath)]);
18
+ else spawnSync('xdg-open', [dirname(resolve(filePath))]);
19
+ }
20
+ res.end('ok');
21
+ });
22
+ },
23
+ };
24
+ }
25
+
26
+ export default defineConfig(({ command }) => ({
27
+ plugins: [react(), openFolderPlugin()],
28
+ build: { outDir: 'dist' },
29
+ server: {
30
+ port: 5173,
31
+ proxy: {
32
+ '/db': BODDB_URL,
33
+ '/files': BODDB_URL,
34
+ '/sse': BODDB_URL,
35
+ '/replication': BODDB_URL,
36
+ },
37
+ },
38
+ define: {
39
+ // Only bake WS URL in dev; production uses location-based fallback
40
+ __BODDB_WS_URL__: command === 'serve' ? JSON.stringify(BODDB_URL.replace(/^http/, 'ws')) : 'undefined',
41
+ },
42
+ }));
package/deploy/base.yaml CHANGED
@@ -15,4 +15,4 @@ service:
15
15
  https:
16
16
  email: admin@livshitz.com
17
17
  deploy:
18
- excludes: [.git, node_modules, .tmp, tests, .claude, .cursor, bun.lock, data]
18
+ excludes: [.git, node_modules, admin/node_modules, .tmp, tests, .claude, .cursor, data]
@@ -12,8 +12,11 @@ export default {
12
12
  },
13
13
  transport: {
14
14
  staticRoutes: {
15
- '/admin': './admin/ui.html',
16
- '/': './admin/ui.html',
15
+ '/admin': './admin/dist/index.html',
16
+ '/': './admin/dist/index.html',
17
+ },
18
+ staticDirs: {
19
+ '/assets': './admin/dist/assets',
17
20
  },
18
21
  },
19
22
  }
@@ -9,8 +9,11 @@ export default {
9
9
  replication: { role: 'primary' as const },
10
10
  transport: {
11
11
  staticRoutes: {
12
- '/admin': './admin/ui.html',
13
- '/': './admin/ui.html',
12
+ '/admin': './admin/dist/index.html',
13
+ '/': './admin/dist/index.html',
14
+ },
15
+ staticDirs: {
16
+ '/assets': './admin/dist/assets',
14
17
  },
15
18
  },
16
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bod.ee/db",
3
- "version": "0.12.6",
3
+ "version": "0.13.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,10 +19,14 @@
19
19
  "build": "echo 'no build step'",
20
20
  "gen:models": "echo 'no gen:models step'",
21
21
  "admin": "bun run admin/admin.ts",
22
+ "admin:build": "cd admin && bun run build",
23
+ "admin:dev": "cd admin && bun run dev",
24
+ "start-admin:dev": "bunx concurrently -n server,admin -c cyan,yellow \"bun run cli.ts config.ts\" \"bun run admin:dev\"",
22
25
  "demo": "bun --watch run cli.ts admin/demo.config.ts",
23
26
  "admin:remote": "bun run admin/proxy.ts",
24
27
  "serve": "bun run cli.ts",
25
28
  "start": "bun run cli.ts config.ts",
29
+ "start-admin": "bunx concurrently -n server,admin -c cyan,yellow \"bun run cli.ts config.ts\" \"bun run admin/admin.ts\"",
26
30
  "publish-lib": "bun publish --access public",
27
31
  "mcp": "bun run mcp.ts --stdio",
28
32
  "deploy": "bun run deploy/deploy.ts boddb deploy",
@@ -11,7 +11,7 @@ import { ReplicationEngine, type ReplicationOptions, type WriteEvent } from './R
11
11
  import { VFSEngine, type VFSEngineOptions } from './VFSEngine.ts';
12
12
  import { KeyAuthEngine, type KeyAuthEngineOptions } from './KeyAuthEngine.ts';
13
13
  import { validatePath } from '../shared/pathUtils.ts';
14
- import { Logger, type LogConfig } from '../shared/logger.ts';
14
+ import { Logger, ComponentLogger, type LogConfig } from '../shared/logger.ts';
15
15
  import pkg from '../../package.json' with { type: 'json' };
16
16
  const PKG_VERSION: string = pkg.version ?? 'unknown';
17
17
 
@@ -76,6 +76,7 @@ export class BodDB {
76
76
  private _lastCpuUsage = process.cpuUsage();
77
77
  private _lastCpuTime = performance.now();
78
78
  private _lastStats: Record<string, unknown> | null = null;
79
+ private _sweepLog: ComponentLogger | null = null;
79
80
 
80
81
  constructor(options?: Partial<BodDBOptions>) {
81
82
  this.options = { ...new BodDBOptions(), ...options };
@@ -217,7 +218,10 @@ export class BodDB {
217
218
  }
218
219
 
219
220
  get(path: string): unknown {
220
- if (path === '_admin/stats' && this._lastStats) return this._lastStats;
221
+ if (path === '_admin/stats' && this._statsInterval) {
222
+ // Return last broadcast stats if available (subscriber-gated); never compute on-demand here
223
+ return this._lastStats ?? null;
224
+ }
221
225
  return this.storage.get(path);
222
226
  }
223
227
 
@@ -240,6 +244,7 @@ export class BodDB {
240
244
 
241
245
  /** Manually trigger TTL sweep + stream auto-compact, returns expired paths */
242
246
  sweep(): string[] {
247
+ if (!this._sweepLog) this._sweepLog = this.log.forComponent('storage');
243
248
  const expired = this.storage.sweep();
244
249
  if (expired.length > 0) {
245
250
  if (this.subs.hasSubscriptions) {
@@ -249,7 +254,9 @@ export class BodDB {
249
254
  for (const p of expired) {
250
255
  this._fireWrite({ op: 'delete', path: p });
251
256
  }
252
- this.log.forComponent('storage').debug(`Sweep: ${expired.length} expired paths removed`);
257
+ this._sweepLog.debug(`Sweep: ${expired.length} expired paths removed`);
258
+ } else {
259
+ this._sweepLog.debug('Sweep: no expired paths');
253
260
  }
254
261
  this.stream.autoCompact();
255
262
  this.mq.sweep();
@@ -394,6 +401,38 @@ export class BodDB {
394
401
  return result;
395
402
  }
396
403
 
404
+ /** Compute a one-shot stats snapshot (used for REST GET when no WS subscribers are active). */
405
+ private _collectStats(): Record<string, unknown> {
406
+ const { statSync } = require('fs');
407
+ const { cpus, totalmem, freemem } = require('os');
408
+ const MB = 1 / (1024 * 1024);
409
+ const mem = process.memoryUsage();
410
+ const osCpuList = cpus();
411
+ return {
412
+ version: PKG_VERSION,
413
+ process: {
414
+ cpuPercent: null, // not meaningful for a one-shot snapshot
415
+ heapUsedMb: Math.round(mem.heapUsed * MB * 100) / 100,
416
+ rssMb: Math.round(mem.rss * MB * 100) / 100,
417
+ uptimeSec: Math.floor(process.uptime()),
418
+ },
419
+ db: {
420
+ path: this.options.path !== ':memory:' ? require('path').resolve(this.options.path) : ':memory:',
421
+ nodeCount: (() => { try { return (this._statsStmtCount?.get() as any)?.n ?? null; } catch { return null; } })(),
422
+ sizeMb: (() => { try { return this.options.path !== ':memory:' ? Math.round(statSync(this.options.path).size * MB * 100) / 100 : null; } catch { return null; } })(),
423
+ },
424
+ system: {
425
+ cpuCores: osCpuList.length,
426
+ totalMemMb: Math.round(totalmem() * MB),
427
+ cpuPercent: null, // not meaningful for a one-shot snapshot
428
+ },
429
+ subs: this.subs.subscriberCount(),
430
+ clients: this._transport?.clientCount ?? 0,
431
+ repl: this.replication?.stats() ?? null,
432
+ ts: Date.now(),
433
+ };
434
+ }
435
+
397
436
  /** Start publishing process/db stats to `_admin/stats` (only when subscribers exist). */
398
437
  startStatsPublisher(): void {
399
438
  if (this._statsInterval) return;
@@ -413,7 +452,7 @@ export class BodDB {
413
452
  // Reuse a single stats object to minimize allocations
414
453
  const statsData: Record<string, unknown> = {
415
454
  version: PKG_VERSION,
416
- process: {}, db: {}, system: {},
455
+ process: {}, db: { path: this.options.path !== ':memory:' ? require('path').resolve(this.options.path) : ':memory:' }, system: {},
417
456
  subs: 0, clients: 0, repl: null, ts: 0,
418
457
  };
419
458
  const proc = statsData.process as Record<string, number>;
@@ -426,8 +465,26 @@ export class BodDB {
426
465
  sys.cpuCores = cpus().length;
427
466
  sys.totalMemMb = Math.round(totalmem() * MB);
428
467
 
468
+ const statsLog = this.log.forComponent('stats');
469
+ let wasIdle = true;
429
470
  this._statsInterval = setInterval(() => {
430
471
  if (!this._transport) return;
472
+ // Skip all collection work when no clients are watching _admin/stats
473
+ if (!this._transport.hasStatsSubscribers) {
474
+ if (!wasIdle) {
475
+ wasIdle = true;
476
+ statsLog.debug('No stats subscribers — collection paused');
477
+ }
478
+ return;
479
+ }
480
+ // Resuming from idle: reset CPU baselines to avoid a bogus spike
481
+ if (wasIdle) {
482
+ wasIdle = false;
483
+ this._lastCpuUsage = process.cpuUsage();
484
+ this._lastCpuTime = performance.now();
485
+ lastOsCpus = cpus();
486
+ statsLog.debug('Stats subscriber connected — collection resumed');
487
+ }
431
488
  tick++;
432
489
 
433
490
  const now = performance.now();
@@ -468,7 +525,7 @@ export class BodDB {
468
525
  if (this._transport) this._transport.broadcastStats(statsData, Date.now());
469
526
 
470
527
  }, 1000);
471
- this.log.forComponent('stats').info('Stats publisher started');
528
+ statsLog.info('Stats publisher started (collection gated on active subscribers)');
472
529
  }
473
530
 
474
531
  /** Stop the stats publisher. */