@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,237 @@
|
|
|
1
|
+
import { db } from '../../context/DbContext';
|
|
2
|
+
import { showStatus } from './utils';
|
|
3
|
+
|
|
4
|
+
export default function MessageQueue() {
|
|
5
|
+
function fillMqPush(path: string, value: string, idem: string) {
|
|
6
|
+
(document.getElementById('mq-push-path') as HTMLInputElement).value = path;
|
|
7
|
+
(document.getElementById('mq-push-value') as HTMLTextAreaElement).value = value;
|
|
8
|
+
(document.getElementById('mq-push-idem') as HTMLInputElement).value = idem;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function doMqPush() {
|
|
12
|
+
const path = (document.getElementById('mq-push-path') as HTMLInputElement).value.trim();
|
|
13
|
+
const idem = (document.getElementById('mq-push-idem') as HTMLInputElement).value.trim();
|
|
14
|
+
if (!path) return showStatus('mq-push-status', 'Path required', false);
|
|
15
|
+
const t0 = performance.now();
|
|
16
|
+
try {
|
|
17
|
+
const value = JSON.parse((document.getElementById('mq-push-value') as HTMLTextAreaElement).value);
|
|
18
|
+
const key = await db.mqPush(path, value, idem);
|
|
19
|
+
showStatus('mq-push-status', `Pushed → ${key}`, true, performance.now() - t0);
|
|
20
|
+
} catch (e: unknown) { showStatus('mq-push-status', (e as Error).message, false); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function doMqFetch() {
|
|
24
|
+
const path = (document.getElementById('mq-fetch-path') as HTMLInputElement).value.trim();
|
|
25
|
+
const count = parseInt((document.getElementById('mq-fetch-count') as HTMLInputElement).value) || 1;
|
|
26
|
+
if (!path) return showStatus('mq-fetch-status', 'Path required', false);
|
|
27
|
+
const t0 = performance.now();
|
|
28
|
+
try {
|
|
29
|
+
const msgs = await db.mqFetch(path, count) as Array<{ key: string }>;
|
|
30
|
+
const el = document.getElementById('mq-fetch-result') as HTMLElement;
|
|
31
|
+
el.style.display = 'block';
|
|
32
|
+
el.textContent = JSON.stringify(msgs, null, 2);
|
|
33
|
+
showStatus('mq-fetch-status', `${msgs.length} message(s) claimed`, true, performance.now() - t0);
|
|
34
|
+
if (msgs.length > 0) {
|
|
35
|
+
(document.getElementById('mq-ack-path') as HTMLInputElement).value = path;
|
|
36
|
+
(document.getElementById('mq-ack-key') as HTMLInputElement).value = msgs[0].key;
|
|
37
|
+
}
|
|
38
|
+
} catch (e: unknown) { showStatus('mq-fetch-status', (e as Error).message, false); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function doMqAck() {
|
|
42
|
+
const path = (document.getElementById('mq-ack-path') as HTMLInputElement).value.trim();
|
|
43
|
+
const key = (document.getElementById('mq-ack-key') as HTMLInputElement).value.trim();
|
|
44
|
+
if (!path || !key) return showStatus('mq-ack-status', 'Path and key required', false);
|
|
45
|
+
const t0 = performance.now();
|
|
46
|
+
try { await db.mqAck(path, key); showStatus('mq-ack-status', `Acked ${key}`, true, performance.now() - t0); }
|
|
47
|
+
catch (e: unknown) { showStatus('mq-ack-status', (e as Error).message, false); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function doMqNack() {
|
|
51
|
+
const path = (document.getElementById('mq-ack-path') as HTMLInputElement).value.trim();
|
|
52
|
+
const key = (document.getElementById('mq-ack-key') as HTMLInputElement).value.trim();
|
|
53
|
+
if (!path || !key) return showStatus('mq-ack-status', 'Path and key required', false);
|
|
54
|
+
const t0 = performance.now();
|
|
55
|
+
try { await db.mqNack(path, key); showStatus('mq-ack-status', `Nacked ${key} — released to pending`, true, performance.now() - t0); }
|
|
56
|
+
catch (e: unknown) { showStatus('mq-ack-status', (e as Error).message, false); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function doMqPeek() {
|
|
60
|
+
const path = (document.getElementById('mq-peek-path') as HTMLInputElement).value.trim();
|
|
61
|
+
const count = parseInt((document.getElementById('mq-peek-count') as HTMLInputElement).value) || 10;
|
|
62
|
+
if (!path) return showStatus('mq-peek-status', 'Path required', false);
|
|
63
|
+
const t0 = performance.now();
|
|
64
|
+
try {
|
|
65
|
+
const msgs = await db.mqPeek(path, count) as unknown[];
|
|
66
|
+
const el = document.getElementById('mq-peek-result') as HTMLElement;
|
|
67
|
+
el.style.display = 'block';
|
|
68
|
+
el.textContent = JSON.stringify(msgs, null, 2);
|
|
69
|
+
showStatus('mq-peek-status', `${msgs.length} message(s)`, true, performance.now() - t0);
|
|
70
|
+
} catch (e: unknown) { showStatus('mq-peek-status', (e as Error).message, false); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function doMqDlq() {
|
|
74
|
+
const path = (document.getElementById('mq-dlq-path') as HTMLInputElement).value.trim();
|
|
75
|
+
if (!path) return showStatus('mq-dlq-status', 'Path required', false);
|
|
76
|
+
const t0 = performance.now();
|
|
77
|
+
try {
|
|
78
|
+
const msgs = await db.mqDlq(path) as unknown[];
|
|
79
|
+
const el = document.getElementById('mq-dlq-result') as HTMLElement;
|
|
80
|
+
el.style.display = 'block';
|
|
81
|
+
el.textContent = msgs.length ? JSON.stringify(msgs, null, 2) : '(empty)';
|
|
82
|
+
showStatus('mq-dlq-status', `${msgs.length} dead letter(s)`, true, performance.now() - t0);
|
|
83
|
+
} catch (e: unknown) { showStatus('mq-dlq-status', (e as Error).message, false); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function doMqPurge() {
|
|
87
|
+
const path = (document.getElementById('mq-purge-path') as HTMLInputElement).value.trim();
|
|
88
|
+
if (!path) return showStatus('mq-purge-status', 'Path required', false);
|
|
89
|
+
const t0 = performance.now();
|
|
90
|
+
try {
|
|
91
|
+
const count = await db.mqPurge(path);
|
|
92
|
+
showStatus('mq-purge-status', `Purged ${count} message(s)`, true, performance.now() - t0);
|
|
93
|
+
} catch (e: unknown) { showStatus('mq-purge-status', (e as Error).message, false); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function doMqDemo() {
|
|
97
|
+
const Q = 'queues/demo';
|
|
98
|
+
const el = document.getElementById('mq-demo-result') as HTMLElement;
|
|
99
|
+
el.style.display = 'block'; el.textContent = '';
|
|
100
|
+
const wait = (ms = 500) => new Promise(r => setTimeout(r, ms));
|
|
101
|
+
const appendLog = async (s: string, delay = 500) => {
|
|
102
|
+
el.textContent += (el.textContent ? '\n' : '') + s;
|
|
103
|
+
el.scrollTop = el.scrollHeight;
|
|
104
|
+
if (delay > 0) await wait(delay);
|
|
105
|
+
};
|
|
106
|
+
const t0 = performance.now();
|
|
107
|
+
try {
|
|
108
|
+
await db.mqPurge(Q, { all: true });
|
|
109
|
+
await appendLog('🧹 Purged queue — clean slate');
|
|
110
|
+
const keys: string[] = [];
|
|
111
|
+
for (let i = 1; i <= 5; i++) {
|
|
112
|
+
const k = await db.mqPush(Q, { task: `job-${i}`, priority: i > 3 ? 'high' : 'normal' }) as string;
|
|
113
|
+
keys.push(k);
|
|
114
|
+
await appendLog(`📥 Pushed job-${i}: ${k.slice(0,8)}`, 300);
|
|
115
|
+
}
|
|
116
|
+
let peek = await db.mqPeek(Q, 10) as Array<{ key: string; status: string; deliveryCount: number }>;
|
|
117
|
+
await appendLog(`\n📋 Peek: ${peek.length} msgs — all ${peek.map(m => m.status).join(', ')}`);
|
|
118
|
+
const batch1 = await db.mqFetch(Q, 3) as Array<{ key: string; deliveryCount: number }>;
|
|
119
|
+
await appendLog(`\n👷 Worker 1 claimed ${batch1.length}: ${batch1.map(m => m.key.slice(0,8)).join(', ')}`);
|
|
120
|
+
const batch2 = await db.mqFetch(Q, 5) as Array<{ key: string }>;
|
|
121
|
+
await appendLog(`👷 Worker 2 claimed ${batch2.length}: ${batch2.map(m => m.key.slice(0,8)).join(', ')}`);
|
|
122
|
+
const batch3 = await db.mqFetch(Q, 5) as unknown[];
|
|
123
|
+
await appendLog(`👷 Worker 3 claimed ${batch3.length} (none left!)`);
|
|
124
|
+
peek = await db.mqPeek(Q, 10) as Array<{ key: string; status: string; deliveryCount: number }>;
|
|
125
|
+
await appendLog(`📋 Peek: all ${peek.map(m => m.status).join(', ')}`);
|
|
126
|
+
await db.mqAck(Q, batch1[0].key);
|
|
127
|
+
await appendLog(`\n✅ Acked ${batch1[0].key.slice(0,8)} (job-1 done)`);
|
|
128
|
+
await db.mqNack(Q, batch1[1].key);
|
|
129
|
+
await appendLog(`🔄 Nacked ${batch1[1].key.slice(0,8)} (job-2 → back to pending)`);
|
|
130
|
+
await db.mqAck(Q, batch1[2].key);
|
|
131
|
+
await appendLog(`✅ Acked ${batch1[2].key.slice(0,8)} (job-3 done)`);
|
|
132
|
+
await db.mqAck(Q, batch2[0].key);
|
|
133
|
+
await appendLog(`✅ Acked ${batch2[0].key.slice(0,8)} (job-4 done)`);
|
|
134
|
+
await db.mqAck(Q, batch2[1].key);
|
|
135
|
+
await appendLog(`✅ Acked ${batch2[1].key.slice(0,8)} (job-5 done)`);
|
|
136
|
+
peek = await db.mqPeek(Q, 10) as Array<{ key: string; status: string; deliveryCount: number }>;
|
|
137
|
+
await appendLog(`\n📋 Remaining: ${peek.length} msg — status: ${peek[0]?.status}, deliveries: ${peek[0]?.deliveryCount}`);
|
|
138
|
+
const retry1 = await db.mqFetch(Q, 1) as Array<{ key: string; deliveryCount: number }>;
|
|
139
|
+
await appendLog(`\n🔄 Retry 1: fetched, deliveryCount=${retry1[0].deliveryCount}`);
|
|
140
|
+
await db.mqNack(Q, retry1[0].key);
|
|
141
|
+
await appendLog(` → nacked back to pending`, 300);
|
|
142
|
+
const retry2 = await db.mqFetch(Q, 1) as Array<{ key: string; deliveryCount: number }>;
|
|
143
|
+
await appendLog(`🔄 Retry 2: fetched, deliveryCount=${retry2[0].deliveryCount}`);
|
|
144
|
+
await db.mqNack(Q, retry2[0].key);
|
|
145
|
+
await appendLog(` → nacked back to pending`, 300);
|
|
146
|
+
const retry3 = await db.mqFetch(Q, 1) as Array<{ key: string; deliveryCount: number }>;
|
|
147
|
+
await appendLog(`🔄 Retry 3: fetched, deliveryCount=${retry3[0].deliveryCount}`);
|
|
148
|
+
await appendLog(` → simulating worker crash (left inflight)...`, 300);
|
|
149
|
+
await appendLog(`\n⏳ In production, sweep reclaims expired inflight msgs every 60s`);
|
|
150
|
+
peek = await db.mqPeek(Q, 10) as Array<{ key: string; status: string; deliveryCount: number }>;
|
|
151
|
+
await appendLog(`📋 Peek: ${peek.length} msg — status: ${peek[0]?.status}, deliveries: ${peek[0]?.deliveryCount}`);
|
|
152
|
+
await appendLog(` After maxDeliveries (default 5), sweep moves to DLQ`);
|
|
153
|
+
const dlq = await db.mqDlq(Q) as unknown[];
|
|
154
|
+
await appendLog(`\n💀 DLQ: ${dlq.length} dead letters`);
|
|
155
|
+
await db.mqPurge(Q, { all: true });
|
|
156
|
+
await appendLog(`\n🧹 Cleaned up queue`);
|
|
157
|
+
const ms = (performance.now() - t0).toFixed(1);
|
|
158
|
+
await appendLog(`🎉 Demo complete in ${ms}ms — 5 pushed, 4 acked, 1 retried 3x`, 0);
|
|
159
|
+
showStatus('mq-demo-status', 'Demo complete', true, performance.now() - t0);
|
|
160
|
+
} catch (e: unknown) {
|
|
161
|
+
await appendLog(`\n❌ Error: ${(e as Error).message}`, 0);
|
|
162
|
+
showStatus('mq-demo-status', (e as Error).message, false);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
168
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
169
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>E2E Demo — full worker lifecycle: push → fetch → ack/nack → retry → DLQ</label>
|
|
170
|
+
<div className="row"><button className="success" onClick={doMqDemo}>RUN DEMO</button></div>
|
|
171
|
+
<div id="mq-demo-status" style={{ marginTop: 4 }}></div>
|
|
172
|
+
<div className="result" id="mq-demo-result" style={{ marginTop: 4, display: 'none', maxHeight: 400, whiteSpace: 'pre-wrap' }}>—</div>
|
|
173
|
+
</div>
|
|
174
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
175
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Push Job — enqueue a message (SQS-style, exactly-once delivery)</label>
|
|
176
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
177
|
+
<button className="sm" onClick={() => fillMqPush('queues/jobs', '{"type":"email","to":"user@example.com"}', '')}>email job</button>
|
|
178
|
+
<button className="sm" onClick={() => fillMqPush('queues/jobs', '{"type":"sms","to":"+1234567890"}', 'sms-123')}>idempotent sms</button>
|
|
179
|
+
</div>
|
|
180
|
+
<div className="row">
|
|
181
|
+
<input id="mq-push-path" type="text" placeholder="queues/jobs" defaultValue="queues/jobs" style={{ flex: 2 }} />
|
|
182
|
+
<input id="mq-push-idem" type="text" placeholder="idempotency key (optional)" style={{ flex: 1 }} />
|
|
183
|
+
</div>
|
|
184
|
+
<textarea id="mq-push-value" style={{ minHeight: 40, marginTop: 4 }} defaultValue='{ "type": "email", "to": "user@example.com" }'></textarea>
|
|
185
|
+
<div className="row" style={{ marginTop: 4 }}><button className="success" onClick={doMqPush}>PUSH</button></div>
|
|
186
|
+
<div id="mq-push-status" style={{ marginTop: 4 }}></div>
|
|
187
|
+
</div>
|
|
188
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
189
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Fetch — claim messages (visibility timeout applies)</label>
|
|
190
|
+
<div className="row">
|
|
191
|
+
<input id="mq-fetch-path" type="text" placeholder="queues/jobs" defaultValue="queues/jobs" style={{ flex: 2 }} />
|
|
192
|
+
<input id="mq-fetch-count" type="number" defaultValue="1" style={{ width: 70 }} placeholder="count" />
|
|
193
|
+
<button onClick={doMqFetch}>FETCH</button>
|
|
194
|
+
</div>
|
|
195
|
+
<div id="mq-fetch-status" style={{ marginTop: 4 }}></div>
|
|
196
|
+
<div className="result" id="mq-fetch-result" style={{ marginTop: 4, display: 'none', maxHeight: 300 }}>—</div>
|
|
197
|
+
</div>
|
|
198
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
199
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Ack / Nack — confirm processing or release back to queue</label>
|
|
200
|
+
<div className="row">
|
|
201
|
+
<input id="mq-ack-path" type="text" placeholder="queues/jobs" defaultValue="queues/jobs" style={{ flex: 2 }} />
|
|
202
|
+
<input id="mq-ack-key" type="text" placeholder="message key" style={{ flex: 2 }} />
|
|
203
|
+
<button className="danger" onClick={doMqAck}>ACK</button>
|
|
204
|
+
<button onClick={doMqNack}>NACK</button>
|
|
205
|
+
</div>
|
|
206
|
+
<div id="mq-ack-status" style={{ marginTop: 4 }}></div>
|
|
207
|
+
</div>
|
|
208
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
209
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Peek — view messages without claiming</label>
|
|
210
|
+
<div className="row">
|
|
211
|
+
<input id="mq-peek-path" type="text" placeholder="queues/jobs" defaultValue="queues/jobs" style={{ flex: 2 }} />
|
|
212
|
+
<input id="mq-peek-count" type="number" defaultValue="10" style={{ width: 70 }} placeholder="count" />
|
|
213
|
+
<button onClick={doMqPeek}>PEEK</button>
|
|
214
|
+
</div>
|
|
215
|
+
<div id="mq-peek-status" style={{ marginTop: 4 }}></div>
|
|
216
|
+
<div className="result" id="mq-peek-result" style={{ marginTop: 4, display: 'none', maxHeight: 300 }}>—</div>
|
|
217
|
+
</div>
|
|
218
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
219
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Dead Letter Queue — messages that exceeded max deliveries</label>
|
|
220
|
+
<div className="row">
|
|
221
|
+
<input id="mq-dlq-path" type="text" placeholder="queues/jobs" defaultValue="queues/jobs" style={{ flex: 2 }} />
|
|
222
|
+
<button onClick={doMqDlq}>VIEW DLQ</button>
|
|
223
|
+
</div>
|
|
224
|
+
<div id="mq-dlq-status" style={{ marginTop: 4 }}></div>
|
|
225
|
+
<div className="result" id="mq-dlq-result" style={{ marginTop: 4, display: 'none', maxHeight: 300 }}>—</div>
|
|
226
|
+
</div>
|
|
227
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
228
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Purge — delete all pending messages in a queue</label>
|
|
229
|
+
<div className="row">
|
|
230
|
+
<input id="mq-purge-path" type="text" placeholder="queues/jobs" defaultValue="queues/jobs" style={{ flex: 2 }} />
|
|
231
|
+
<button className="danger" onClick={() => { if (confirm('Purge all pending messages?')) doMqPurge(); }}>PURGE</button>
|
|
232
|
+
</div>
|
|
233
|
+
<div id="mq-purge-status" style={{ marginTop: 4 }}></div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import { db } from '../../context/DbContext';
|
|
3
|
+
import { showStatus, escHtml } from './utils';
|
|
4
|
+
|
|
5
|
+
export default function Query() {
|
|
6
|
+
const filtersRef = useRef<HTMLDivElement>(null);
|
|
7
|
+
|
|
8
|
+
function addFilter() {
|
|
9
|
+
const container = filtersRef.current!;
|
|
10
|
+
const row = document.createElement('div');
|
|
11
|
+
row.className = 'filter-row';
|
|
12
|
+
row.innerHTML = `
|
|
13
|
+
<input type="text" placeholder="field" style="flex:2">
|
|
14
|
+
<select style="flex:1"><option>==</option><option>!=</option><option>></option><option>>=</option><option><</option><option><=</option><option>contains</option><option>startsWith</option></select>
|
|
15
|
+
<input type="text" placeholder="value" style="flex:2">
|
|
16
|
+
<button onclick="this.parentElement.remove()" class="danger sm">✕</button>`;
|
|
17
|
+
container.appendChild(row);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderTable(data: unknown): string {
|
|
21
|
+
if (!Array.isArray(data) || !data.length) return '<div class="result">No results</div>';
|
|
22
|
+
const allKeys = [...new Set(data.flatMap((r) => Object.keys(r as Record<string, unknown>)))];
|
|
23
|
+
const ths = allKeys.map((k) => `<th>${escHtml(k)}</th>`).join('');
|
|
24
|
+
const trs = data.map((r) => `<tr>${allKeys.map((k) => `<td>${escHtml((r as Record<string, unknown>)[k] == null ? '' : String((r as Record<string, unknown>)[k]))}</td>`).join('')}</tr>`).join('');
|
|
25
|
+
return `<div style="overflow:auto;margin-top:6px"><table><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table></div>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function doQuery() {
|
|
29
|
+
const path = (document.getElementById('q-path') as HTMLInputElement).value.trim();
|
|
30
|
+
const filterRows = filtersRef.current!.querySelectorAll('.filter-row');
|
|
31
|
+
const filters = [...filterRows].map((r) => {
|
|
32
|
+
const inputs = r.querySelectorAll('input');
|
|
33
|
+
const op = (r.querySelector('select') as HTMLSelectElement).value;
|
|
34
|
+
let val: unknown = (inputs[1] as HTMLInputElement).value.trim();
|
|
35
|
+
try { val = JSON.parse(val as string); } catch {}
|
|
36
|
+
return { field: (inputs[0] as HTMLInputElement).value.trim(), op, value: val };
|
|
37
|
+
}).filter((f) => f.field);
|
|
38
|
+
const orderField = (document.getElementById('q-order-field') as HTMLInputElement).value.trim();
|
|
39
|
+
const orderDir = (document.getElementById('q-order-dir') as HTMLSelectElement).value as 'asc' | 'desc';
|
|
40
|
+
const limit = (document.getElementById('q-limit') as HTMLInputElement).value.trim();
|
|
41
|
+
const offset = (document.getElementById('q-offset') as HTMLInputElement).value.trim();
|
|
42
|
+
let q = db.query(path);
|
|
43
|
+
for (const f of filters) q = q.where(f.field, f.op, f.value);
|
|
44
|
+
if (orderField) q = q.order(orderField, orderDir);
|
|
45
|
+
if (limit) q = q.limit(Number(limit));
|
|
46
|
+
if (offset) q = q.offset(Number(offset));
|
|
47
|
+
const t0 = performance.now();
|
|
48
|
+
const res = await q.get();
|
|
49
|
+
showStatus('q-status', Array.isArray(res) ? `${(res as unknown[]).length} results` : 'Error', Array.isArray(res), performance.now() - t0);
|
|
50
|
+
(document.getElementById('q-result') as HTMLElement).innerHTML = renderTable(res);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<div>
|
|
56
|
+
<label>Base Path</label>
|
|
57
|
+
<input id="q-path" type="text" placeholder="users" />
|
|
58
|
+
</div>
|
|
59
|
+
<div>
|
|
60
|
+
<label>Filters</label>
|
|
61
|
+
<div id="q-filters" ref={filtersRef}></div>
|
|
62
|
+
<button onClick={addFilter} style={{ marginTop: 5 }} className="sm">+ Filter</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div className="row">
|
|
65
|
+
<div style={{ flex: 2 }}><label>Order Field</label><input id="q-order-field" type="text" placeholder="name" /></div>
|
|
66
|
+
<div style={{ flex: 1 }}><label>Dir</label><select id="q-order-dir"><option value="asc">asc</option><option value="desc">desc</option></select></div>
|
|
67
|
+
<div style={{ flex: 1 }}><label>Limit</label><input id="q-limit" type="text" placeholder="—" /></div>
|
|
68
|
+
<div style={{ flex: 1 }}><label>Offset</label><input id="q-offset" type="text" placeholder="—" /></div>
|
|
69
|
+
</div>
|
|
70
|
+
<div><button onClick={doQuery}>RUN QUERY</button></div>
|
|
71
|
+
<div id="q-status"></div>
|
|
72
|
+
<div id="q-result"></div>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { db } from '../../context/DbContext';
|
|
2
|
+
import { showStatus, refreshTreePath } from './utils';
|
|
3
|
+
|
|
4
|
+
export default function ReadWrite() {
|
|
5
|
+
async function doGet() {
|
|
6
|
+
const path = (document.getElementById('rw-path') as HTMLInputElement).value.trim();
|
|
7
|
+
const t0 = performance.now();
|
|
8
|
+
try {
|
|
9
|
+
const res = await db.get(path);
|
|
10
|
+
(document.getElementById('rw-result') as HTMLElement).textContent = JSON.stringify(res, null, 2);
|
|
11
|
+
showStatus('rw-status', 'OK', true, performance.now() - t0);
|
|
12
|
+
} catch (e: unknown) { showStatus('rw-status', (e as Error).message, false, performance.now() - t0); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function doSet() {
|
|
16
|
+
const path = (document.getElementById('rw-path') as HTMLInputElement).value.trim();
|
|
17
|
+
const raw = (document.getElementById('rw-value') as HTMLTextAreaElement).value.trim();
|
|
18
|
+
let value;
|
|
19
|
+
try { value = JSON.parse(raw); } catch { return showStatus('rw-status', 'Invalid JSON', false); }
|
|
20
|
+
const t0 = performance.now();
|
|
21
|
+
try {
|
|
22
|
+
await db.set(path, value);
|
|
23
|
+
showStatus('rw-status', 'Set OK', true, performance.now() - t0);
|
|
24
|
+
refreshTreePath(path);
|
|
25
|
+
} catch (e: unknown) { showStatus('rw-status', (e as Error).message, false, performance.now() - t0); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function doUpdate() {
|
|
29
|
+
const path = (document.getElementById('rw-path') as HTMLInputElement).value.trim();
|
|
30
|
+
const raw = (document.getElementById('rw-value') as HTMLTextAreaElement).value.trim();
|
|
31
|
+
let value;
|
|
32
|
+
try { value = JSON.parse(raw); } catch { return showStatus('rw-status', 'Invalid JSON', false); }
|
|
33
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return showStatus('rw-status', 'UPDATE requires an object', false);
|
|
34
|
+
const updates = Object.fromEntries(Object.entries(value).map(([k, v]) => [`${path}/${k}`, v]));
|
|
35
|
+
const t0 = performance.now();
|
|
36
|
+
try {
|
|
37
|
+
await db.update(updates);
|
|
38
|
+
showStatus('rw-status', 'Updated OK', true, performance.now() - t0);
|
|
39
|
+
for (const p of Object.keys(updates)) refreshTreePath(p);
|
|
40
|
+
} catch (e: unknown) { showStatus('rw-status', (e as Error).message, false, performance.now() - t0); }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function doDelete() {
|
|
44
|
+
const path = (document.getElementById('rw-path') as HTMLInputElement).value.trim();
|
|
45
|
+
const t0 = performance.now();
|
|
46
|
+
try {
|
|
47
|
+
await db.delete(path);
|
|
48
|
+
showStatus('rw-status', 'Deleted', true, performance.now() - t0);
|
|
49
|
+
refreshTreePath(path);
|
|
50
|
+
} catch (e: unknown) { showStatus('rw-status', (e as Error).message, false, performance.now() - t0); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function doRwAuth() {
|
|
54
|
+
const token = (document.getElementById('rw-auth-token') as HTMLInputElement).value.trim();
|
|
55
|
+
const authToken = document.getElementById('auth-token') as HTMLInputElement;
|
|
56
|
+
if (authToken) authToken.value = token;
|
|
57
|
+
await applyAuth(token);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function applyAuth(token: string) {
|
|
61
|
+
const statusEl = document.getElementById('rw-auth-status');
|
|
62
|
+
const authStatusEl = document.getElementById('auth-status');
|
|
63
|
+
if (!token) {
|
|
64
|
+
db.disconnect(); db.reconnect();
|
|
65
|
+
if (statusEl) statusEl.innerHTML = '<span style="color:#555">none</span>';
|
|
66
|
+
if (authStatusEl) authStatusEl.innerHTML = '<span style="color:#555">Not authenticated</span>';
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const ctx = await db._send('auth', { token }) as unknown;
|
|
71
|
+
const s = JSON.stringify(ctx);
|
|
72
|
+
if (statusEl) statusEl.innerHTML = `<span style="color:#4ec9b0">${s}</span>`;
|
|
73
|
+
if (authStatusEl) authStatusEl.innerHTML = `<span style="color:#4ec9b0">Authenticated</span> — ${s}`;
|
|
74
|
+
} catch (e: unknown) {
|
|
75
|
+
const msg = (e as Error).message;
|
|
76
|
+
if (statusEl) statusEl.innerHTML = `<span style="color:#f48771">failed</span>`;
|
|
77
|
+
if (authStatusEl) authStatusEl.innerHTML = `<span style="color:#f48771">Auth failed: ${msg}</span>`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function doUpdateTab() {
|
|
82
|
+
const raw = (document.getElementById('upd-value') as HTMLTextAreaElement).value.trim();
|
|
83
|
+
let updates;
|
|
84
|
+
try { updates = JSON.parse(raw); } catch { return showStatus('upd-status', 'Invalid JSON', false); }
|
|
85
|
+
const t0 = performance.now();
|
|
86
|
+
try {
|
|
87
|
+
await db.update(updates);
|
|
88
|
+
showStatus('upd-status', 'Updated', true, performance.now() - t0);
|
|
89
|
+
for (const p of Object.keys(updates)) refreshTreePath(p);
|
|
90
|
+
} catch (e: unknown) { showStatus('upd-status', (e as Error).message, false, performance.now() - t0); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function doPush() {
|
|
94
|
+
const path = (document.getElementById('push-path') as HTMLInputElement).value.trim();
|
|
95
|
+
const raw = (document.getElementById('push-value') as HTMLTextAreaElement).value.trim();
|
|
96
|
+
let value;
|
|
97
|
+
try { value = JSON.parse(raw); } catch { return showStatus('push-status', 'Invalid JSON', false); }
|
|
98
|
+
const t0 = performance.now();
|
|
99
|
+
try {
|
|
100
|
+
const key = await db.push(path, value);
|
|
101
|
+
showStatus('push-status', `Pushed → ${key}`, true, performance.now() - t0);
|
|
102
|
+
refreshTreePath(path);
|
|
103
|
+
} catch (e: unknown) { showStatus('push-status', (e as Error).message, false, performance.now() - t0); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function doBatch() {
|
|
107
|
+
const raw = (document.getElementById('batch-value') as HTMLTextAreaElement).value.trim();
|
|
108
|
+
let operations: unknown[];
|
|
109
|
+
try { operations = JSON.parse(raw); } catch { return showStatus('batch-status', 'Invalid JSON array', false); }
|
|
110
|
+
if (!Array.isArray(operations)) return showStatus('batch-status', 'Must be a JSON array', false);
|
|
111
|
+
const t0 = performance.now();
|
|
112
|
+
try {
|
|
113
|
+
const result = await db.batch(operations);
|
|
114
|
+
showStatus('batch-status', `Batch OK (${operations.length} ops)`, true, performance.now() - t0);
|
|
115
|
+
const resultEl = document.getElementById('batch-result') as HTMLElement;
|
|
116
|
+
if (result) { resultEl.textContent = JSON.stringify(result, null, 2); resultEl.style.display = 'block'; }
|
|
117
|
+
else { resultEl.style.display = 'none'; }
|
|
118
|
+
const batchPaths = new Set((operations as Array<{path?: string; updates?: Record<string,unknown>}>).map(op =>
|
|
119
|
+
(op.path || Object.keys(op.updates || {})[0] || '').split('/')[0]).filter(Boolean));
|
|
120
|
+
for (const p of batchPaths) refreshTreePath(p);
|
|
121
|
+
} catch (e: unknown) { showStatus('batch-status', (e as Error).message, false, performance.now() - t0); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
127
|
+
<span style={{ fontSize: 11, color: '#555' }}>Auth:</span>
|
|
128
|
+
<input id="rw-auth-token" type="text" placeholder="token (or leave empty)" style={{ flex: 1, fontSize: 11 }} />
|
|
129
|
+
<button onClick={doRwAuth} className="sm">SET</button>
|
|
130
|
+
<span id="rw-auth-status" style={{ fontSize: 11, color: '#555' }}>—</span>
|
|
131
|
+
</div>
|
|
132
|
+
<div>
|
|
133
|
+
<label>Path</label>
|
|
134
|
+
<div className="row">
|
|
135
|
+
<input id="rw-path" type="text" placeholder="users/alice/name" />
|
|
136
|
+
<button onClick={doGet}>GET</button>
|
|
137
|
+
<button className="danger" onClick={doDelete}>DELETE</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
<div>
|
|
141
|
+
<label>Value (JSON)</label>
|
|
142
|
+
<textarea id="rw-value" placeholder={'"Alice"\nor { "name": "Alice", "age": 30 }'}></textarea>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="row">
|
|
145
|
+
<button className="success" onClick={doSet}>SET</button>
|
|
146
|
+
<button onClick={doUpdate}>UPDATE</button>
|
|
147
|
+
</div>
|
|
148
|
+
<div id="rw-status"></div>
|
|
149
|
+
<div>
|
|
150
|
+
<label>Result</label>
|
|
151
|
+
<div className="result" id="rw-result">—</div>
|
|
152
|
+
</div>
|
|
153
|
+
<div style={{ marginTop: 8 }}>
|
|
154
|
+
<label>Multi-path Update — JSON object mapping path → value</label>
|
|
155
|
+
<textarea id="upd-value" style={{ minHeight: 80 }} placeholder='{ "users/alice/age": 31, "users/bob/role": "admin" }'></textarea>
|
|
156
|
+
<div className="row" style={{ marginTop: 4 }}><button onClick={doUpdateTab}>MULTI-UPDATE</button></div>
|
|
157
|
+
<div id="upd-status"></div>
|
|
158
|
+
</div>
|
|
159
|
+
<div style={{ marginTop: 8, borderTop: '1px solid #222', paddingTop: 8 }}>
|
|
160
|
+
<label>Push — append value with auto-generated key</label>
|
|
161
|
+
<div className="row">
|
|
162
|
+
<input id="push-path" type="text" placeholder="logs" style={{ flex: 1 }} />
|
|
163
|
+
<button className="success" onClick={doPush}>PUSH</button>
|
|
164
|
+
</div>
|
|
165
|
+
<textarea id="push-value" style={{ minHeight: 50, marginTop: 4 }} placeholder='{ "level": "info", "msg": "hello" }'></textarea>
|
|
166
|
+
<div id="push-status"></div>
|
|
167
|
+
</div>
|
|
168
|
+
<div style={{ marginTop: 8, borderTop: '1px solid #222', paddingTop: 8 }}>
|
|
169
|
+
<label>Batch — atomic multi-op (JSON array of operations)</label>
|
|
170
|
+
<textarea id="batch-value" style={{ minHeight: 80 }}></textarea>
|
|
171
|
+
<div className="row" style={{ marginTop: 4 }}><button onClick={doBatch}>RUN BATCH</button></div>
|
|
172
|
+
<div id="batch-status"></div>
|
|
173
|
+
<div className="result" id="batch-result" style={{ marginTop: 4, display: 'none' }}>—</div>
|
|
174
|
+
</div>
|
|
175
|
+
</>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { showStatus, apiFetch } from './utils';
|
|
3
|
+
|
|
4
|
+
interface Props { active: boolean; }
|
|
5
|
+
|
|
6
|
+
export default function Replication({ active }: Props) {
|
|
7
|
+
useEffect(() => { if (active) loadRepl(); }, [active]);
|
|
8
|
+
|
|
9
|
+
async function loadRepl() {
|
|
10
|
+
const t0 = performance.now();
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch('/replication');
|
|
13
|
+
const json = await res.json();
|
|
14
|
+
const ms = performance.now() - t0;
|
|
15
|
+
if (!json.ok) { showStatus('repl-status', json.error || 'Failed', false, ms); return; }
|
|
16
|
+
const { role, sources, synced } = json;
|
|
17
|
+
showStatus('repl-status', `Role: ${role} — ${sources.length} source(s)`, true, ms);
|
|
18
|
+
const configEl = document.getElementById('repl-config') as HTMLElement;
|
|
19
|
+
configEl.style.display = 'block';
|
|
20
|
+
configEl.textContent = sources.map((s: Record<string, unknown>) =>
|
|
21
|
+
`${s.url}\n paths: [${(s.paths as string[]).join(', ')}]\n localPrefix: ${s.localPrefix || '(none)'}\n id: ${s.id || '(auto)'}`
|
|
22
|
+
).join('\n\n') || '(no sources configured)';
|
|
23
|
+
const syncedEl = document.getElementById('repl-synced') as HTMLElement;
|
|
24
|
+
syncedEl.style.display = 'block';
|
|
25
|
+
syncedEl.textContent = JSON.stringify(synced, null, 2);
|
|
26
|
+
} catch (e: unknown) { showStatus('repl-status', (e as Error).message, false); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fillReplWrite(path: string, value: string) {
|
|
30
|
+
(document.getElementById('repl-write-path') as HTMLInputElement).value = path;
|
|
31
|
+
(document.getElementById('repl-write-value') as HTMLTextAreaElement).value = value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function doReplWrite() {
|
|
35
|
+
const path = (document.getElementById('repl-write-path') as HTMLInputElement).value.trim();
|
|
36
|
+
const raw = (document.getElementById('repl-write-value') as HTMLTextAreaElement).value.trim();
|
|
37
|
+
if (!path) return showStatus('repl-write-status', 'Path required', false);
|
|
38
|
+
const t0 = performance.now();
|
|
39
|
+
try {
|
|
40
|
+
const value = JSON.parse(raw);
|
|
41
|
+
const json = await apiFetch('/replication/source-write', {
|
|
42
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ path, value }),
|
|
44
|
+
});
|
|
45
|
+
const ms = performance.now() - t0;
|
|
46
|
+
showStatus('repl-write-status', json.ok ? `Written to source: ${path}` : (json.error as string || 'Failed'), !!json.ok, ms);
|
|
47
|
+
if (json.ok) setTimeout(loadRepl, 600);
|
|
48
|
+
} catch (e: unknown) { showStatus('repl-write-status', (e as Error).message, false, performance.now() - t0); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function doReplDelete() {
|
|
52
|
+
const path = (document.getElementById('repl-write-path') as HTMLInputElement).value.trim();
|
|
53
|
+
if (!path) return showStatus('repl-write-status', 'Path required', false);
|
|
54
|
+
const t0 = performance.now();
|
|
55
|
+
try {
|
|
56
|
+
const json = await apiFetch('/replication/source-delete/' + path, { method: 'DELETE' });
|
|
57
|
+
const ms = performance.now() - t0;
|
|
58
|
+
showStatus('repl-write-status', json.ok ? `Deleted on source: ${path}` : (json.error as string || 'Failed'), !!json.ok, ms);
|
|
59
|
+
if (json.ok) setTimeout(loadRepl, 600);
|
|
60
|
+
} catch (e: unknown) { showStatus('repl-write-status', (e as Error).message, false, performance.now() - t0); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
65
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
66
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Source Feed Status — paths synced from remote BodDB instances</label>
|
|
67
|
+
<div className="row" style={{ marginBottom: 6 }}><button onClick={loadRepl}>REFRESH</button></div>
|
|
68
|
+
<div id="repl-status" style={{ marginTop: 4 }}></div>
|
|
69
|
+
<div className="result" id="repl-config" style={{ marginTop: 4, display: 'none', maxHeight: 300 }}>—</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
72
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Synced Data — local copy of source paths (read-only mirror)</label>
|
|
73
|
+
<div className="result" id="repl-synced" style={{ maxHeight: 400, display: 'none' }}>—</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
76
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Write to Source — modify remote data and watch it sync locally</label>
|
|
77
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
78
|
+
<button className="sm" onClick={() => fillReplWrite('catalog/widgets', '{"name":"Widget A","price":34.99,"stock":200}')}>update widget price</button>
|
|
79
|
+
<button className="sm" onClick={() => fillReplWrite('catalog/new-item', '{"name":"New Item","price":9.99,"stock":50}')}>add item</button>
|
|
80
|
+
<button className="sm" onClick={() => fillReplWrite('alerts/sys-3', `{"level":"error","msg":"Disk full","ts":${Date.now()}}`)}>add alert</button>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="row">
|
|
83
|
+
<input id="repl-write-path" type="text" placeholder="catalog/widgets" defaultValue="catalog/widgets" style={{ flex: 2 }} />
|
|
84
|
+
</div>
|
|
85
|
+
<textarea id="repl-write-value" style={{ minHeight: 40, marginTop: 4 }} defaultValue='{ "name": "Widget A", "price": 34.99, "stock": 200 }'></textarea>
|
|
86
|
+
<div className="row" style={{ marginTop: 4 }}>
|
|
87
|
+
<button className="success" onClick={doReplWrite}>WRITE TO SOURCE</button>
|
|
88
|
+
<button className="danger" onClick={doReplDelete}>DELETE ON SOURCE</button>
|
|
89
|
+
</div>
|
|
90
|
+
<div id="repl-write-status" style={{ marginTop: 4 }}></div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|