@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.
- package/CLAUDE.md +2 -1
- package/admin/admin.ts +24 -4
- package/admin/bun.lock +248 -0
- package/admin/index.html +12 -0
- package/admin/package.json +22 -0
- package/admin/src/App.tsx +23 -0
- package/admin/src/client/ZuzClient.ts +183 -0
- package/admin/src/client/types.ts +28 -0
- package/admin/src/components/MetricsBar.tsx +167 -0
- package/admin/src/components/Sparkline.tsx +72 -0
- package/admin/src/components/TreePane.tsx +287 -0
- package/admin/src/components/tabs/Advanced.tsx +222 -0
- package/admin/src/components/tabs/AuthRules.tsx +104 -0
- package/admin/src/components/tabs/Cache.tsx +113 -0
- package/admin/src/components/tabs/KeyAuth.tsx +462 -0
- package/admin/src/components/tabs/MessageQueue.tsx +237 -0
- package/admin/src/components/tabs/Query.tsx +75 -0
- package/admin/src/components/tabs/ReadWrite.tsx +177 -0
- package/admin/src/components/tabs/Replication.tsx +94 -0
- package/admin/src/components/tabs/Streams.tsx +329 -0
- package/admin/src/components/tabs/StressTests.tsx +209 -0
- package/admin/src/components/tabs/Subscriptions.tsx +69 -0
- package/admin/src/components/tabs/TabPane.tsx +151 -0
- package/admin/src/components/tabs/VFS.tsx +435 -0
- package/admin/src/components/tabs/View.tsx +14 -0
- package/admin/src/components/tabs/utils.ts +25 -0
- package/admin/src/context/DbContext.tsx +33 -0
- package/admin/src/context/StatsContext.tsx +56 -0
- package/admin/src/main.tsx +10 -0
- package/admin/src/styles.css +96 -0
- package/admin/tsconfig.app.json +21 -0
- package/admin/tsconfig.json +7 -0
- package/admin/tsconfig.node.json +15 -0
- package/admin/vite.config.ts +42 -0
- package/deploy/base.yaml +1 -1
- package/deploy/prod-il.config.ts +5 -2
- package/deploy/prod.config.ts +5 -2
- package/package.json +5 -1
- package/src/server/BodDB.ts +62 -5
- package/src/server/ReplicationEngine.ts +149 -34
- package/src/server/StorageEngine.ts +12 -4
- package/src/server/StreamEngine.ts +2 -2
- package/src/server/Transport.ts +60 -0
- package/tests/replication.test.ts +162 -1
- 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,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,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
package/deploy/prod-il.config.ts
CHANGED
|
@@ -12,8 +12,11 @@ export default {
|
|
|
12
12
|
},
|
|
13
13
|
transport: {
|
|
14
14
|
staticRoutes: {
|
|
15
|
-
'/admin': './admin/
|
|
16
|
-
'/': './admin/
|
|
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
|
}
|
package/deploy/prod.config.ts
CHANGED
|
@@ -9,8 +9,11 @@ export default {
|
|
|
9
9
|
replication: { role: 'primary' as const },
|
|
10
10
|
transport: {
|
|
11
11
|
staticRoutes: {
|
|
12
|
-
'/admin': './admin/
|
|
13
|
-
'/': './admin/
|
|
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.
|
|
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",
|
package/src/server/BodDB.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
528
|
+
statsLog.info('Stats publisher started (collection gated on active subscribers)');
|
|
472
529
|
}
|
|
473
530
|
|
|
474
531
|
/** Stop the stats publisher. */
|