@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,222 @@
|
|
|
1
|
+
import { db } from '../../context/DbContext';
|
|
2
|
+
import { showStatus, refreshTreePath } from './utils';
|
|
3
|
+
|
|
4
|
+
export default function Advanced() {
|
|
5
|
+
// Transforms
|
|
6
|
+
function fillTransform(path: string, type: string, value: string) {
|
|
7
|
+
(document.getElementById('tf-path') as HTMLInputElement).value = path;
|
|
8
|
+
(document.getElementById('tf-type') as HTMLSelectElement).value = type;
|
|
9
|
+
(document.getElementById('tf-value') as HTMLInputElement).value = value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function doTransform() {
|
|
13
|
+
const path = (document.getElementById('tf-path') as HTMLInputElement).value.trim();
|
|
14
|
+
const type = (document.getElementById('tf-type') as HTMLSelectElement).value;
|
|
15
|
+
const rawVal = (document.getElementById('tf-value') as HTMLInputElement).value.trim();
|
|
16
|
+
let value: unknown;
|
|
17
|
+
if (type === 'serverTimestamp') { value = null; }
|
|
18
|
+
else if (type === 'increment') { value = Number(rawVal) || 0; }
|
|
19
|
+
else { try { value = JSON.parse(rawVal); } catch { return showStatus('tf-status', 'Invalid JSON value', false); } }
|
|
20
|
+
const t0 = performance.now();
|
|
21
|
+
try {
|
|
22
|
+
const result = await db.transform(path, type, value);
|
|
23
|
+
showStatus('tf-status', `${type} applied`, true, performance.now() - t0);
|
|
24
|
+
const el = document.getElementById('tf-result') as HTMLElement;
|
|
25
|
+
el.textContent = JSON.stringify(result, null, 2); el.style.display = 'block';
|
|
26
|
+
refreshTreePath(path);
|
|
27
|
+
} catch (e: unknown) { showStatus('tf-status', (e as Error).message, false, performance.now() - t0); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// TTL
|
|
31
|
+
function fillTTL(path: string, value: string, seconds: number) {
|
|
32
|
+
(document.getElementById('ttl-path') as HTMLInputElement).value = path;
|
|
33
|
+
(document.getElementById('ttl-value') as HTMLTextAreaElement).value = value;
|
|
34
|
+
(document.getElementById('ttl-seconds') as HTMLInputElement).value = String(seconds);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function doSetTTL() {
|
|
38
|
+
const path = (document.getElementById('ttl-path') as HTMLInputElement).value.trim();
|
|
39
|
+
const ttl = Number((document.getElementById('ttl-seconds') as HTMLInputElement).value) || 60;
|
|
40
|
+
const raw = (document.getElementById('ttl-value') as HTMLTextAreaElement).value.trim();
|
|
41
|
+
let value;
|
|
42
|
+
try { value = JSON.parse(raw); } catch { return showStatus('ttl-status', 'Invalid JSON', false); }
|
|
43
|
+
const t0 = performance.now();
|
|
44
|
+
try {
|
|
45
|
+
await db.setTTL(path, value, ttl);
|
|
46
|
+
showStatus('ttl-status', `Set with TTL=${ttl}s`, true, performance.now() - t0);
|
|
47
|
+
refreshTreePath(path);
|
|
48
|
+
} catch (e: unknown) { showStatus('ttl-status', (e as Error).message, false, performance.now() - t0); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function doSweep() {
|
|
52
|
+
const t0 = performance.now();
|
|
53
|
+
try {
|
|
54
|
+
const expired = await db.sweep() as unknown[];
|
|
55
|
+
showStatus('ttl-status', `Swept ${expired?.length ?? 0} entries`, true, performance.now() - t0);
|
|
56
|
+
} catch (e: unknown) { showStatus('ttl-status', (e as Error).message, false, performance.now() - t0); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// FTS
|
|
60
|
+
function fillFts(query: string, prefix: string) {
|
|
61
|
+
(document.getElementById('fts-query') as HTMLInputElement).value = query;
|
|
62
|
+
(document.getElementById('fts-prefix') as HTMLInputElement).value = prefix || '';
|
|
63
|
+
}
|
|
64
|
+
function fillFtsIndex(path: string, content: string) {
|
|
65
|
+
(document.getElementById('fts-path') as HTMLInputElement).value = path;
|
|
66
|
+
(document.getElementById('fts-content') as HTMLInputElement).value = content;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function doFtsIndex() {
|
|
70
|
+
const path = (document.getElementById('fts-path') as HTMLInputElement).value.trim();
|
|
71
|
+
const content = (document.getElementById('fts-content') as HTMLInputElement).value.trim();
|
|
72
|
+
if (!path || !content) return showStatus('fts-status', 'Path and content required', false);
|
|
73
|
+
const t0 = performance.now();
|
|
74
|
+
try {
|
|
75
|
+
await db.ftsIndex(path, content);
|
|
76
|
+
showStatus('fts-status', 'Indexed', true, performance.now() - t0);
|
|
77
|
+
} catch (e: unknown) { showStatus('fts-status', (e as Error).message, false, performance.now() - t0); }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function doFtsSearch() {
|
|
81
|
+
const text = (document.getElementById('fts-query') as HTMLInputElement).value.trim();
|
|
82
|
+
const pathPrefix = (document.getElementById('fts-prefix') as HTMLInputElement).value.trim() || undefined;
|
|
83
|
+
if (!text) return showStatus('fts-status', 'Enter a search query', false);
|
|
84
|
+
const t0 = performance.now();
|
|
85
|
+
try {
|
|
86
|
+
const results = await db.ftsSearch(text, pathPrefix) as unknown[];
|
|
87
|
+
const el = document.getElementById('fts-result') as HTMLElement;
|
|
88
|
+
showStatus('fts-status', `${results.length} results`, true, performance.now() - t0);
|
|
89
|
+
el.textContent = JSON.stringify(results, null, 2); el.style.display = 'block';
|
|
90
|
+
} catch (e: unknown) { showStatus('fts-status', (e as Error).message, false, performance.now() - t0); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Vectors
|
|
94
|
+
function fillVecSearch(prefix: string) {
|
|
95
|
+
const q = JSON.stringify(Array.from({ length: 384 }, (_, i) => +(Math.sin(i * 0.1) * 0.5).toFixed(4)));
|
|
96
|
+
(document.getElementById('vec-query') as HTMLInputElement).value = q;
|
|
97
|
+
(document.getElementById('vec-prefix') as HTMLInputElement).value = prefix || '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function doVecStore() {
|
|
101
|
+
const path = (document.getElementById('vec-path') as HTMLInputElement).value.trim();
|
|
102
|
+
const raw = (document.getElementById('vec-embedding') as HTMLInputElement).value.trim();
|
|
103
|
+
let embedding;
|
|
104
|
+
try { embedding = JSON.parse(raw); } catch { return showStatus('vec-status', 'Invalid JSON array', false); }
|
|
105
|
+
if (!Array.isArray(embedding)) return showStatus('vec-status', 'Embedding must be an array', false);
|
|
106
|
+
const t0 = performance.now();
|
|
107
|
+
try {
|
|
108
|
+
await db.vecStore(path, embedding);
|
|
109
|
+
showStatus('vec-status', `Stored (${embedding.length}d)`, true, performance.now() - t0);
|
|
110
|
+
} catch (e: unknown) { showStatus('vec-status', (e as Error).message, false, performance.now() - t0); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function doVecSearch() {
|
|
114
|
+
const raw = (document.getElementById('vec-query') as HTMLInputElement).value.trim();
|
|
115
|
+
const pathPrefix = (document.getElementById('vec-prefix') as HTMLInputElement).value.trim() || undefined;
|
|
116
|
+
const limit = Number((document.getElementById('vec-limit') as HTMLInputElement).value) || 5;
|
|
117
|
+
let query;
|
|
118
|
+
try { query = JSON.parse(raw); } catch { return showStatus('vec-status', 'Invalid JSON array', false); }
|
|
119
|
+
const t0 = performance.now();
|
|
120
|
+
try {
|
|
121
|
+
const results = await db.vecSearch(query, pathPrefix, limit) as unknown[];
|
|
122
|
+
const el = document.getElementById('vec-result') as HTMLElement;
|
|
123
|
+
showStatus('vec-status', `${results.length} results`, true, performance.now() - t0);
|
|
124
|
+
el.textContent = JSON.stringify(results, null, 2); el.style.display = 'block';
|
|
125
|
+
} catch (e: unknown) { showStatus('vec-status', (e as Error).message, false, performance.now() - t0); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
130
|
+
{/* Transforms */}
|
|
131
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
132
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Transforms — apply atomic operations to paths</label>
|
|
133
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
134
|
+
<button className="sm" onClick={() => fillTransform('counters/likes', 'increment', '1')}>increment +1</button>
|
|
135
|
+
<button className="sm" onClick={() => fillTransform('users/alice/updatedAt', 'serverTimestamp', '')}>serverTimestamp</button>
|
|
136
|
+
<button className="sm" onClick={() => fillTransform('users/alice/tags', 'arrayUnion', '["vip","active"]')}>arrayUnion</button>
|
|
137
|
+
<button className="sm" onClick={() => fillTransform('users/alice/tags', 'arrayRemove', '["active"]')}>arrayRemove</button>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="row">
|
|
140
|
+
<input id="tf-path" type="text" placeholder="counters/likes" style={{ flex: 2 }} />
|
|
141
|
+
<select id="tf-type" style={{ flex: 1 }}>
|
|
142
|
+
<option value="increment">increment</option>
|
|
143
|
+
<option value="serverTimestamp">serverTimestamp</option>
|
|
144
|
+
<option value="arrayUnion">arrayUnion</option>
|
|
145
|
+
<option value="arrayRemove">arrayRemove</option>
|
|
146
|
+
</select>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="row" style={{ marginTop: 4 }}>
|
|
149
|
+
<input id="tf-value" type="text" placeholder="value" style={{ flex: 1 }} />
|
|
150
|
+
<button className="success" onClick={doTransform}>APPLY</button>
|
|
151
|
+
</div>
|
|
152
|
+
<div id="tf-status" style={{ marginTop: 4 }}></div>
|
|
153
|
+
<div className="result" id="tf-result" style={{ marginTop: 4, display: 'none' }}>—</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{/* TTL */}
|
|
157
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
158
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>TTL — set values with auto-expiry</label>
|
|
159
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
160
|
+
<button className="sm" onClick={() => fillTTL('sessions/temp', '{"token":"abc123"}', 60)}>session 60s</button>
|
|
161
|
+
<button className="sm" onClick={() => fillTTL('cache/homepage', '"cached html"', 10)}>cache 10s</button>
|
|
162
|
+
</div>
|
|
163
|
+
<div className="row">
|
|
164
|
+
<input id="ttl-path" type="text" placeholder="sessions/temp" style={{ flex: 2 }} />
|
|
165
|
+
<input id="ttl-seconds" type="number" placeholder="TTL (seconds)" defaultValue="60" style={{ width: 120 }} />
|
|
166
|
+
</div>
|
|
167
|
+
<div style={{ marginTop: 4 }}>
|
|
168
|
+
<textarea id="ttl-value" style={{ minHeight: 40 }} placeholder='"session data" or { "token": "abc" }'></textarea>
|
|
169
|
+
</div>
|
|
170
|
+
<div className="row" style={{ marginTop: 4 }}>
|
|
171
|
+
<button className="success" onClick={doSetTTL}>SET WITH TTL</button>
|
|
172
|
+
<button onClick={doSweep}>SWEEP NOW</button>
|
|
173
|
+
</div>
|
|
174
|
+
<div id="ttl-status" style={{ marginTop: 4 }}></div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* FTS */}
|
|
178
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
179
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Full-Text Search (FTS5)</label>
|
|
180
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
181
|
+
<button className="sm" onClick={() => fillFts('admin', 'users')}>search "admin"</button>
|
|
182
|
+
<button className="sm" onClick={() => fillFts('design', '')}>search "design"</button>
|
|
183
|
+
<button className="sm" onClick={() => fillFtsIndex('posts/p1', 'A tutorial about building reactive databases')}>index post</button>
|
|
184
|
+
</div>
|
|
185
|
+
<div className="row">
|
|
186
|
+
<input id="fts-path" type="text" placeholder="path to index" style={{ flex: 1 }} />
|
|
187
|
+
<input id="fts-content" type="text" placeholder="text content to index" style={{ flex: 2 }} />
|
|
188
|
+
<button onClick={doFtsIndex}>INDEX</button>
|
|
189
|
+
</div>
|
|
190
|
+
<div className="row" style={{ marginTop: 4 }}>
|
|
191
|
+
<input id="fts-query" type="text" placeholder="search query" style={{ flex: 2 }} />
|
|
192
|
+
<input id="fts-prefix" type="text" placeholder="path prefix (optional)" style={{ flex: 1 }} />
|
|
193
|
+
<button className="success" onClick={doFtsSearch}>SEARCH</button>
|
|
194
|
+
</div>
|
|
195
|
+
<div id="fts-status" style={{ marginTop: 4 }}></div>
|
|
196
|
+
<div className="result" id="fts-result" style={{ marginTop: 4, display: 'none' }}>—</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* Vectors */}
|
|
200
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
201
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Vector Search (cosine similarity, 384d)</label>
|
|
202
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
203
|
+
<button className="sm" onClick={() => fillVecSearch('users')}>search users</button>
|
|
204
|
+
<button className="sm" onClick={() => fillVecSearch('')}>search all</button>
|
|
205
|
+
</div>
|
|
206
|
+
<div className="row">
|
|
207
|
+
<input id="vec-path" type="text" placeholder="path to store embedding" style={{ flex: 1 }} />
|
|
208
|
+
<input id="vec-embedding" type="text" placeholder="[0.1, 0.2, ...] (384 floats)" style={{ flex: 2 }} />
|
|
209
|
+
<button onClick={doVecStore}>STORE</button>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="row" style={{ marginTop: 4 }}>
|
|
212
|
+
<input id="vec-query" type="text" placeholder="query vector [0.1, 0.2, ...]" style={{ flex: 2 }} />
|
|
213
|
+
<input id="vec-prefix" type="text" placeholder="path prefix" style={{ flex: 1 }} />
|
|
214
|
+
<input id="vec-limit" type="number" defaultValue="5" style={{ width: 60 }} placeholder="limit" />
|
|
215
|
+
<button className="success" onClick={doVecSearch}>SEARCH</button>
|
|
216
|
+
</div>
|
|
217
|
+
<div id="vec-status" style={{ marginTop: 4 }}></div>
|
|
218
|
+
<div className="result" id="vec-result" style={{ marginTop: 4, display: 'none' }}>—</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { db } from '../../context/DbContext';
|
|
3
|
+
import { escHtml } from './utils';
|
|
4
|
+
|
|
5
|
+
interface Props { active: boolean; }
|
|
6
|
+
|
|
7
|
+
export default function AuthRules({ active }: Props) {
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (active) loadRules();
|
|
10
|
+
}, [active]);
|
|
11
|
+
|
|
12
|
+
async function loadRules() {
|
|
13
|
+
const el = document.getElementById('auth-rules') as HTMLElement;
|
|
14
|
+
try {
|
|
15
|
+
const rules = await db.getRules() as Array<{ pattern: string; read: unknown; write: unknown }>;
|
|
16
|
+
el.textContent = (rules as Array<{ pattern: string; read: unknown; write: unknown }>).map(r => {
|
|
17
|
+
const parts = [];
|
|
18
|
+
if (r.read !== null) parts.push(`read: ${r.read}`);
|
|
19
|
+
if (r.write !== null) parts.push(`write: ${r.write}`);
|
|
20
|
+
return `${r.pattern.padEnd(20)} → ${parts.join(', ')}`;
|
|
21
|
+
}).join('\n') || '(no rules configured)';
|
|
22
|
+
} catch { el.textContent = 'Failed to load rules'; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function applyAuth(token: string) {
|
|
26
|
+
const statusEl = document.getElementById('auth-status') as HTMLElement;
|
|
27
|
+
const rwStatusEl = document.getElementById('rw-auth-status') as HTMLElement;
|
|
28
|
+
if (!token) {
|
|
29
|
+
db.disconnect(); db.reconnect();
|
|
30
|
+
if (statusEl) statusEl.innerHTML = '<span style="color:#555">Not authenticated — all open rules apply</span>';
|
|
31
|
+
if (rwStatusEl) rwStatusEl.innerHTML = '<span style="color:#555">none</span>';
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const ctx = await db._send('auth', { token });
|
|
36
|
+
const s = escHtml(JSON.stringify(ctx));
|
|
37
|
+
if (statusEl) statusEl.innerHTML = `<span style="color:#4ec9b0">Authenticated</span> — ${s}`;
|
|
38
|
+
if (rwStatusEl) rwStatusEl.innerHTML = `<span style="color:#4ec9b0">${s}</span>`;
|
|
39
|
+
} catch (e: unknown) {
|
|
40
|
+
const msg = (e as Error).message;
|
|
41
|
+
if (statusEl) statusEl.innerHTML = `<span style="color:#f48771">Auth failed: ${escHtml(msg)}</span>`;
|
|
42
|
+
if (rwStatusEl) rwStatusEl.innerHTML = `<span style="color:#f48771">failed</span>`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function doAuth() {
|
|
47
|
+
const token = (document.getElementById('auth-token') as HTMLInputElement).value.trim();
|
|
48
|
+
await applyAuth(token);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function doDeauth() {
|
|
52
|
+
(document.getElementById('auth-token') as HTMLInputElement).value = '';
|
|
53
|
+
(document.getElementById('rw-auth-token') as HTMLInputElement).value = '';
|
|
54
|
+
applyAuth('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function doTestRule() {
|
|
58
|
+
const path = (document.getElementById('auth-test-path') as HTMLInputElement).value.trim();
|
|
59
|
+
const op = (document.getElementById('auth-test-op') as HTMLSelectElement).value;
|
|
60
|
+
const resultEl = document.getElementById('auth-test-result') as HTMLElement;
|
|
61
|
+
if (!path) return;
|
|
62
|
+
try {
|
|
63
|
+
if (op === 'read') {
|
|
64
|
+
await db.get(path);
|
|
65
|
+
resultEl.innerHTML = `<span style="color:#4ec9b0">ALLOWED</span> — read ${escHtml(path)}`;
|
|
66
|
+
} else {
|
|
67
|
+
const existing = await db.get(path);
|
|
68
|
+
await db.set(path, existing);
|
|
69
|
+
resultEl.innerHTML = `<span style="color:#4ec9b0">ALLOWED</span> — write ${escHtml(path)}`;
|
|
70
|
+
}
|
|
71
|
+
} catch (e: unknown) {
|
|
72
|
+
const msg = (e as Error).message;
|
|
73
|
+
const denied = msg?.includes('Permission denied') || msg?.includes('denied');
|
|
74
|
+
resultEl.innerHTML = `<span style="color:${denied ? '#f48771' : '#ce9178'}">${denied ? 'DENIED' : 'ERROR'}</span> — ${escHtml(msg)}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
80
|
+
<div>
|
|
81
|
+
<label>Authenticate</label>
|
|
82
|
+
<div className="row">
|
|
83
|
+
<input id="auth-token" type="text" placeholder="admin-secret or user:alice" style={{ flex: 1 }} />
|
|
84
|
+
<button onClick={doAuth}>AUTH</button>
|
|
85
|
+
<button className="danger" onClick={doDeauth}>CLEAR</button>
|
|
86
|
+
</div>
|
|
87
|
+
<div id="auth-status" style={{ marginTop: 4, fontSize: 12, color: '#555' }}>Not authenticated — all open rules apply</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<label>Active Rules (server-configured)</label>
|
|
91
|
+
<div className="result" style={{ fontSize: 11, lineHeight: 1.6, whiteSpace: 'pre' }} id="auth-rules">Loading…</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div>
|
|
94
|
+
<label>Test Rule — check if current auth can read/write a path</label>
|
|
95
|
+
<div className="row">
|
|
96
|
+
<input id="auth-test-path" type="text" placeholder="users/alice" style={{ flex: 1 }} />
|
|
97
|
+
<select id="auth-test-op" style={{ width: 90 }}><option value="read">READ</option><option value="write">WRITE</option></select>
|
|
98
|
+
<button onClick={doTestRule}>TEST</button>
|
|
99
|
+
</div>
|
|
100
|
+
<div className="result" id="auth-test-result">—</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import { db } from '../../context/DbContext';
|
|
3
|
+
|
|
4
|
+
interface CacheEntry { data: unknown; updatedAt?: number; }
|
|
5
|
+
|
|
6
|
+
export default function Cache() {
|
|
7
|
+
const memoryCache = useRef(new Map<string, CacheEntry>());
|
|
8
|
+
const subOffRef = useRef<(() => void) | null>(null);
|
|
9
|
+
const logRef = useRef<string[]>([]);
|
|
10
|
+
|
|
11
|
+
function cacheLog(msg: string) {
|
|
12
|
+
logRef.current.push(`[${new Date().toLocaleTimeString()}] ${msg}`);
|
|
13
|
+
if (logRef.current.length > 20) logRef.current.shift();
|
|
14
|
+
const el = document.getElementById('cache-log') as HTMLElement;
|
|
15
|
+
if (el) el.textContent = logRef.current.join('\n');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function cacheSet() {
|
|
19
|
+
const path = (document.getElementById('cache-path') as HTMLInputElement).value.trim();
|
|
20
|
+
const val = JSON.parse((document.getElementById('cache-value') as HTMLInputElement).value);
|
|
21
|
+
await db.set(path, val);
|
|
22
|
+
memoryCache.current.delete(path);
|
|
23
|
+
cacheLog(`SET ${path} → invalidated cache`);
|
|
24
|
+
(document.getElementById('cache-result') as HTMLElement).textContent = `✓ Set ${path} and invalidated cache`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function cacheGet() {
|
|
28
|
+
const path = (document.getElementById('cache-path') as HTMLInputElement).value.trim();
|
|
29
|
+
const t0 = performance.now();
|
|
30
|
+
const cached = memoryCache.current.get(path);
|
|
31
|
+
if (cached) {
|
|
32
|
+
const ms = (performance.now() - t0).toFixed(2);
|
|
33
|
+
cacheLog(`HIT ${path} (${ms}ms) — served from memory`);
|
|
34
|
+
(document.getElementById('cache-result') as HTMLElement).textContent =
|
|
35
|
+
`[CACHE HIT · ${ms}ms]\n` + JSON.stringify(cached.data, null, 2) +
|
|
36
|
+
(cached.updatedAt ? `\n\nupdatedAt: ${new Date(cached.updatedAt).toLocaleString()}` : '');
|
|
37
|
+
db.getSnapshot(path).then(snap => {
|
|
38
|
+
memoryCache.current.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
39
|
+
cacheLog(`REVALIDATED ${path}`);
|
|
40
|
+
}).catch(() => {});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const snap = await db.getSnapshot(path);
|
|
44
|
+
const ms = (performance.now() - t0).toFixed(2);
|
|
45
|
+
memoryCache.current.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
46
|
+
cacheLog(`MISS ${path} (${ms}ms) — fetched from network, cached`);
|
|
47
|
+
(document.getElementById('cache-result') as HTMLElement).textContent =
|
|
48
|
+
`[CACHE MISS · ${ms}ms]\n` + JSON.stringify(snap.val(), null, 2) +
|
|
49
|
+
(snap.updatedAt ? `\n\nupdatedAt: ${new Date(snap.updatedAt).toLocaleString()}` : '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function cacheGetFresh() {
|
|
53
|
+
const path = (document.getElementById('cache-path') as HTMLInputElement).value.trim();
|
|
54
|
+
const t0 = performance.now();
|
|
55
|
+
const snap = await db.getSnapshot(path);
|
|
56
|
+
const ms = (performance.now() - t0).toFixed(2);
|
|
57
|
+
memoryCache.current.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
58
|
+
cacheLog(`FRESH ${path} (${ms}ms) — network fetch, cache updated`);
|
|
59
|
+
(document.getElementById('cache-result') as HTMLElement).textContent =
|
|
60
|
+
`[NETWORK · ${ms}ms]\n` + JSON.stringify(snap.val(), null, 2) +
|
|
61
|
+
(snap.updatedAt ? `\n\nupdatedAt: ${new Date(snap.updatedAt).toLocaleString()}` : '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cacheSub() {
|
|
65
|
+
if (subOffRef.current) { cacheLog('Already subscribed'); return; }
|
|
66
|
+
const path = (document.getElementById('cache-path') as HTMLInputElement).value.trim();
|
|
67
|
+
subOffRef.current = db.on(path, (snap) => {
|
|
68
|
+
memoryCache.current.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
69
|
+
cacheLog(`SUB UPDATE ${path} — cache refreshed`);
|
|
70
|
+
(document.getElementById('cache-result') as HTMLElement).textContent =
|
|
71
|
+
`[LIVE · subscribed]\n` + JSON.stringify(snap.val(), null, 2) +
|
|
72
|
+
(snap.updatedAt ? `\n\nupdatedAt: ${new Date(snap.updatedAt).toLocaleString()}` : '');
|
|
73
|
+
});
|
|
74
|
+
cacheLog(`SUBSCRIBED to ${path} — cache stays fresh`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cacheUnsub() {
|
|
78
|
+
if (subOffRef.current) { subOffRef.current(); subOffRef.current = null; cacheLog('Unsubscribed'); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cacheStats() {
|
|
82
|
+
const stats = { memoryEntries: memoryCache.current.size, paths: [...memoryCache.current.keys()], subscribed: !!subOffRef.current };
|
|
83
|
+
(document.getElementById('cache-result') as HTMLElement).textContent = JSON.stringify(stats, null, 2);
|
|
84
|
+
cacheLog(`Stats: ${memoryCache.current.size} entries in memory`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
<h3 style={{ color: '#569cd6', marginBottom: 8 }}>BodClientCached Demo</h3>
|
|
90
|
+
<p style={{ color: '#666', fontSize: 11, marginBottom: 10 }}>Two-tier cache (memory + IndexedDB) with stale-while-revalidate. Wraps BodClient for instant reads.</p>
|
|
91
|
+
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
|
92
|
+
<div style={{ flex: 1 }}>
|
|
93
|
+
<label>Path</label>
|
|
94
|
+
<input type="text" id="cache-path" defaultValue="cache/demo" placeholder="path" />
|
|
95
|
+
</div>
|
|
96
|
+
<div style={{ flex: 1 }}>
|
|
97
|
+
<label>Value (JSON)</label>
|
|
98
|
+
<input type="text" id="cache-value" defaultValue='{"name":"Alice","score":42}' placeholder="value" />
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap' }}>
|
|
102
|
+
<button onClick={cacheSet}>Set (invalidates cache)</button>
|
|
103
|
+
<button onClick={cacheGet}>Get (cache-first)</button>
|
|
104
|
+
<button onClick={cacheGetFresh}>Get Fresh (network)</button>
|
|
105
|
+
<button className="success" onClick={cacheSub}>Subscribe (keeps fresh)</button>
|
|
106
|
+
<button className="danger" onClick={cacheUnsub}>Unsubscribe</button>
|
|
107
|
+
<button onClick={cacheStats}>Cache Stats</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div id="cache-result" className="result" style={{ minHeight: 120 }}>Cache demo — set a value, then get it (served from cache on second read).</div>
|
|
110
|
+
<div id="cache-log" className="result" style={{ minHeight: 80, marginTop: 8, color: '#888', fontSize: 11 }}>Event log...</div>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|