@bod.ee/db 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/.claude/skills/config-file.md +54 -0
  3. package/.claude/skills/deploying-bod-db.md +29 -0
  4. package/.claude/skills/developing-bod-db.md +127 -0
  5. package/.claude/skills/using-bod-db.md +403 -0
  6. package/CLAUDE.md +110 -0
  7. package/README.md +252 -0
  8. package/admin/rules.ts +12 -0
  9. package/admin/server.ts +523 -0
  10. package/admin/ui.html +2281 -0
  11. package/cli.ts +177 -0
  12. package/client.ts +2 -0
  13. package/config.ts +20 -0
  14. package/deploy/.env.example +1 -0
  15. package/deploy/base.yaml +18 -0
  16. package/deploy/boddb-logs.yaml +10 -0
  17. package/deploy/boddb.yaml +10 -0
  18. package/deploy/demo.html +196 -0
  19. package/deploy/deploy.ts +32 -0
  20. package/deploy/prod-logs.config.ts +15 -0
  21. package/deploy/prod.config.ts +15 -0
  22. package/index.ts +20 -0
  23. package/mcp.ts +78 -0
  24. package/package.json +29 -0
  25. package/react.ts +1 -0
  26. package/src/client/BodClient.ts +515 -0
  27. package/src/react/hooks.ts +121 -0
  28. package/src/server/BodDB.ts +319 -0
  29. package/src/server/ExpressionRules.ts +250 -0
  30. package/src/server/FTSEngine.ts +76 -0
  31. package/src/server/FileAdapter.ts +116 -0
  32. package/src/server/MCPAdapter.ts +409 -0
  33. package/src/server/MQEngine.ts +286 -0
  34. package/src/server/QueryEngine.ts +45 -0
  35. package/src/server/RulesEngine.ts +108 -0
  36. package/src/server/StorageEngine.ts +464 -0
  37. package/src/server/StreamEngine.ts +320 -0
  38. package/src/server/SubscriptionEngine.ts +120 -0
  39. package/src/server/Transport.ts +479 -0
  40. package/src/server/VectorEngine.ts +115 -0
  41. package/src/shared/errors.ts +15 -0
  42. package/src/shared/pathUtils.ts +94 -0
  43. package/src/shared/protocol.ts +59 -0
  44. package/src/shared/transforms.ts +99 -0
  45. package/tests/batch.test.ts +60 -0
  46. package/tests/bench.ts +205 -0
  47. package/tests/e2e.test.ts +284 -0
  48. package/tests/expression-rules.test.ts +114 -0
  49. package/tests/file-adapter.test.ts +57 -0
  50. package/tests/fts.test.ts +58 -0
  51. package/tests/mq-flow.test.ts +204 -0
  52. package/tests/mq.test.ts +326 -0
  53. package/tests/push.test.ts +55 -0
  54. package/tests/query.test.ts +60 -0
  55. package/tests/rules.test.ts +78 -0
  56. package/tests/sse.test.ts +78 -0
  57. package/tests/storage.test.ts +199 -0
  58. package/tests/stream.test.ts +385 -0
  59. package/tests/stress.test.ts +202 -0
  60. package/tests/subscriptions.test.ts +86 -0
  61. package/tests/transforms.test.ts +92 -0
  62. package/tests/transport.test.ts +209 -0
  63. package/tests/ttl.test.ts +70 -0
  64. package/tests/vector.test.ts +69 -0
  65. package/tsconfig.json +27 -0
package/admin/ui.html ADDED
@@ -0,0 +1,2281 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>ZuzDB Admin</title>
6
+ <style>
7
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
8
+ body { font-family: monospace; font-size: 13px; background: #0d0d0d; color: #d4d4d4; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
9
+
10
+ /* Metrics bar */
11
+ #metrics-bar { display: flex; background: #0a0a0a; border-bottom: 1px solid #2a2a2a; flex-shrink: 0; overflow-x: auto; align-items: stretch; }
12
+ .metric-card { display: flex; flex-direction: column; padding: 5px 10px 4px; border-right: 1px solid #181818; min-width: 110px; flex-shrink: 0; gap: 1px; }
13
+ .metric-card:last-child { border-right: none; margin-left: auto; min-width: auto; }
14
+ .metric-top { display: flex; justify-content: space-between; align-items: baseline; }
15
+ .metric-label { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; }
16
+ .metric-value { font-size: 13px; color: #4ec9b0; font-weight: bold; }
17
+ .metric-value.warn { color: #ce9178; }
18
+ .metric-value.dim { color: #888; }
19
+ .metric-canvas { display: block; margin-top: 2px; }
20
+ #ws-dot { width: 7px; height: 7px; border-radius: 50%; background: #555; display: inline-block; margin-left: 4px; vertical-align: middle; }
21
+ #ws-dot.live { background: #4ec9b0; animation: pulse 1.5s infinite; }
22
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
23
+
24
+ /* Main layout */
25
+ #main { display: flex; flex: 1; overflow: hidden; }
26
+
27
+ /* Tree */
28
+ #tree-pane { width: 28%; border-right: 1px solid #2a2a2a; display: flex; flex-direction: column; min-width: 180px; }
29
+ #tree-header { padding: 6px 10px; background: #161616; border-bottom: 1px solid #2a2a2a; display: flex; justify-content: space-between; align-items: center; }
30
+ #tree-header span { color: #555; font-size: 11px; }
31
+ #tree-container { flex: 1; overflow-y: auto; padding: 4px; }
32
+ #tree-container details { margin-left: 12px; }
33
+ #tree-container summary { cursor: pointer; padding: 2px 4px; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #4ec9b0; list-style: none; }
34
+ #tree-container summary::before { content: 'โ–ถ'; font-size: 10px; color: #666; margin-right: 5px; display: inline-block; transition: transform 0.15s; }
35
+ details[open] > summary::before { transform: rotate(90deg); color: #aaa; }
36
+ #tree-container summary:hover { background: #1e1e1e; }
37
+ .tree-leaf { padding: 2px 4px 2px 16px; cursor: pointer; border-radius: 3px; color: #9cdcfe; display: flex; gap: 4px; align-items: baseline; overflow: hidden; }
38
+ .tree-leaf:hover { background: #1e1e1e; }
39
+ .tree-val { color: #ce9178; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
40
+ .tree-key { color: #4ec9b0; flex-shrink: 0; }
41
+ .ttl-badge { font-size: 9px; padding: 0 4px; border-radius: 3px; background: #4d3519; color: #d4a054; flex-shrink: 0; }
42
+ @keyframes treeFlash { 0%,100% { background: transparent; } 30% { background: rgba(86,156,214,0.25); } }
43
+ .flash { animation: treeFlash 1.2s ease-out; border-radius: 3px; }
44
+
45
+ /* Right pane */
46
+ #right-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
47
+ .tabs { display: flex; background: #161616; border-bottom: 1px solid #2a2a2a; }
48
+ .tab { padding: 7px 16px; cursor: pointer; border-bottom: 2px solid transparent; color: #666; font-size: 12px; }
49
+ .tab.active { color: #fff; border-bottom-color: #569cd6; }
50
+ .panel { display: none; flex: 1; overflow-y: auto; padding: 12px; flex-direction: column; gap: 10px; }
51
+ .panel.active { display: flex; }
52
+
53
+ /* Controls */
54
+ label { color: #666; font-size: 10px; margin-bottom: 2px; display: block; text-transform: uppercase; letter-spacing: 0.3px; }
55
+ 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; }
56
+ input[type=text]:focus, textarea:focus { outline: none; border-color: #569cd6; }
57
+ textarea { resize: vertical; min-height: 80px; }
58
+ .row { display: flex; gap: 6px; align-items: flex-end; }
59
+ .row input { flex: 1; }
60
+ button { padding: 5px 11px; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 12px; background: #264f78; color: #ccc; white-space: nowrap; }
61
+ button:hover { background: #3a6fa8; color: #fff; }
62
+ button.danger { background: #4a1e1e; color: #f48771; }
63
+ button.danger:hover { background: #7a2e2e; }
64
+ button.success { background: #1e4a1e; color: #4ec9b0; }
65
+ button.success:hover { background: #2e7a2e; }
66
+ button.sub { background: #3a2e5a; color: #c5b8f0; }
67
+ button.sub:hover { background: #5a4a8a; }
68
+ button.sm { padding: 3px 7px; font-size: 11px; }
69
+ .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; }
70
+ .status { font-size: 11px; padding: 3px 8px; border-radius: 3px; }
71
+ .status.ok { background: #1a3a1a; color: #4ec9b0; }
72
+ .status.err { background: #3a1a1a; color: #f48771; }
73
+
74
+ /* Query filters */
75
+ .filter-row { display: flex; gap: 5px; align-items: center; margin-bottom: 4px; }
76
+ .filter-row input { flex: 2; }
77
+ .filter-row select { flex: 1; }
78
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
79
+ th { background: #1a1a1a; padding: 4px 8px; text-align: left; color: #666; border-bottom: 1px solid #222; }
80
+ td { padding: 4px 8px; border-bottom: 1px solid #1a1a1a; color: #d4d4d4; word-break: break-all; }
81
+ tr:hover td { background: #161616; }
82
+
83
+ /* Subscriptions */
84
+ .sub-item { background: #111; border: 1px solid #222; border-radius: 3px; padding: 8px; }
85
+ .sub-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; color: #4ec9b0; }
86
+ .sub-log { max-height: 130px; overflow-y: auto; font-size: 11px; background: #0a0a0a; padding: 5px; border-radius: 2px; }
87
+ .sub-log div { padding: 2px 0; border-bottom: 1px solid #161616; color: #9cdcfe; }
88
+ .sub-log div span { color: #555; margin-right: 6px; }
89
+
90
+ /* Stress test */
91
+ .stress-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
92
+ .stress-card { background: #111; border: 1px solid #222; border-radius: 3px; padding: 10px; }
93
+ .stress-card h4 { color: #888; font-size: 11px; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.5px; }
94
+ .stress-result { margin-top: 8px; font-size: 11px; background: #0a0a0a; border-radius: 2px; padding: 6px; color: #4ec9b0; min-height: 60px; white-space: pre-wrap; }
95
+ .progress-bar { height: 4px; background: #222; border-radius: 2px; margin-top: 6px; overflow: hidden; }
96
+ .progress-fill { height: 100%; background: #569cd6; border-radius: 2px; width: 0%; transition: width 0.1s; }
97
+ 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; }
98
+ </style>
99
+ </head>
100
+ <body>
101
+
102
+ <!-- Metrics Bar -->
103
+ <div id="metrics-bar">
104
+ <div class="metric-card">
105
+ <div class="metric-top"><span class="metric-label">CPU</span><span class="metric-value" id="s-cpu">โ€”</span></div>
106
+ <canvas class="metric-canvas" id="g-cpu" width="100" height="28"></canvas>
107
+ <div style="font-size:10px;color:#555;margin-top:2px">sys <span id="s-syscpu">โ€”</span>% ยท <span id="s-cpucores">โ€”</span> cores</div>
108
+ </div>
109
+ <div class="metric-card">
110
+ <div class="metric-top"><span class="metric-label">Heap</span><span class="metric-value" id="s-heap">โ€”</span></div>
111
+ <canvas class="metric-canvas" id="g-heap" width="100" height="28"></canvas>
112
+ </div>
113
+ <div class="metric-card">
114
+ <div class="metric-top"><span class="metric-label">RSS</span><span class="metric-value" id="s-rss">โ€”</span></div>
115
+ <canvas class="metric-canvas" id="g-rss" width="100" height="28"></canvas>
116
+ <div style="font-size:10px;color:#555;margin-top:2px">of <span id="s-totalmem">โ€”</span> total</div>
117
+ </div>
118
+ <div class="metric-card">
119
+ <div class="metric-top"><span class="metric-label">Nodes</span><span class="metric-value" id="s-nodes">โ€”</span></div>
120
+ <canvas class="metric-canvas" id="g-nodes" width="100" height="28"></canvas>
121
+ <div style="font-size:10px;color:#555;margin-top:2px"><span id="s-dbsize">โ€”</span> MB on disk</div>
122
+ </div>
123
+ <div class="metric-card" style="border-left:1px solid #282828">
124
+ <div class="metric-top"><span class="metric-label">Uptime</span><span class="metric-value dim" id="s-uptime">โ€”</span></div>
125
+ <div style="margin-top:4px;font-size:10px;color:#555" id="s-ts">โ€”</div>
126
+ <div style="margin-top:4px"><span class="metric-label">WS<span id="ws-dot"></span></span> <span style="font-size:10px;color:#555"><span id="s-clients">0</span> clients ยท <span id="s-subs">0</span> subs</span></div>
127
+ </div>
128
+ </div>
129
+
130
+ <div id="main">
131
+ <!-- Tree Pane -->
132
+ <div id="tree-pane">
133
+ <div id="tree-header">
134
+ <b>Tree</b>
135
+ <span id="node-count">โ€”</span>
136
+ <button class="sm" onclick="loadTree()">โ†ป</button>
137
+ </div>
138
+ <div id="tree-container">Connectingโ€ฆ</div>
139
+ </div>
140
+
141
+ <!-- Right Pane -->
142
+ <div id="right-pane">
143
+ <div class="tabs">
144
+ <div class="tab active" onclick="showTab('rw')">Read / Write</div>
145
+ <div class="tab" onclick="showTab('query')">Query Builder</div>
146
+ <div class="tab" onclick="showTab('subs')">Live Subscriptions</div>
147
+ <div class="tab" onclick="showTab('auth');loadRules()">Auth &amp; Rules</div>
148
+ <div class="tab" onclick="showTab('advanced')">Advanced</div>
149
+ <div class="tab" onclick="showTab('streams')">Streams</div>
150
+ <div class="tab" onclick="showTab('mq')">MQ</div>
151
+ <div class="tab" onclick="showTab('stress')">Stress Tests</div>
152
+ <div class="tab" onclick="showTab('view')">View</div>
153
+ </div>
154
+
155
+ <!-- Read/Write Panel -->
156
+ <div class="panel active" id="panel-rw">
157
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
158
+ <span style="font-size:11px;color:#555">Auth:</span>
159
+ <input id="rw-auth-token" type="text" placeholder="token (or leave empty)" style="flex:1;font-size:11px">
160
+ <button onclick="doRwAuth()" class="sm">SET</button>
161
+ <span id="rw-auth-status" style="font-size:11px;color:#555">โ€”</span>
162
+ </div>
163
+ <div>
164
+ <label>Path</label>
165
+ <div class="row">
166
+ <input id="rw-path" type="text" placeholder="users/alice/name">
167
+ <button onclick="doGet()">GET</button>
168
+ <button class="danger" onclick="doDelete()">DELETE</button>
169
+ </div>
170
+ </div>
171
+ <div>
172
+ <label>Value (JSON)</label>
173
+ <textarea id="rw-value" placeholder='"Alice"&#10;or { "name": "Alice", "age": 30 }'></textarea>
174
+ </div>
175
+ <div class="row"><button class="success" onclick="doSet()">SET</button><button onclick="doUpdate()">UPDATE</button></div>
176
+ <div id="rw-status"></div>
177
+ <div>
178
+ <label>Result</label>
179
+ <div class="result" id="rw-result">โ€”</div>
180
+ </div>
181
+ <div style="margin-top:8px">
182
+ <label>Multi-path Update โ€” JSON object mapping path โ†’ value</label>
183
+ <textarea id="upd-value" style="min-height:80px" placeholder='{ "users/alice/age": 31, "users/bob/role": "admin" }'></textarea>
184
+ <div class="row" style="margin-top:4px"><button onclick="doUpdateTab()">MULTI-UPDATE</button></div>
185
+ <div id="upd-status"></div>
186
+ </div>
187
+ <div style="margin-top:8px;border-top:1px solid #222;padding-top:8px">
188
+ <label>Push โ€” append value with auto-generated key</label>
189
+ <div class="row">
190
+ <input id="push-path" type="text" placeholder="logs" style="flex:1">
191
+ <button class="success" onclick="doPush()">PUSH</button>
192
+ </div>
193
+ <textarea id="push-value" style="min-height:50px;margin-top:4px" placeholder='{ "level": "info", "msg": "hello" }'></textarea>
194
+ <div id="push-status"></div>
195
+ </div>
196
+ <div style="margin-top:8px;border-top:1px solid #222;padding-top:8px">
197
+ <label>Batch โ€” atomic multi-op (JSON array of operations)</label>
198
+ <textarea id="batch-value" style="min-height:80px" placeholder='[
199
+ { "op": "set", "path": "users/dave", "value": { "name": "Dave", "role": "user" } },
200
+ { "op": "delete", "path": "settings/temp" },
201
+ { "op": "push", "path": "logs", "value": { "msg": "batch op" } }
202
+ ]'></textarea>
203
+ <div class="row" style="margin-top:4px"><button onclick="doBatch()">RUN BATCH</button></div>
204
+ <div id="batch-status"></div>
205
+ <div class="result" id="batch-result" style="margin-top:4px;display:none">โ€”</div>
206
+ </div>
207
+ </div>
208
+
209
+ <!-- Query Panel -->
210
+ <div class="panel" id="panel-query">
211
+ <div>
212
+ <label>Base Path</label>
213
+ <input id="q-path" type="text" placeholder="users">
214
+ </div>
215
+ <div>
216
+ <label>Filters</label>
217
+ <div id="q-filters"></div>
218
+ <button onclick="addFilter()" style="margin-top:5px" class="sm">+ Filter</button>
219
+ </div>
220
+ <div class="row">
221
+ <div style="flex:2"><label>Order Field</label><input id="q-order-field" type="text" placeholder="name"></div>
222
+ <div style="flex:1"><label>Dir</label><select id="q-order-dir"><option value="asc">asc</option><option value="desc">desc</option></select></div>
223
+ <div style="flex:1"><label>Limit</label><input id="q-limit" type="text" placeholder="โ€”"></div>
224
+ <div style="flex:1"><label>Offset</label><input id="q-offset" type="text" placeholder="โ€”"></div>
225
+ </div>
226
+ <div><button onclick="doQuery()">RUN QUERY</button></div>
227
+ <div id="q-status"></div>
228
+ <div id="q-result"></div>
229
+ </div>
230
+
231
+ <!-- Subscriptions Panel -->
232
+ <div class="panel" id="panel-subs">
233
+ <div>
234
+ <label>Path to Subscribe</label>
235
+ <div class="row">
236
+ <input id="sub-path" type="text" placeholder="users/alice">
237
+ <button class="sub" onclick="doSubscribe('value')">VALUE</button>
238
+ <button class="sub" onclick="doSubscribe('child')" style="background:#4a3f6b">CHILD</button>
239
+ </div>
240
+ <div style="color:#555;font-size:11px;margin-top:4px">VALUE โ€” fires on any change at/below path &nbsp;ยท&nbsp; CHILD โ€” fires for direct child added/changed/removed</div>
241
+ </div>
242
+ <div id="sub-list" style="display:flex;flex-direction:column;gap:8px;margin-top:6px;"></div>
243
+ </div>
244
+
245
+ <!-- Auth & Rules Panel -->
246
+ <div class="panel" id="panel-auth">
247
+ <div style="display:flex;flex-direction:column;gap:12px">
248
+ <div>
249
+ <label>Authenticate</label>
250
+ <div class="row">
251
+ <input id="auth-token" type="text" placeholder='admin-secret or user:alice' style="flex:1">
252
+ <button onclick="doAuth()">AUTH</button>
253
+ <button class="danger" onclick="doDeauth()">CLEAR</button>
254
+ </div>
255
+ <div id="auth-status" style="margin-top:4px;font-size:12px;color:#555">Not authenticated โ€” all open rules apply</div>
256
+ </div>
257
+ <div>
258
+ <label>Active Rules (server-configured)</label>
259
+ <div class="result" style="font-size:11px;line-height:1.6;white-space:pre" id="auth-rules">Loadingโ€ฆ</div>
260
+ </div>
261
+ <div>
262
+ <label>Test Rule โ€” check if current auth can read/write a path</label>
263
+ <div class="row">
264
+ <input id="auth-test-path" type="text" placeholder="users/alice" style="flex:1">
265
+ <select id="auth-test-op" style="width:90px"><option value="read">READ</option><option value="write">WRITE</option></select>
266
+ <button onclick="doTestRule()">TEST</button>
267
+ </div>
268
+ <div class="result" id="auth-test-result">โ€”</div>
269
+ </div>
270
+ </div>
271
+ </div>
272
+
273
+ <!-- Advanced Panel (Transforms, TTL, FTS, Vectors) -->
274
+ <div class="panel" id="panel-advanced">
275
+ <div style="display:flex;flex-direction:column;gap:12px">
276
+
277
+ <!-- Transforms -->
278
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
279
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Transforms โ€” apply atomic operations to paths</label>
280
+ <div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
281
+ <button class="sm" onclick="fillTransform('counters/likes','increment','1')">increment +1</button>
282
+ <button class="sm" onclick="fillTransform('users/alice/updatedAt','serverTimestamp','')">serverTimestamp</button>
283
+ <button class="sm" onclick="fillTransform('users/alice/tags','arrayUnion','[&quot;vip&quot;,&quot;active&quot;]')">arrayUnion</button>
284
+ <button class="sm" onclick="fillTransform('users/alice/tags','arrayRemove','[&quot;active&quot;]')">arrayRemove</button>
285
+ </div>
286
+ <div class="row">
287
+ <input id="tf-path" type="text" placeholder="counters/likes" style="flex:2">
288
+ <select id="tf-type" style="flex:1">
289
+ <option value="increment">increment</option>
290
+ <option value="serverTimestamp">serverTimestamp</option>
291
+ <option value="arrayUnion">arrayUnion</option>
292
+ <option value="arrayRemove">arrayRemove</option>
293
+ </select>
294
+ </div>
295
+ <div class="row" style="margin-top:4px">
296
+ <input id="tf-value" type="text" placeholder="value (number for increment, JSON array for array ops)" style="flex:1">
297
+ <button class="success" onclick="doTransform()">APPLY</button>
298
+ </div>
299
+ <div id="tf-status" style="margin-top:4px"></div>
300
+ <div class="result" id="tf-result" style="margin-top:4px;display:none">โ€”</div>
301
+ </div>
302
+
303
+ <!-- TTL -->
304
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
305
+ <label style="font-size:11px;color:#888;margin-bottom:6px">TTL โ€” set values with auto-expiry</label>
306
+ <div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
307
+ <button class="sm" onclick="fillTTL('sessions/temp','{&quot;token&quot;:&quot;abc123&quot;}',60)">session 60s</button>
308
+ <button class="sm" onclick="fillTTL('cache/homepage','&quot;cached html&quot;',10)">cache 10s</button>
309
+ </div>
310
+ <div class="row">
311
+ <input id="ttl-path" type="text" placeholder="sessions/temp" style="flex:2">
312
+ <input id="ttl-seconds" type="number" placeholder="TTL (seconds)" value="60" style="width:120px">
313
+ </div>
314
+ <div style="margin-top:4px">
315
+ <textarea id="ttl-value" style="min-height:40px" placeholder='"session data" or { "token": "abc" }'></textarea>
316
+ </div>
317
+ <div class="row" style="margin-top:4px">
318
+ <button class="success" onclick="doSetTTL()">SET WITH TTL</button>
319
+ <button onclick="doSweep()">SWEEP NOW</button>
320
+ </div>
321
+ <div id="ttl-status" style="margin-top:4px"></div>
322
+ </div>
323
+
324
+ <!-- FTS -->
325
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
326
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Full-Text Search (FTS5)</label>
327
+ <div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
328
+ <button class="sm" onclick="fillFts('admin','users')">search "admin"</button>
329
+ <button class="sm" onclick="fillFts('design','')">search "design"</button>
330
+ <button class="sm" onclick="fillFtsIndex('posts/p1','A tutorial about building reactive databases')">index post</button>
331
+ </div>
332
+ <div class="row">
333
+ <input id="fts-path" type="text" placeholder="path to index" style="flex:1">
334
+ <input id="fts-content" type="text" placeholder="text content to index" style="flex:2">
335
+ <button onclick="doFtsIndex()">INDEX</button>
336
+ </div>
337
+ <div class="row" style="margin-top:4px">
338
+ <input id="fts-query" type="text" placeholder="search query" style="flex:2">
339
+ <input id="fts-prefix" type="text" placeholder="path prefix (optional)" style="flex:1">
340
+ <button class="success" onclick="doFtsSearch()">SEARCH</button>
341
+ </div>
342
+ <div id="fts-status" style="margin-top:4px"></div>
343
+ <div class="result" id="fts-result" style="margin-top:4px;display:none">โ€”</div>
344
+ </div>
345
+
346
+ <!-- Vectors -->
347
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
348
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Vector Search (cosine similarity, 384d)</label>
349
+ <div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
350
+ <button class="sm" onclick="fillVecSearch('users')">search users</button>
351
+ <button class="sm" onclick="fillVecSearch('')">search all</button>
352
+ </div>
353
+ <div class="row">
354
+ <input id="vec-path" type="text" placeholder="path to store embedding" style="flex:1">
355
+ <input id="vec-embedding" type="text" placeholder="[0.1, 0.2, ...] (384 floats)" style="flex:2">
356
+ <button onclick="doVecStore()">STORE</button>
357
+ </div>
358
+ <div class="row" style="margin-top:4px">
359
+ <input id="vec-query" type="text" placeholder="query vector [0.1, 0.2, ...]" style="flex:2">
360
+ <input id="vec-prefix" type="text" placeholder="path prefix" style="flex:1">
361
+ <input id="vec-limit" type="number" value="5" style="width:60px" placeholder="limit">
362
+ <button class="success" onclick="doVecSearch()">SEARCH</button>
363
+ </div>
364
+ <div id="vec-status" style="margin-top:4px"></div>
365
+ <div class="result" id="vec-result" style="margin-top:4px;display:none">โ€”</div>
366
+ </div>
367
+
368
+ </div>
369
+ </div>
370
+
371
+ <!-- Streams Panel -->
372
+ <div class="panel" id="panel-streams">
373
+ <div style="display:flex;flex-direction:column;gap:12px">
374
+
375
+ <!-- E2E Demo -->
376
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
377
+ <label style="font-size:11px;color:#888;margin-bottom:6px">E2E Demo โ€” push events โ†’ consumer groups โ†’ ack โ†’ compact โ†’ materialize</label>
378
+ <div class="row">
379
+ <button class="success" onclick="doStreamDemo()">RUN DEMO</button>
380
+ </div>
381
+ <div id="st-demo-status" style="margin-top:4px"></div>
382
+ <div class="result" id="st-demo-result" style="margin-top:4px;display:none;max-height:400px;white-space:pre-wrap">โ€”</div>
383
+ </div>
384
+
385
+ <!-- Push Event -->
386
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
387
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Push Event โ€” append to a topic (idempotent push optional)</label>
388
+ <div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
389
+ <button class="sm" onclick="fillStreamPush('events/orders','{&quot;orderId&quot;:&quot;o99&quot;,&quot;amount&quot;:250}','')">push order</button>
390
+ <button class="sm" onclick="fillStreamPush('events/orders','{&quot;orderId&quot;:&quot;o99&quot;,&quot;amount&quot;:250}','order-o99')">idempotent push</button>
391
+ </div>
392
+ <div class="row">
393
+ <input id="st-push-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
394
+ <input id="st-push-idem" type="text" placeholder="idempotency key (optional)" style="flex:1">
395
+ </div>
396
+ <textarea id="st-push-value" style="min-height:40px;margin-top:4px" placeholder='{ "orderId": "o99", "amount": 250 }'>{ "orderId": "o99", "amount": 250 }</textarea>
397
+ <div class="row" style="margin-top:4px">
398
+ <button class="success" onclick="doStreamPush()">PUSH</button>
399
+ </div>
400
+ <div id="st-push-status" style="margin-top:4px"></div>
401
+ </div>
402
+
403
+ <!-- Read Stream -->
404
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
405
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Read โ€” fetch unprocessed events for a consumer group</label>
406
+ <div class="row">
407
+ <input id="st-read-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
408
+ <input id="st-read-group" type="text" placeholder="group ID" value="admin-ui" style="flex:1">
409
+ <input id="st-read-limit" type="number" value="50" style="width:70px" placeholder="limit">
410
+ <button onclick="doStreamRead()">READ</button>
411
+ </div>
412
+ <div id="st-read-status" style="margin-top:4px"></div>
413
+ <div class="result" id="st-read-result" style="margin-top:4px;display:none;max-height:300px">โ€”</div>
414
+ </div>
415
+
416
+ <!-- Ack -->
417
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
418
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Ack โ€” commit offset for a consumer group</label>
419
+ <div class="row">
420
+ <input id="st-ack-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
421
+ <input id="st-ack-group" type="text" placeholder="group ID" value="admin-ui" style="flex:1">
422
+ <input id="st-ack-key" type="text" placeholder="event key to ack" style="flex:2">
423
+ <button onclick="doStreamAck()">ACK</button>
424
+ </div>
425
+ <div id="st-ack-status" style="margin-top:4px"></div>
426
+ </div>
427
+
428
+ <!-- Compact -->
429
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
430
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Compact โ€” remove old/excess events (safe: respects consumer offsets)</label>
431
+ <div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
432
+ <button class="sm" onclick="fillCompact('events/orders','','100','')">keep 100</button>
433
+ <button class="sm" onclick="fillCompact('events/orders','3600','','')">max 1h</button>
434
+ <button class="sm" onclick="fillCompact('events/orders','','','orderId')">keepKey: orderId</button>
435
+ </div>
436
+ <div class="row">
437
+ <input id="st-compact-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
438
+ <input id="st-compact-age" type="number" placeholder="maxAge (sec)" style="width:110px">
439
+ <input id="st-compact-count" type="number" placeholder="maxCount" style="width:100px">
440
+ <input id="st-compact-key" type="text" placeholder="keepKey field" style="flex:1">
441
+ <button class="danger" onclick="doStreamCompact()">COMPACT</button>
442
+ <button class="danger" onclick="doStreamReset()" title="Delete all events, snapshot, and offsets">RESET</button>
443
+ </div>
444
+ <div id="st-compact-status" style="margin-top:4px"></div>
445
+ </div>
446
+
447
+ <!-- Snapshot & Materialize -->
448
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
449
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Snapshot & Materialize โ€” view base state and merged view</label>
450
+ <div class="row">
451
+ <input id="st-snap-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
452
+ <input id="st-snap-keepkey" type="text" placeholder="keepKey (for materialize)" style="flex:1">
453
+ <button onclick="doStreamSnapshot()">SNAPSHOT</button>
454
+ <button class="success" onclick="doStreamMaterialize()">MATERIALIZE</button>
455
+ </div>
456
+ <div id="st-snap-status" style="margin-top:4px"></div>
457
+ <div class="result" id="st-snap-result" style="margin-top:4px;display:none;max-height:300px">โ€”</div>
458
+ </div>
459
+
460
+ <!-- Stream Subscribe -->
461
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
462
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Subscribe โ€” live stream with replay from last offset</label>
463
+ <div class="row">
464
+ <input id="st-sub-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
465
+ <input id="st-sub-group" type="text" placeholder="group ID" value="admin-live" style="flex:1">
466
+ <button class="sub" onclick="doStreamSub()">SUBSCRIBE</button>
467
+ </div>
468
+ <div id="st-sub-list" style="display:flex;flex-direction:column;gap:8px;margin-top:6px;"></div>
469
+ </div>
470
+
471
+ </div>
472
+ </div>
473
+
474
+ <!-- MQ Panel -->
475
+ <div class="panel" id="panel-mq">
476
+ <div style="display:flex;flex-direction:column;gap:12px">
477
+
478
+ <!-- E2E Demo -->
479
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
480
+ <label style="font-size:11px;color:#888;margin-bottom:6px">E2E Demo โ€” full worker lifecycle: push โ†’ fetch โ†’ ack/nack โ†’ retry โ†’ DLQ</label>
481
+ <div class="row">
482
+ <button class="success" onclick="doMqDemo()">RUN DEMO</button>
483
+ </div>
484
+ <div id="mq-demo-status" style="margin-top:4px"></div>
485
+ <div class="result" id="mq-demo-result" style="margin-top:4px;display:none;max-height:400px;white-space:pre-wrap">โ€”</div>
486
+ </div>
487
+
488
+ <!-- Push Job -->
489
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
490
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Push Job โ€” enqueue a message (SQS-style, exactly-once delivery)</label>
491
+ <div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
492
+ <button class="sm" onclick="fillMqPush('queues/jobs','{&quot;type&quot;:&quot;email&quot;,&quot;to&quot;:&quot;user@example.com&quot;}','')">email job</button>
493
+ <button class="sm" onclick="fillMqPush('queues/jobs','{&quot;type&quot;:&quot;sms&quot;,&quot;to&quot;:&quot;+1234567890&quot;}','sms-123')">idempotent sms</button>
494
+ </div>
495
+ <div class="row">
496
+ <input id="mq-push-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
497
+ <input id="mq-push-idem" type="text" placeholder="idempotency key (optional)" style="flex:1">
498
+ </div>
499
+ <textarea id="mq-push-value" style="min-height:40px;margin-top:4px" placeholder='{ "type": "email", "to": "user@example.com" }'>{ "type": "email", "to": "user@example.com" }</textarea>
500
+ <div class="row" style="margin-top:4px">
501
+ <button class="success" onclick="doMqPush()">PUSH</button>
502
+ </div>
503
+ <div id="mq-push-status" style="margin-top:4px"></div>
504
+ </div>
505
+
506
+ <!-- Fetch -->
507
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
508
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Fetch โ€” claim messages (visibility timeout applies)</label>
509
+ <div class="row">
510
+ <input id="mq-fetch-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
511
+ <input id="mq-fetch-count" type="number" value="1" style="width:70px" placeholder="count">
512
+ <button onclick="doMqFetch()">FETCH</button>
513
+ </div>
514
+ <div id="mq-fetch-status" style="margin-top:4px"></div>
515
+ <div class="result" id="mq-fetch-result" style="margin-top:4px;display:none;max-height:300px">โ€”</div>
516
+ </div>
517
+
518
+ <!-- Ack / Nack -->
519
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
520
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Ack / Nack โ€” confirm processing or release back to queue</label>
521
+ <div class="row">
522
+ <input id="mq-ack-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
523
+ <input id="mq-ack-key" type="text" placeholder="message key" style="flex:2">
524
+ <button class="danger" onclick="doMqAck()">ACK</button>
525
+ <button onclick="doMqNack()">NACK</button>
526
+ </div>
527
+ <div id="mq-ack-status" style="margin-top:4px"></div>
528
+ </div>
529
+
530
+ <!-- Peek -->
531
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
532
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Peek โ€” view messages without claiming</label>
533
+ <div class="row">
534
+ <input id="mq-peek-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
535
+ <input id="mq-peek-count" type="number" value="10" style="width:70px" placeholder="count">
536
+ <button onclick="doMqPeek()">PEEK</button>
537
+ </div>
538
+ <div id="mq-peek-status" style="margin-top:4px"></div>
539
+ <div class="result" id="mq-peek-result" style="margin-top:4px;display:none;max-height:300px">โ€”</div>
540
+ </div>
541
+
542
+ <!-- DLQ -->
543
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
544
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Dead Letter Queue โ€” messages that exceeded max deliveries</label>
545
+ <div class="row">
546
+ <input id="mq-dlq-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
547
+ <button onclick="doMqDlq()">VIEW DLQ</button>
548
+ </div>
549
+ <div id="mq-dlq-status" style="margin-top:4px"></div>
550
+ <div class="result" id="mq-dlq-result" style="margin-top:4px;display:none;max-height:300px">โ€”</div>
551
+ </div>
552
+
553
+ <!-- Purge -->
554
+ <div style="border:1px solid #222;border-radius:3px;padding:10px">
555
+ <label style="font-size:11px;color:#888;margin-bottom:6px">Purge โ€” delete all pending messages in a queue</label>
556
+ <div class="row">
557
+ <input id="mq-purge-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
558
+ <button class="danger" onclick="if(confirm('Purge all pending messages?'))doMqPurge()">PURGE</button>
559
+ </div>
560
+ <div id="mq-purge-status" style="margin-top:4px"></div>
561
+ </div>
562
+
563
+ </div>
564
+ </div>
565
+
566
+ <!-- Stress Tests Panel -->
567
+ <div class="panel" id="panel-stress">
568
+ <div class="stress-grid">
569
+ <div class="stress-card">
570
+ <h4>Sequential Writes</h4>
571
+ <div class="row" style="align-items:center;gap:8px">
572
+ <label style="margin:0">N:</label>
573
+ <input type="number" id="sw-n" value="1000" min="10" max="100000" style="width:90px">
574
+ <button onclick="runSeqWrites()">Run</button>
575
+ </div>
576
+ <div class="progress-bar"><div class="progress-fill" id="sw-prog"></div></div>
577
+ <div class="stress-result" id="sw-result">โ€”</div>
578
+ </div>
579
+ <div class="stress-card">
580
+ <h4>Burst Reads</h4>
581
+ <div class="row" style="align-items:center;gap:8px">
582
+ <label style="margin:0">N:</label>
583
+ <input type="number" id="br-n" value="2000" min="10" max="100000" style="width:90px">
584
+ <button onclick="runBurstReads()">Run</button>
585
+ </div>
586
+ <div class="progress-bar"><div class="progress-fill" id="br-prog"></div></div>
587
+ <div class="stress-result" id="br-result">โ€”</div>
588
+ </div>
589
+ <div class="stress-card">
590
+ <h4>Mixed Read/Write</h4>
591
+ <div class="row" style="align-items:center;gap:8px">
592
+ <label style="margin:0">N:</label>
593
+ <input type="number" id="mw-n" value="500" min="10" max="50000" style="width:90px">
594
+ <label style="margin:0">W%:</label>
595
+ <input type="number" id="mw-wp" value="30" min="0" max="100" style="width:60px">
596
+ <button onclick="runMixed()">Run</button>
597
+ </div>
598
+ <div class="progress-bar"><div class="progress-fill" id="mw-prog"></div></div>
599
+ <div class="stress-result" id="mw-result">โ€”</div>
600
+ </div>
601
+ <div class="stress-card">
602
+ <h4>Query Under Load</h4>
603
+ <div class="row" style="align-items:center;gap:8px">
604
+ <label style="margin:0">Records:</label>
605
+ <input type="number" id="ql-n" value="500" min="10" max="10000" style="width:80px">
606
+ <button onclick="runQueryLoad()">Run</button>
607
+ </div>
608
+ <div class="progress-bar"><div class="progress-fill" id="ql-prog"></div></div>
609
+ <div class="stress-result" id="ql-result">โ€”</div>
610
+ </div>
611
+ <div class="stress-card">
612
+ <h4>Bulk Update</h4>
613
+ <div class="row" style="align-items:center;gap:8px">
614
+ <label style="margin:0">Keys/batch:</label>
615
+ <input type="number" id="bu-n" value="100" min="5" max="5000" style="width:80px">
616
+ <label style="margin:0">Batches:</label>
617
+ <input type="number" id="bu-b" value="20" min="1" max="500" style="width:60px">
618
+ <button onclick="runBulkUpdate()">Run</button>
619
+ </div>
620
+ <div class="progress-bar"><div class="progress-fill" id="bu-prog"></div></div>
621
+ <div class="stress-result" id="bu-result">โ€”</div>
622
+ </div>
623
+ <div class="stress-card">
624
+ <h4>Deep Path Writes</h4>
625
+ <div class="row" style="align-items:center;gap:8px">
626
+ <label style="margin:0">N:</label>
627
+ <input type="number" id="dp-n" value="200" min="10" max="5000" style="width:80px">
628
+ <label style="margin:0">Depth:</label>
629
+ <input type="number" id="dp-d" value="6" min="2" max="12" style="width:60px">
630
+ <button onclick="runDeepPaths()">Run</button>
631
+ </div>
632
+ <div class="progress-bar"><div class="progress-fill" id="dp-prog"></div></div>
633
+ <div class="stress-result" id="dp-result">โ€”</div>
634
+ </div>
635
+ </div>
636
+ </div>
637
+
638
+ <!-- View Panel -->
639
+ <div class="panel" id="panel-view">
640
+ <div style="display:flex;flex-direction:column;gap:10px;height:100%">
641
+ <div style="display:flex;align-items:center;gap:10px">
642
+ <span style="color:#888;font-size:12px">Path:</span>
643
+ <span id="view-path" style="color:#4ec9b0;font-family:monospace;font-size:13px">โ€”</span>
644
+ <span id="view-time" style="color:#555;font-size:11px"></span>
645
+ </div>
646
+ <pre id="view-result" style="flex:1;margin:0;padding:12px;background:#0a0a0a;border:1px solid #2a2a2a;border-radius:4px;overflow:auto;color:#d4d4d4;font-size:13px;white-space:pre-wrap;word-break:break-all">Select a node in the tree to view its data.</pre>
647
+ </div>
648
+ </div>
649
+ </div>
650
+ </div>
651
+
652
+ <script>
653
+ // โ”€โ”€ Logger โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
654
+ const LEVEL_STYLE = {
655
+ debug: 'color:#666',
656
+ info: 'color:#4ec9b0',
657
+ warn: 'color:#ce9178',
658
+ error: 'color:#f48771;font-weight:bold',
659
+ };
660
+
661
+ // Aggregates rapid repeated debug messages into a collapsed group with a count
662
+ const _agg = { key: null, count: 0, paths: [], timer: null };
663
+ function _flushAgg() {
664
+ if (!_agg.count) return;
665
+ console.groupCollapsed(`%c[DEBUG] ${_agg.key} ร—${_agg.count}`, 'color:#666');
666
+ _agg.paths.forEach(p => console.debug('%c ' + p, 'color:#555'));
667
+ console.groupEnd();
668
+ _agg.key = null; _agg.count = 0; _agg.paths = [];
669
+ }
670
+
671
+ function log(level, ...args) {
672
+ if (level === 'debug') {
673
+ const key = args[0]; // e.g. 'โ†’ set'
674
+ const path = args[1] ?? '';
675
+ // Aggregate high-frequency debug ops
676
+ if (_agg.key === key) {
677
+ _agg.count++;
678
+ _agg.paths.push(path);
679
+ clearTimeout(_agg.timer);
680
+ _agg.timer = setTimeout(_flushAgg, 400);
681
+ return;
682
+ }
683
+ _flushAgg();
684
+ // Start new aggregation window
685
+ _agg.key = key; _agg.count = 1; _agg.paths = [path];
686
+ clearTimeout(_agg.timer);
687
+ _agg.timer = setTimeout(_flushAgg, 400);
688
+ return;
689
+ }
690
+ _flushAgg(); // flush any pending aggregate before an info/warn/error
691
+ const style = LEVEL_STYLE[level] ?? '';
692
+ console[level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log'](`%c[${level.toUpperCase()}]`, style, ...args);
693
+ }
694
+
695
+ // โ”€โ”€ ZuzClient (browser port, mirrors src/client/ZuzClient.ts API exactly) โ”€โ”€โ”€โ”€โ”€โ”€
696
+ class ValueSnapshot {
697
+ constructor(path, data) { this.path = path; this._data = data; }
698
+ val() { return this._data; }
699
+ get key() { return this.path.split('/').pop(); }
700
+ exists() { return this._data !== null && this._data !== undefined; }
701
+ }
702
+
703
+ class ClientQueryBuilder {
704
+ constructor(client, path) { this._client = client; this._path = path; this._filters = []; }
705
+ where(field, op, value) { this._filters.push({ field, op, value }); return this; }
706
+ order(field, dir = 'asc') { this._order = { field, dir }; return this; }
707
+ limit(n) { this._limit = n; return this; }
708
+ offset(n) { this._offset = n; return this; }
709
+ get() { return this._client._query(this._path, this._filters.length ? this._filters : undefined, this._order, this._limit, this._offset); }
710
+ }
711
+
712
+ class ZuzClient {
713
+ constructor(url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`) {
714
+ this._url = url;
715
+ this._ws = null;
716
+ this._msgId = 0;
717
+ this._pending = new Map();
718
+ this._valueCbs = new Map(); // path โ†’ Set<(ValueSnapshot)=>void>
719
+ this._childCbs = new Map(); // path โ†’ Set<(ChildEvent)=>void>
720
+ this._activeSubs = new Set(); // 'value:path' | 'child:path'
721
+ this._reconnectDelay = 1000;
722
+ this._closed = false;
723
+ }
724
+
725
+ connect() {
726
+ if (this._ws?.readyState === WebSocket.OPEN) return Promise.resolve();
727
+ return new Promise((resolve) => {
728
+ log('info', `WS connecting to ${this._url}`);
729
+ const ws = new WebSocket(this._url);
730
+ this._ws = ws;
731
+ ws.onopen = async () => {
732
+ this._reconnectDelay = 1000;
733
+ document.getElementById('ws-dot').classList.add('live');
734
+ log('info', `WS connected`);
735
+ // re-subscribe all active subs after reconnect
736
+ for (const key of this._activeSubs) {
737
+ const colon = key.indexOf(':');
738
+ const event = key.slice(0, colon), path = key.slice(colon + 1);
739
+ this._send('sub', { path, event }).catch(() => {});
740
+ }
741
+ if (this._activeSubs.size) log('debug', `Re-subscribed ${this._activeSubs.size} active subs`);
742
+ resolve();
743
+ loadTree();
744
+ };
745
+ ws.onclose = () => {
746
+ document.getElementById('ws-dot').classList.remove('live');
747
+ for (const [, p] of this._pending) p.reject(new Error('Connection closed'));
748
+ this._pending.clear();
749
+ if (!this._closed) {
750
+ const delay = this._reconnectDelay = Math.min(this._reconnectDelay * 2, 30000);
751
+ log('warn', `WS disconnected โ€” reconnecting in ${delay}ms`);
752
+ setTimeout(() => this.connect(), delay);
753
+ } else {
754
+ log('info', 'WS closed');
755
+ }
756
+ };
757
+ ws.onerror = (e) => log('error', 'WS error', e.message ?? '');
758
+ ws.onmessage = (e) => {
759
+ const msg = JSON.parse(e.data);
760
+ if (msg.type === 'value') {
761
+ if (msg.path !== '_admin/stats' && !msg.path.startsWith('stress/')) log('debug', `โ† value`, msg.path);
762
+ const snap = new ValueSnapshot(msg.path, msg.data);
763
+ for (const cb of this._valueCbs.get(msg.path) ?? []) cb(snap);
764
+ return;
765
+ }
766
+ if (msg.type === 'stream') {
767
+ const streamKey = `${msg.path}:${msg.groupId}`;
768
+ const cbs = this._streamCbs?.get(streamKey);
769
+ if (cbs) { for (const cb of cbs) cb(msg.events); }
770
+ return;
771
+ }
772
+ if (msg.type === 'child') {
773
+ if (!msg.path.startsWith('_admin') && msg.path !== 'stress' && !msg.path.startsWith('stress/')) log('debug', `โ† child:${msg.event}`, `${msg.path}/${msg.key}`);
774
+ const ev = { type: msg.event, key: msg.key, path: msg.path + '/' + msg.key, val: () => msg.data };
775
+ for (const cb of this._childCbs.get(msg.path) ?? []) cb(ev);
776
+ return;
777
+ }
778
+ const p = this._pending.get(msg.id);
779
+ if (p) { this._pending.delete(msg.id); msg.ok ? p.resolve(msg.data ?? null) : p.reject(new Error(msg.error)); }
780
+ };
781
+ });
782
+ }
783
+
784
+ disconnect() { this._closed = true; this._ws?.close(); }
785
+
786
+ _send(op, params = {}) {
787
+ return new Promise((resolve, reject) => {
788
+ if (this._ws?.readyState !== WebSocket.OPEN) return reject(new Error('Not connected'));
789
+ const id = String(++this._msgId);
790
+ this._pending.set(id, { resolve, reject });
791
+ const p = params.path ?? '';
792
+ if (!['sub','unsub'].includes(op) && !p.startsWith('_admin') && !p.startsWith('stress/'))
793
+ log('debug', `โ†’ ${op}`, p);
794
+ this._ws.send(JSON.stringify({ id, op, ...params }));
795
+ });
796
+ }
797
+
798
+ get(path) { return this._send('get', { path }); }
799
+ getShallow(path) { return this._send('get', { path: path ?? '', shallow: true }); }
800
+ set(path, value) { return this._send('set', { path, value }); }
801
+ update(updates) { return this._send('update', { updates }); }
802
+ delete(path) { return this._send('delete', { path }); }
803
+ batch(operations) { return this._send('batch', { operations }); }
804
+ push(path, value) { return this._send('push', { path, value }); }
805
+ transform(path, type, value) { return this._send('transform', { path, type, value }); }
806
+ setTTL(path, value, ttl) { return this._send('set-ttl', { path, value, ttl }); }
807
+ sweep() { return this._send('sweep', {}); }
808
+ ftsIndex(path, content) { return this._send('fts-index', { path, content }); }
809
+ ftsSearch(text, path, limit) { return this._send('fts-search', { text, path, limit }); }
810
+ vecStore(path, embedding) { return this._send('vec-store', { path, embedding }); }
811
+ vecSearch(query, path, limit, threshold) { return this._send('vec-search', { query, path, limit, threshold }); }
812
+ getRules() { return this._send('get-rules', {}); }
813
+ streamCompact(path, opts) { return this._send('stream-compact', { path, ...opts }); }
814
+ streamReset(path) { return this._send('stream-reset', { path }); }
815
+ streamSnapshot(path) { return this._send('stream-snapshot', { path }); }
816
+ streamMaterialize(path, keepKey) { return this._send('stream-materialize', { path, keepKey }); }
817
+ streamRead(path, groupId, limit) { return this._send('stream-read', { path, groupId, limit }); }
818
+ streamAck(path, groupId, key) { return this._send('stream-ack', { path, groupId, key }); }
819
+ streamSub(path, groupId) { return this._send('stream-sub', { path, groupId }); }
820
+ streamUnsub(path, groupId) { return this._send('stream-unsub', { path, groupId }); }
821
+
822
+ mqPush(path, value, idem) { return this._send('mq-push', { path, value, idempotencyKey: idem || undefined }); }
823
+ mqFetch(path, count) { return this._send('mq-fetch', { path, count }); }
824
+ mqAck(path, key) { return this._send('mq-ack', { path, key }); }
825
+ mqNack(path, key) { return this._send('mq-nack', { path, key }); }
826
+ mqPeek(path, count) { return this._send('mq-peek', { path, count }); }
827
+ mqDlq(path) { return this._send('mq-dlq', { path }); }
828
+ mqPurge(path, opts) { return this._send('mq-purge', { path, all: opts?.all }); }
829
+
830
+ query(path) { return new ClientQueryBuilder(this, path); }
831
+
832
+ _query(path, filters, order, limit, offset) {
833
+ return this._send('query', { path, filters, order, limit, offset });
834
+ }
835
+
836
+ on(path, cb) {
837
+ const key = `value:${path}`;
838
+ if (!this._valueCbs.has(path)) this._valueCbs.set(path, new Set());
839
+ this._valueCbs.get(path).add(cb);
840
+ if (!this._activeSubs.has(key)) {
841
+ this._activeSubs.add(key);
842
+ if (this._ws?.readyState === WebSocket.OPEN) this._send('sub', { path, event: 'value' }).catch(() => {});
843
+ }
844
+ return () => {
845
+ this._valueCbs.get(path)?.delete(cb);
846
+ if (!this._valueCbs.get(path)?.size) {
847
+ this._valueCbs.delete(path); this._activeSubs.delete(key);
848
+ if (this._ws?.readyState === WebSocket.OPEN) this._send('unsub', { path, event: 'value' }).catch(() => {});
849
+ }
850
+ };
851
+ }
852
+
853
+ onChild(path, cb) {
854
+ const key = `child:${path}`;
855
+ if (!this._childCbs.has(path)) this._childCbs.set(path, new Set());
856
+ this._childCbs.get(path).add(cb);
857
+ if (!this._activeSubs.has(key)) {
858
+ this._activeSubs.add(key);
859
+ if (this._ws?.readyState === WebSocket.OPEN) this._send('sub', { path, event: 'child' }).catch(() => {});
860
+ }
861
+ return () => {
862
+ this._childCbs.get(path)?.delete(cb);
863
+ if (!this._childCbs.get(path)?.size) {
864
+ this._childCbs.delete(path); this._activeSubs.delete(key);
865
+ if (this._ws?.readyState === WebSocket.OPEN) this._send('unsub', { path, event: 'child' }).catch(() => {});
866
+ }
867
+ };
868
+ }
869
+
870
+ get connected() { return this._ws?.readyState === WebSocket.OPEN; }
871
+ }
872
+
873
+ const db = new ZuzClient();
874
+
875
+ // โ”€โ”€ Sparkline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
876
+ const SPARK_POINTS = 60;
877
+
878
+ function hexToRgba(hex, a) {
879
+ const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
880
+ return `rgba(${r},${g},${b},${a})`;
881
+ }
882
+
883
+ class Sparkline {
884
+ constructor(canvasId, color = '#4ec9b0') {
885
+ this.canvas = document.getElementById(canvasId);
886
+ this.ctx = this.canvas.getContext('2d');
887
+ this.color = color; this.data = [];
888
+ const dpr = window.devicePixelRatio || 1;
889
+ const w = this.canvas.width, h = this.canvas.height;
890
+ this.canvas.width = w * dpr; this.canvas.height = h * dpr;
891
+ this.canvas.style.width = w + 'px'; this.canvas.style.height = h + 'px';
892
+ this.ctx.scale(dpr, dpr);
893
+ this.W = w; this.H = h;
894
+ }
895
+ push(v) {
896
+ this.data.push(v);
897
+ if (this.data.length > SPARK_POINTS) this.data.shift();
898
+ this.draw();
899
+ }
900
+ draw() {
901
+ const { ctx, data, W, H, color } = this;
902
+ ctx.clearRect(0, 0, W, H);
903
+ if (data.length < 2) return;
904
+ const min = Math.min(...data), max = Math.max(...data), range = max - min || 1;
905
+ const xStep = W / (SPARK_POINTS - 1), pad = 2;
906
+ const yScale = v => H - pad - ((v - min) / range) * (H - pad * 2);
907
+ const x0 = (SPARK_POINTS - data.length) * xStep;
908
+ ctx.beginPath();
909
+ ctx.moveTo(x0, H);
910
+ data.forEach((v, i) => ctx.lineTo(x0 + i * xStep, yScale(v)));
911
+ ctx.lineTo(x0 + (data.length - 1) * xStep, H);
912
+ ctx.closePath();
913
+ ctx.fillStyle = hexToRgba(color, 0.15);
914
+ ctx.fill();
915
+ ctx.beginPath();
916
+ data.forEach((v, i) => { const x = x0 + i * xStep; i === 0 ? ctx.moveTo(x, yScale(v)) : ctx.lineTo(x, yScale(v)); });
917
+ ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke();
918
+ const lv = data[data.length - 1];
919
+ ctx.beginPath(); ctx.arc(x0 + (data.length - 1) * xStep, yScale(lv), 2.5, 0, Math.PI * 2);
920
+ ctx.fillStyle = color; ctx.fill();
921
+ }
922
+ }
923
+
924
+ // โ”€โ”€ Stats subscription via ZuzDB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
925
+ const graphs = {
926
+ cpu: new Sparkline('g-cpu', '#ce9178'),
927
+ heap: new Sparkline('g-heap', '#569cd6'),
928
+ rss: new Sparkline('g-rss', '#9cdcfe'),
929
+ nodes:new Sparkline('g-nodes','#4ec9b0'),
930
+ };
931
+
932
+ db.on('_admin/stats', (snap) => {
933
+ const s = snap.val();
934
+ if (!s) return;
935
+ const cpu = s.process.cpuPercent;
936
+ const cpuEl = document.getElementById('s-cpu');
937
+ cpuEl.textContent = cpu + '%';
938
+ cpuEl.className = 'metric-value' + (cpu > 80 ? ' warn' : '');
939
+ graphs.cpu.push(cpu);
940
+
941
+ document.getElementById('s-heap').textContent = s.process.heapUsedMb + ' MB';
942
+ graphs.heap.push(s.process.heapUsedMb);
943
+
944
+ document.getElementById('s-rss').textContent = s.process.rssMb + ' MB';
945
+ graphs.rss.push(s.process.rssMb);
946
+
947
+ document.getElementById('s-nodes').textContent = s.db.nodeCount;
948
+ document.getElementById('s-dbsize').textContent = s.db.sizeMb ?? 'โ€”';
949
+ graphs.nodes.push(s.db.nodeCount);
950
+
951
+ if (s.system) {
952
+ document.getElementById('s-cpucores').textContent = s.system.cpuCores;
953
+ document.getElementById('s-syscpu').textContent = s.system.cpuPercent;
954
+ const totalGb = (s.system.totalMemMb / 1024).toFixed(1);
955
+ document.getElementById('s-totalmem').textContent = totalGb + ' GB';
956
+ }
957
+
958
+ document.getElementById('s-clients').textContent = s.clients ?? 0;
959
+ document.getElementById('s-subs').textContent = s.subs ?? 0;
960
+ document.getElementById('s-uptime').textContent = fmtUptime(s.process.uptimeSec);
961
+ document.getElementById('s-ts').textContent = new Date(s.ts).toLocaleTimeString();
962
+ });
963
+
964
+ // Connect after all subscriptions are registered so onopen re-subscribes them all
965
+ db.connect();
966
+
967
+ // โ”€โ”€ Live tree via child subscriptions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
968
+ const _treeUnsubs = new Map(); // key โ†’ unsub fn
969
+ const showAdmin = new URLSearchParams(location.search).has('showAdmin');
970
+
971
+ let _treeDebounceTimer = null;
972
+ const _pendingChangedPaths = new Set();
973
+ let _lastAdminRefresh = 0;
974
+ function debouncedLoadTree(path) {
975
+ _pendingChangedPaths.add(path);
976
+ clearTimeout(_treeDebounceTimer);
977
+ _treeDebounceTimer = setTimeout(() => {
978
+ const paths = [..._pendingChangedPaths];
979
+ _pendingChangedPaths.clear();
980
+ const roots = new Set();
981
+ for (const p of paths) {
982
+ const top = p.split('/')[0];
983
+ // Throttle _admin to once per 3s (updates every 1s)
984
+ if (top === '_admin') {
985
+ const now = Date.now();
986
+ if (now - _lastAdminRefresh < 3000) continue;
987
+ _lastAdminRefresh = now;
988
+ }
989
+ roots.add(top);
990
+ }
991
+ for (const r of roots) refreshPath(r);
992
+ }, 300);
993
+ }
994
+
995
+ function subscribeTreeLive(topLevelKeys) {
996
+ // Unsub keys no longer in tree
997
+ for (const [key, unsub] of _treeUnsubs) {
998
+ if (!topLevelKeys.includes(key)) { unsub(); _treeUnsubs.delete(key); }
999
+ }
1000
+ // Skip stress key (too noisy); _admin is throttled via debounce
1001
+ const NOISY_KEYS = new Set(['stress', '_admin']);
1002
+ const visibleKeys = topLevelKeys.filter(k => !NOISY_KEYS.has(k));
1003
+ for (const key of visibleKeys) {
1004
+ if (_treeUnsubs.has(key)) continue;
1005
+ const unsub = db.onChild(key, (ev) => debouncedLoadTree(ev.path));
1006
+ _treeUnsubs.set(key, unsub);
1007
+ }
1008
+ }
1009
+
1010
+ function fmtUptime(sec) {
1011
+ if (sec < 60) return sec + 's';
1012
+ if (sec < 3600) return Math.floor(sec/60) + 'm ' + (sec%60) + 's';
1013
+ return Math.floor(sec/3600) + 'h ' + Math.floor((sec%3600)/60) + 'm';
1014
+ }
1015
+
1016
+ // โ”€โ”€ Tab switching โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1017
+ const TAB_IDS = ['rw','query','subs','auth','advanced','streams','mq','stress','view'];
1018
+ function showTab(id) {
1019
+ document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', TAB_IDS[i] === id));
1020
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
1021
+ document.getElementById('panel-' + id).classList.add('active');
1022
+ localStorage.setItem('zuzdb:tab', id);
1023
+ }
1024
+
1025
+ // โ”€โ”€ Field persistence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1026
+ const PERSIST_FIELDS = ['rw-path','rw-value','upd-value','q-path','sub-path','auth-test-path','rw-auth-token','auth-token','push-path','push-value','batch-value','tf-path','tf-value','ttl-path','ttl-value','fts-path','fts-content','fts-query','fts-prefix','vec-path','vec-embedding','vec-query','vec-prefix'];
1027
+ const DEFAULT_VALUES = {
1028
+ 'rw-path': 'users/alice',
1029
+ 'rw-value': '{ "name": "Alice", "age": 30, "role": "admin" }',
1030
+ 'upd-value': '{ "users/alice/age": 31, "users/bob/role": "admin" }',
1031
+ 'q-path': 'users',
1032
+ 'sub-path': 'users',
1033
+ 'auth-test-path': 'users/alice',
1034
+ 'push-path': 'logs',
1035
+ 'push-value': `{ "level": "info", "msg": "hello from admin", "ts": ${Date.now()} }`,
1036
+ 'batch-value': '[\n { "op": "set", "path": "counters/views", "value": 42 },\n { "op": "push", "path": "logs", "value": { "msg": "batch test" } }\n]',
1037
+ 'tf-path': 'counters/likes',
1038
+ 'tf-value': '1',
1039
+ 'ttl-path': 'sessions/demo',
1040
+ 'ttl-value': '{ "token": "abc123", "user": "alice" }',
1041
+ 'fts-path': 'users/alice',
1042
+ 'fts-content': 'Alice is an admin user who manages the system',
1043
+ 'fts-query': 'admin',
1044
+ 'fts-prefix': 'users',
1045
+ 'vec-path': 'users/alice',
1046
+ 'vec-embedding': JSON.stringify(Array.from({length:384}, (_,i) => +(Math.sin(i*0.1)*0.5).toFixed(4))),
1047
+ 'vec-query': JSON.stringify(Array.from({length:384}, (_,i) => +(Math.sin(i*0.1)*0.5).toFixed(4))),
1048
+ 'vec-prefix': 'users',
1049
+ };
1050
+ function persistFields() {
1051
+ const state = {};
1052
+ for (const id of PERSIST_FIELDS) state[id] = document.getElementById(id).value;
1053
+ localStorage.setItem('zuzdb:fields', JSON.stringify(state));
1054
+ }
1055
+ function restoreFields() {
1056
+ const saved = localStorage.getItem('zuzdb:fields');
1057
+ try {
1058
+ const state = JSON.parse(saved ?? '{}');
1059
+ const hasState = saved && Object.keys(state).some(k => state[k]);
1060
+ for (const id of PERSIST_FIELDS) {
1061
+ const el = document.getElementById(id);
1062
+ if (!el) continue;
1063
+ if (hasState && state[id]) { el.value = state[id]; }
1064
+ else if (DEFAULT_VALUES[id]) { el.value = DEFAULT_VALUES[id]; }
1065
+ }
1066
+ } catch {}
1067
+ const tab = localStorage.getItem('zuzdb:tab');
1068
+ if (tab && TAB_IDS.includes(tab)) showTab(tab);
1069
+ }
1070
+ for (const id of PERSIST_FIELDS) {
1071
+ document.getElementById(id).addEventListener('input', persistFields);
1072
+ }
1073
+ restoreFields();
1074
+
1075
+ // โ”€โ”€ Tree โ€” preserves open state on refresh + across reloads โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1076
+ const OPEN_KEY = 'zuzdb:treeOpen';
1077
+
1078
+ function getOpenPaths() {
1079
+ const open = new Set();
1080
+ document.querySelectorAll('#tree-container details[open]').forEach(d => open.add(d.dataset.path));
1081
+ return open;
1082
+ }
1083
+
1084
+ function saveOpenPaths(open) {
1085
+ try { localStorage.setItem(OPEN_KEY, JSON.stringify([...open])); } catch {}
1086
+ }
1087
+
1088
+ function loadOpenPaths() {
1089
+ try { return new Set(JSON.parse(localStorage.getItem(OPEN_KEY) ?? '[]')); } catch { return new Set(); }
1090
+ }
1091
+
1092
+ // โ”€โ”€ Lazy tree โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1093
+ // Fetches only immediate children of a path. Nodes expand on click.
1094
+ const _loadedPaths = new Set(); // tracks which branch paths have been fetched
1095
+ let _treeGeneration = 0; // incremented on full reload to cancel stale expansions
1096
+
1097
+ async function fetchChildren(path) {
1098
+ try {
1099
+ return await db.getShallow(path || undefined) || [];
1100
+ } catch { return []; }
1101
+ }
1102
+
1103
+ function renderChildren(children, parentPath) {
1104
+ let html = '';
1105
+ for (const ch of children) {
1106
+ if (!showAdmin && (parentPath ? parentPath + '/' + ch.key : ch.key).startsWith('_admin')) continue;
1107
+ const path = parentPath ? parentPath + '/' + ch.key : ch.key;
1108
+ const ttlBadge = ch.ttl != null && ch.ttl >= 0 ? `<span class="ttl-badge" title="Expires in ${ch.ttl}s">${ch.ttl}s</span>` : ch.ttl === -1 ? '<span class="ttl-badge" title="Contains TTL entries">ttl</span>' : '';
1109
+ if (ch.isLeaf) {
1110
+ const raw = typeof ch.value === 'object' && ch.value !== null ? JSON.stringify(ch.value) : String(ch.value ?? '');
1111
+ const display = raw.length > 36 ? raw.slice(0, 36) + 'โ€ฆ' : raw;
1112
+ html += `<div class="tree-leaf" data-path="${escHtml(path)}" onclick="selectPath('${path.replace(/'/g, "\\'")}')"><span class="tree-key">${escHtml(ch.key)}</span>${ttlBadge}<span class="tree-val">${escHtml(String(display ?? ''))}</span></div>`;
1113
+ } else {
1114
+ const isOpen = _restoredOpenPaths.has(path);
1115
+ html += `<details data-path="${escHtml(path)}"${isOpen ? ' open' : ''}><summary onclick="selectPath('${path.replace(/'/g, "\\'")}')">${escHtml(ch.key)}${ttlBadge}</summary><div class="tree-children" data-parent="${escHtml(path)}"></div></details>`;
1116
+ }
1117
+ }
1118
+ return html;
1119
+ }
1120
+
1121
+ let _restoredOpenPaths = loadOpenPaths();
1122
+
1123
+ async function loadTree(changedPaths) {
1124
+ _loadedPaths.clear();
1125
+ const gen = ++_treeGeneration;
1126
+ _restoredOpenPaths = getOpenPaths().size ? getOpenPaths() : loadOpenPaths();
1127
+ const children = await fetchChildren('');
1128
+ if (!children || gen !== _treeGeneration) return;
1129
+
1130
+ const container = document.getElementById('tree-container');
1131
+ container.innerHTML = renderChildren(children, '');
1132
+
1133
+ // Subscribe live to top-level keys
1134
+ subscribeTreeLive(children.filter(c => !c.isLeaf).map(c => c.key));
1135
+
1136
+ // Auto-expand any that were previously open
1137
+ for (const det of container.querySelectorAll('details[open]')) {
1138
+ expandNode(det);
1139
+ }
1140
+
1141
+ // Attach toggle listeners
1142
+ attachToggleListeners(container);
1143
+
1144
+ // Update node count
1145
+ updateNodeCount();
1146
+
1147
+ // Flash changed
1148
+ if (changedPaths) flashPaths(changedPaths);
1149
+ }
1150
+
1151
+ function attachToggleListeners(root) {
1152
+ root.querySelectorAll('details').forEach(d => {
1153
+ if (d._treeListener) return;
1154
+ d._treeListener = true;
1155
+ d.addEventListener('toggle', () => {
1156
+ saveOpenPaths(getOpenPaths());
1157
+ if (d.open) expandNode(d);
1158
+ }, { passive: true });
1159
+ });
1160
+ }
1161
+
1162
+ async function expandNode(details, isRefresh) {
1163
+ const path = details.dataset.path;
1164
+ if (_loadedPaths.has(path)) return;
1165
+ _loadedPaths.add(path);
1166
+ const gen = _treeGeneration;
1167
+
1168
+ // Remember which sub-paths were open before re-render
1169
+ const prevOpen = new Set();
1170
+ if (isRefresh) {
1171
+ details.querySelectorAll('details[open]').forEach(d => prevOpen.add(d.dataset.path));
1172
+ }
1173
+
1174
+ const childContainer = details.querySelector(':scope > .tree-children');
1175
+ if (!childContainer) return;
1176
+
1177
+ const children = await fetchChildren(path);
1178
+ if (gen !== _treeGeneration) return; // stale โ€” tree was reloaded
1179
+
1180
+ // Merge prevOpen into _restoredOpenPaths so renderChildren marks them open
1181
+ for (const p of prevOpen) _restoredOpenPaths.add(p);
1182
+
1183
+ childContainer.innerHTML = renderChildren(children, path);
1184
+
1185
+ // Auto-expand open children (restored or previously open)
1186
+ for (const det of childContainer.querySelectorAll(':scope details[open]')) {
1187
+ expandNode(det);
1188
+ }
1189
+ attachToggleListeners(childContainer);
1190
+ updateNodeCount();
1191
+ }
1192
+
1193
+ async function refreshPath(path) {
1194
+ // Re-fetch the nearest loaded ancestor and update its children in-place
1195
+ // For simplicity, find the <details> or root and re-expand
1196
+ const parts = path.split('/');
1197
+ let target = '';
1198
+ // Walk up to find the deepest loaded ancestor
1199
+ for (let i = 1; i <= parts.length; i++) {
1200
+ const p = parts.slice(0, i).join('/');
1201
+ if (_loadedPaths.has(p)) target = p;
1202
+ }
1203
+
1204
+ if (!target) {
1205
+ // Refresh root
1206
+ return loadTree([path]);
1207
+ }
1208
+
1209
+ // Re-fetch this node and all its children
1210
+ for (const p of [..._loadedPaths]) {
1211
+ if (p === target || p.startsWith(target + '/')) _loadedPaths.delete(p);
1212
+ }
1213
+ const det = document.querySelector(`#tree-container details[data-path="${CSS.escape(target)}"]`);
1214
+ if (det && det.open) {
1215
+ await expandNode(det, true);
1216
+ flashPaths([path]);
1217
+ }
1218
+ }
1219
+
1220
+ function updateNodeCount() {
1221
+ const leaves = document.querySelectorAll('#tree-container .tree-leaf').length;
1222
+ const branches = document.querySelectorAll('#tree-container details').length;
1223
+ document.getElementById('node-count').textContent = `${leaves} leaves, ${branches} branches`;
1224
+ }
1225
+
1226
+ function flashPaths(paths) {
1227
+ for (const path of paths) {
1228
+ let el = document.querySelector(`#tree-container [data-path="${CSS.escape(path)}"]`);
1229
+ if (!el) {
1230
+ const parts = path.split('/');
1231
+ while (parts.length > 1) {
1232
+ parts.pop();
1233
+ el = document.querySelector(`#tree-container [data-path="${CSS.escape(parts.join('/'))}"]`);
1234
+ if (el) break;
1235
+ }
1236
+ }
1237
+ if (el) { el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 1200); }
1238
+ }
1239
+ }
1240
+
1241
+ function selectPath(path) {
1242
+ document.getElementById('rw-path').value = path;
1243
+ document.getElementById('sub-path').value = path;
1244
+ document.getElementById('q-path').value = path.includes('/') ? path.split('/').slice(0, -1).join('/') : path;
1245
+ showTab('view');
1246
+ document.getElementById('view-path').textContent = path;
1247
+ document.getElementById('view-time').textContent = '';
1248
+ document.getElementById('view-result').textContent = 'Loadingโ€ฆ';
1249
+ const t0 = performance.now();
1250
+ db.get(path).then(data => {
1251
+ document.getElementById('view-time').textContent = (performance.now() - t0).toFixed(1) + ' ms';
1252
+ document.getElementById('view-result').textContent = JSON.stringify(data, null, 2);
1253
+ }).catch(e => {
1254
+ document.getElementById('view-time').textContent = (performance.now() - t0).toFixed(1) + ' ms';
1255
+ document.getElementById('view-result').textContent = 'Error: ' + e.message;
1256
+ });
1257
+ }
1258
+
1259
+ function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
1260
+
1261
+ // โ”€โ”€ Read/Write โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1262
+ async function doGet() {
1263
+ const path = document.getElementById('rw-path').value.trim();
1264
+ log('info', `GET ${path}`);
1265
+ const t0 = performance.now();
1266
+ try {
1267
+ const res = await db.get(path);
1268
+ const ms = performance.now() - t0;
1269
+ log('info', `GET ${path} โ†’`, res);
1270
+ document.getElementById('rw-result').textContent = JSON.stringify(res, null, 2);
1271
+ showStatus('rw-status', 'OK', true, ms);
1272
+ } catch(e) { log('error', `GET ${path} failed:`, e.message); showStatus('rw-status', e.message, false, performance.now() - t0); }
1273
+ }
1274
+
1275
+ async function doSet() {
1276
+ const path = document.getElementById('rw-path').value.trim();
1277
+ const raw = document.getElementById('rw-value').value.trim();
1278
+ let value;
1279
+ try { value = JSON.parse(raw); } catch { return showStatus('rw-status', 'Invalid JSON', false); }
1280
+ log('info', `SET ${path} =`, value);
1281
+ const t0 = performance.now();
1282
+ try {
1283
+ await db.set(path, value);
1284
+ showStatus('rw-status', 'Set OK', true, performance.now() - t0);
1285
+ refreshPath(path);
1286
+ } catch(e) { log('error', `SET ${path} failed:`, e.message); showStatus('rw-status', e.message, false, performance.now() - t0); }
1287
+ }
1288
+
1289
+ async function doUpdate() {
1290
+ const path = document.getElementById('rw-path').value.trim();
1291
+ const raw = document.getElementById('rw-value').value.trim();
1292
+ let value;
1293
+ try { value = JSON.parse(raw); } catch { return showStatus('rw-status', 'Invalid JSON', false); }
1294
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return showStatus('rw-status', 'UPDATE requires an object', false);
1295
+ const updates = Object.fromEntries(Object.entries(value).map(([k, v]) => [`${path}/${k}`, v]));
1296
+ log('info', `UPDATE ${path}`, updates);
1297
+ const t0 = performance.now();
1298
+ try {
1299
+ await db.update(updates);
1300
+ showStatus('rw-status', 'Updated OK', true, performance.now() - t0);
1301
+ for (const p of Object.keys(updates)) refreshPath(p);
1302
+ } catch(e) { log('error', `UPDATE ${path} failed:`, e.message); showStatus('rw-status', e.message, false, performance.now() - t0); }
1303
+ }
1304
+
1305
+ async function doDelete() {
1306
+ const path = document.getElementById('rw-path').value.trim();
1307
+ log('info', `DELETE ${path}`);
1308
+ const t0 = performance.now();
1309
+ try {
1310
+ await db.delete(path);
1311
+ showStatus('rw-status', 'Deleted', true, performance.now() - t0);
1312
+ refreshPath(path);
1313
+ } catch(e) { log('error', `DELETE ${path} failed:`, e.message); showStatus('rw-status', e.message, false, performance.now() - t0); }
1314
+ }
1315
+
1316
+ // โ”€โ”€ Update tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1317
+ async function doUpdateTab() {
1318
+ const raw = document.getElementById('upd-value').value.trim();
1319
+ let updates;
1320
+ try { updates = JSON.parse(raw); } catch { return showStatus('upd-status', 'Invalid JSON', false); }
1321
+ log('info', 'UPDATE (multi-path)', updates);
1322
+ const t0 = performance.now();
1323
+ try {
1324
+ await db.update(updates);
1325
+ showStatus('upd-status', 'Updated', true, performance.now() - t0);
1326
+ for (const p of Object.keys(updates)) refreshPath(p);
1327
+ } catch(e) { log('error', 'UPDATE failed:', e.message); showStatus('upd-status', e.message, false, performance.now() - t0); }
1328
+ }
1329
+
1330
+ // โ”€โ”€ Push โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1331
+ async function doPush() {
1332
+ const path = document.getElementById('push-path').value.trim();
1333
+ const raw = document.getElementById('push-value').value.trim();
1334
+ let value;
1335
+ try { value = JSON.parse(raw); } catch { return showStatus('push-status', 'Invalid JSON', false); }
1336
+ log('info', `PUSH ${path}`, value);
1337
+ const t0 = performance.now();
1338
+ try {
1339
+ const key = await db.push(path, value);
1340
+ showStatus('push-status', `Pushed โ†’ ${key}`, true, performance.now() - t0);
1341
+ refreshPath(path);
1342
+ } catch(e) { log('error', `PUSH ${path} failed:`, e.message); showStatus('push-status', e.message, false, performance.now() - t0); }
1343
+ }
1344
+
1345
+ // โ”€โ”€ Batch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1346
+ async function doBatch() {
1347
+ const raw = document.getElementById('batch-value').value.trim();
1348
+ let operations;
1349
+ try { operations = JSON.parse(raw); } catch { return showStatus('batch-status', 'Invalid JSON array', false); }
1350
+ if (!Array.isArray(operations)) return showStatus('batch-status', 'Must be a JSON array', false);
1351
+ log('info', `BATCH ${operations.length} ops`, operations);
1352
+ const t0 = performance.now();
1353
+ try {
1354
+ const result = await db.batch(operations);
1355
+ showStatus('batch-status', `Batch OK (${operations.length} ops)`, true, performance.now() - t0);
1356
+ const resultEl = document.getElementById('batch-result');
1357
+ if (result) { resultEl.textContent = JSON.stringify(result, null, 2); resultEl.style.display = 'block'; }
1358
+ else { resultEl.style.display = 'none'; }
1359
+ // Refresh affected paths
1360
+ const batchPaths = new Set(operations.map(op => (op.path || Object.keys(op.updates || {})[0] || '').split('/')[0]).filter(Boolean));
1361
+ for (const p of batchPaths) refreshPath(p);
1362
+ } catch(e) { log('error', 'BATCH failed:', e.message); showStatus('batch-status', e.message, false, performance.now() - t0); }
1363
+ }
1364
+
1365
+ // โ”€โ”€ Query Builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1366
+ function addFilter() {
1367
+ const container = document.getElementById('q-filters');
1368
+ const row = document.createElement('div');
1369
+ row.className = 'filter-row';
1370
+ row.innerHTML = `
1371
+ <input type="text" placeholder="field" style="flex:2">
1372
+ <select style="flex:1"><option>==</option><option>!=</option><option>&gt;</option><option>&gt;=</option><option>&lt;</option><option>&lt;=</option><option>contains</option><option>startsWith</option></select>
1373
+ <input type="text" placeholder="value" style="flex:2">
1374
+ <button onclick="this.parentElement.remove()" class="danger sm">โœ•</button>`;
1375
+ container.appendChild(row);
1376
+ }
1377
+
1378
+ async function doQuery() {
1379
+ const path = document.getElementById('q-path').value.trim();
1380
+ const filterRows = document.querySelectorAll('#q-filters .filter-row');
1381
+ const filters = [...filterRows].map(r => {
1382
+ const inputs = r.querySelectorAll('input');
1383
+ const op = r.querySelector('select').value;
1384
+ let val = inputs[1].value.trim();
1385
+ try { val = JSON.parse(val); } catch {}
1386
+ return { field: inputs[0].value.trim(), op, value: val };
1387
+ }).filter(f => f.field);
1388
+ const orderField = document.getElementById('q-order-field').value.trim();
1389
+ const orderDir = document.getElementById('q-order-dir').value;
1390
+ const limit = document.getElementById('q-limit').value.trim();
1391
+ const offset = document.getElementById('q-offset').value.trim();
1392
+ let q = db.query(path);
1393
+ for (const f of filters) q = q.where(f.field, f.op, f.value);
1394
+ if (orderField) q = q.order(orderField, orderDir);
1395
+ if (limit) q = q.limit(Number(limit));
1396
+ if (offset) q = q.offset(Number(offset));
1397
+ const t0 = performance.now();
1398
+ const res = await q.get();
1399
+ showStatus('q-status', Array.isArray(res) ? `${res.length} results` : 'Error', Array.isArray(res), performance.now() - t0);
1400
+ document.getElementById('q-result').innerHTML = renderTable(res);
1401
+ }
1402
+
1403
+ function renderTable(data) {
1404
+ if (!Array.isArray(data) || !data.length) return '<div class="result">No results</div>';
1405
+ const allKeys = [...new Set(data.flatMap(r => Object.keys(r)))];
1406
+ const ths = allKeys.map(k => `<th>${escHtml(k)}</th>`).join('');
1407
+ const trs = data.map(r => `<tr>${allKeys.map(k => `<td>${escHtml(r[k] == null ? '' : String(r[k]))}</td>`).join('')}</tr>`).join('');
1408
+ return `<div style="overflow:auto;margin-top:6px"><table><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table></div>`;
1409
+ }
1410
+
1411
+ // โ”€โ”€ Subscriptions (via ZuzDB WS) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1412
+ const activeSubs = {};
1413
+ const EVENT_BADGE = { added: 'color:#4ec9b0', changed: 'color:#569cd6', removed: 'color:#f48771', value: 'color:#9cdcfe' };
1414
+
1415
+ function doSubscribe(mode = 'value') {
1416
+ const path = document.getElementById('sub-path').value.trim();
1417
+ const subKey = `${mode}:${path}`;
1418
+ if (!path || activeSubs[subKey]) return;
1419
+ const id = 'sub-' + Math.random().toString(36).slice(2);
1420
+ const div = document.createElement('div');
1421
+ div.className = 'sub-item'; div.id = id;
1422
+ div.innerHTML = `
1423
+ <div class="sub-item-header">
1424
+ <b>${escHtml(path)}</b>
1425
+ <span style="color:#555;font-size:11px;margin-left:4px">[${mode}]</span>
1426
+ <button class="danger sm" onclick="doUnsubscribe('${id}','${subKey}')">โœ•</button>
1427
+ </div>
1428
+ <div class="sub-log" id="log-${id}"><div style="color:#555">Waiting for eventsโ€ฆ</div></div>`;
1429
+ document.getElementById('sub-list').appendChild(div);
1430
+
1431
+ function appendEntry(type, label, val) {
1432
+ const logEl = document.getElementById('log-' + id);
1433
+ const time = new Date().toLocaleTimeString();
1434
+ const entry = document.createElement('div');
1435
+ const style = EVENT_BADGE[type] ?? 'color:#9cdcfe';
1436
+ entry.innerHTML = `<span>${time}</span><span style="${style};margin-right:6px">${escHtml(type)}</span><span style="color:#666;margin-right:4px">${escHtml(label)}</span>${escHtml(JSON.stringify(val))}`;
1437
+ logEl.prepend(entry);
1438
+ if (logEl.children.length > 50) logEl.lastChild.remove();
1439
+ }
1440
+
1441
+ let unsub;
1442
+ if (mode === 'child') {
1443
+ unsub = db.onChild(path, (ev) => appendEntry(ev.type, ev.key, ev.val()));
1444
+ } else {
1445
+ unsub = db.on(path, (snap) => appendEntry('value', snap.path, snap.val()));
1446
+ }
1447
+ activeSubs[subKey] = { unsub, id };
1448
+ }
1449
+
1450
+ function doUnsubscribe(id, subKey) {
1451
+ activeSubs[subKey]?.unsub();
1452
+ delete activeSubs[subKey];
1453
+ document.getElementById(id)?.remove();
1454
+ }
1455
+
1456
+ // โ”€โ”€ Auth & Rules โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1457
+ let _authCtx = null;
1458
+
1459
+ async function loadRules() {
1460
+ const el = document.getElementById('auth-rules');
1461
+ try {
1462
+ const rules = await db.getRules();
1463
+ el.textContent = rules.map(r => {
1464
+ const parts = [];
1465
+ if (r.read !== null) parts.push(`read: ${typeof r.read === 'string' ? r.read : r.read}`);
1466
+ if (r.write !== null) parts.push(`write: ${typeof r.write === 'string' ? r.write : r.write}`);
1467
+ return `${r.pattern.padEnd(20)} โ†’ ${parts.join(', ')}`;
1468
+ }).join('\n') || '(no rules configured)';
1469
+ } catch { el.textContent = 'Failed to load rules'; }
1470
+ }
1471
+
1472
+ function _updateAuthStatus(ctx) {
1473
+ const summary = ctx ? `${escHtml(JSON.stringify(ctx))}` : 'Not authenticated';
1474
+ const color = ctx ? '#4ec9b0' : '#555';
1475
+ document.getElementById('auth-status').innerHTML = `<span style="color:${color}">${ctx ? 'Authenticated' : 'Not authenticated'}</span>${ctx ? ' โ€” ' + summary : ''}`;
1476
+ document.getElementById('rw-auth-status').innerHTML = ctx
1477
+ ? `<span style="color:#4ec9b0">${escHtml(JSON.stringify(ctx))}</span>`
1478
+ : '<span style="color:#555">none</span>';
1479
+ }
1480
+
1481
+ async function _applyAuth(token) {
1482
+ if (!token) {
1483
+ _authCtx = null;
1484
+ db.disconnect(); db._closed = false; db.connect();
1485
+ _updateAuthStatus(null);
1486
+ log('info', 'Auth cleared โ€” reconnecting');
1487
+ return;
1488
+ }
1489
+ try {
1490
+ const ctx = await db._send('auth', { token });
1491
+ _authCtx = ctx;
1492
+ _updateAuthStatus(ctx);
1493
+ log('info', 'Auth success', ctx);
1494
+ } catch(e) {
1495
+ _authCtx = null;
1496
+ _updateAuthStatus(null);
1497
+ document.getElementById('auth-status').innerHTML = `<span style="color:#f48771">Auth failed: ${escHtml(e.message)}</span>`;
1498
+ document.getElementById('rw-auth-status').innerHTML = `<span style="color:#f48771">failed</span>`;
1499
+ log('warn', 'Auth failed:', e.message);
1500
+ }
1501
+ }
1502
+
1503
+ async function doAuth() {
1504
+ const token = document.getElementById('auth-token').value.trim();
1505
+ await _applyAuth(token);
1506
+ }
1507
+
1508
+ async function doRwAuth() {
1509
+ const token = document.getElementById('rw-auth-token').value.trim();
1510
+ document.getElementById('auth-token').value = token; // keep in sync
1511
+ await _applyAuth(token);
1512
+ }
1513
+
1514
+ function doDeauth() {
1515
+ document.getElementById('auth-token').value = '';
1516
+ document.getElementById('rw-auth-token').value = '';
1517
+ _applyAuth('');
1518
+ }
1519
+
1520
+ async function doTestRule() {
1521
+ const path = document.getElementById('auth-test-path').value.trim();
1522
+ const op = document.getElementById('auth-test-op').value;
1523
+ const resultEl = document.getElementById('auth-test-result');
1524
+ if (!path) return;
1525
+ try {
1526
+ // Attempt the op โ€” if it throws PERMISSION_DENIED we know it's blocked
1527
+ if (op === 'read') {
1528
+ await db.get(path);
1529
+ resultEl.innerHTML = `<span style="color:#4ec9b0">ALLOWED</span> โ€” read ${escHtml(path)}`;
1530
+ } else {
1531
+ const existing = await db.get(path);
1532
+ await db.set(path, existing); // write same value back โ€” non-destructive test
1533
+ resultEl.innerHTML = `<span style="color:#4ec9b0">ALLOWED</span> โ€” write ${escHtml(path)}`;
1534
+ }
1535
+ } catch(e) {
1536
+ const denied = e.message?.includes('Permission denied') || e.message?.includes('denied');
1537
+ resultEl.innerHTML = `<span style="color:${denied ? '#f48771' : '#ce9178'}">${denied ? 'DENIED' : 'ERROR'}</span> โ€” ${escHtml(e.message)}`;
1538
+ }
1539
+ }
1540
+
1541
+ // โ”€โ”€ Quick-fill helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1542
+ function fillTransform(path, type, value) {
1543
+ document.getElementById('tf-path').value = path;
1544
+ document.getElementById('tf-type').value = type;
1545
+ document.getElementById('tf-value').value = value;
1546
+ }
1547
+ function fillTTL(path, value, seconds) {
1548
+ document.getElementById('ttl-path').value = path;
1549
+ document.getElementById('ttl-value').value = value;
1550
+ document.getElementById('ttl-seconds').value = seconds;
1551
+ }
1552
+ function fillFts(query, prefix) {
1553
+ document.getElementById('fts-query').value = query;
1554
+ document.getElementById('fts-prefix').value = prefix || '';
1555
+ }
1556
+ function fillFtsIndex(path, content) {
1557
+ document.getElementById('fts-path').value = path;
1558
+ document.getElementById('fts-content').value = content;
1559
+ }
1560
+ function fillVecSearch(prefix) {
1561
+ // Fill with alice's embedding pattern for easy testing
1562
+ const q = JSON.stringify(Array.from({length: 384}, (_, i) => +(Math.sin(i * 0.1) * 0.5).toFixed(4)));
1563
+ document.getElementById('vec-query').value = q;
1564
+ document.getElementById('vec-prefix').value = prefix || '';
1565
+ }
1566
+
1567
+ // โ”€โ”€ Transforms โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1568
+ async function doTransform() {
1569
+ const path = document.getElementById('tf-path').value.trim();
1570
+ const type = document.getElementById('tf-type').value;
1571
+ const rawVal = document.getElementById('tf-value').value.trim();
1572
+ let value;
1573
+ if (type === 'serverTimestamp') {
1574
+ value = null;
1575
+ } else if (type === 'increment') {
1576
+ value = Number(rawVal) || 0;
1577
+ } else {
1578
+ try { value = JSON.parse(rawVal); } catch { return showStatus('tf-status', 'Invalid JSON value', false); }
1579
+ }
1580
+ const t0 = performance.now();
1581
+ try {
1582
+ const result = await db.transform(path, type, value);
1583
+ showStatus('tf-status', `${type} applied`, true, performance.now() - t0);
1584
+ const el = document.getElementById('tf-result');
1585
+ el.textContent = JSON.stringify(result, null, 2); el.style.display = 'block';
1586
+ refreshPath(path);
1587
+ } catch(e) { showStatus('tf-status', e.message, false, performance.now() - t0); }
1588
+ }
1589
+
1590
+ // โ”€โ”€ TTL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1591
+ async function doSetTTL() {
1592
+ const path = document.getElementById('ttl-path').value.trim();
1593
+ const ttl = Number(document.getElementById('ttl-seconds').value) || 60;
1594
+ const raw = document.getElementById('ttl-value').value.trim();
1595
+ let value;
1596
+ try { value = JSON.parse(raw); } catch { return showStatus('ttl-status', 'Invalid JSON', false); }
1597
+ const t0 = performance.now();
1598
+ try {
1599
+ await db.setTTL(path, value, ttl);
1600
+ showStatus('ttl-status', `Set with TTL=${ttl}s`, true, performance.now() - t0);
1601
+ refreshPath(path);
1602
+ } catch(e) { showStatus('ttl-status', e.message, false, performance.now() - t0); }
1603
+ }
1604
+
1605
+ async function doSweep() {
1606
+ const t0 = performance.now();
1607
+ try {
1608
+ const expired = await db.sweep();
1609
+ showStatus('ttl-status', `Swept ${expired?.length ?? 0} entries`, true, performance.now() - t0);
1610
+ if (expired?.length) loadTree();
1611
+ } catch(e) { showStatus('ttl-status', e.message, false, performance.now() - t0); }
1612
+ }
1613
+
1614
+ // โ”€โ”€ FTS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1615
+ async function doFtsIndex() {
1616
+ const path = document.getElementById('fts-path').value.trim();
1617
+ const content = document.getElementById('fts-content').value.trim();
1618
+ if (!path || !content) return showStatus('fts-status', 'Path and content required', false);
1619
+ const t0 = performance.now();
1620
+ try {
1621
+ await db.ftsIndex(path, content);
1622
+ showStatus('fts-status', 'Indexed', true, performance.now() - t0);
1623
+ } catch(e) { showStatus('fts-status', e.message, false, performance.now() - t0); }
1624
+ }
1625
+
1626
+ async function doFtsSearch() {
1627
+ const text = document.getElementById('fts-query').value.trim();
1628
+ const pathPrefix = document.getElementById('fts-prefix').value.trim() || undefined;
1629
+ if (!text) return showStatus('fts-status', 'Enter a search query', false);
1630
+ const t0 = performance.now();
1631
+ try {
1632
+ const results = await db.ftsSearch(text, pathPrefix);
1633
+ const el = document.getElementById('fts-result');
1634
+ showStatus('fts-status', `${results.length} results`, true, performance.now() - t0);
1635
+ el.textContent = JSON.stringify(results, null, 2); el.style.display = 'block';
1636
+ } catch(e) { showStatus('fts-status', e.message, false, performance.now() - t0); }
1637
+ }
1638
+
1639
+ // โ”€โ”€ Vectors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1640
+ async function doVecStore() {
1641
+ const path = document.getElementById('vec-path').value.trim();
1642
+ const raw = document.getElementById('vec-embedding').value.trim();
1643
+ let embedding;
1644
+ try { embedding = JSON.parse(raw); } catch { return showStatus('vec-status', 'Invalid JSON array', false); }
1645
+ if (!Array.isArray(embedding)) return showStatus('vec-status', 'Embedding must be an array', false);
1646
+ const t0 = performance.now();
1647
+ try {
1648
+ await db.vecStore(path, embedding);
1649
+ showStatus('vec-status', `Stored (${embedding.length}d)`, true, performance.now() - t0);
1650
+ } catch(e) { showStatus('vec-status', e.message, false, performance.now() - t0); }
1651
+ }
1652
+
1653
+ async function doVecSearch() {
1654
+ const raw = document.getElementById('vec-query').value.trim();
1655
+ const pathPrefix = document.getElementById('vec-prefix').value.trim() || undefined;
1656
+ const limit = Number(document.getElementById('vec-limit').value) || 5;
1657
+ let query;
1658
+ try { query = JSON.parse(raw); } catch { return showStatus('vec-status', 'Invalid JSON array', false); }
1659
+ const t0 = performance.now();
1660
+ try {
1661
+ const results = await db.vecSearch(query, pathPrefix, limit);
1662
+ const el = document.getElementById('vec-result');
1663
+ showStatus('vec-status', `${results.length} results`, true, performance.now() - t0);
1664
+ el.textContent = JSON.stringify(results, null, 2); el.style.display = 'block';
1665
+ } catch(e) { showStatus('vec-status', e.message, false, performance.now() - t0); }
1666
+ }
1667
+
1668
+ // โ”€โ”€ Stress Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1669
+ function setProgress(id, pct) { document.getElementById(id).style.width = pct + '%'; }
1670
+ function setStressResult(id, text) { document.getElementById(id).textContent = text; }
1671
+ function fmtStressResult({ n, elapsedMs, extraLines = [] }) {
1672
+ const ops = Math.round(n / (elapsedMs / 1000));
1673
+ return [`ops: ${n}`, `time: ${elapsedMs.toFixed(1)}ms`, `throughput: ${ops.toLocaleString()} ops/s`, ...extraLines].join('\n');
1674
+ }
1675
+
1676
+ async function runSeqWrites() {
1677
+ const n = Number(document.getElementById('sw-n').value);
1678
+ setStressResult('sw-result', 'Runningโ€ฆ'); setProgress('sw-prog', 0);
1679
+ const t0 = performance.now();
1680
+ const batch = 50;
1681
+ for (let i = 0; i < n; i += batch) {
1682
+ const end = Math.min(i + batch, n);
1683
+ await Promise.all(Array.from({ length: end - i }, (_, j) => db.set(`stress/seq/${i+j}`, { v: i+j, ts: Date.now() })));
1684
+ setProgress('sw-prog', Math.round((end / n) * 100));
1685
+ }
1686
+ setStressResult('sw-result', fmtStressResult({ n, elapsedMs: performance.now() - t0 }));
1687
+ setProgress('sw-prog', 100); loadTree();
1688
+ }
1689
+
1690
+ async function runBurstReads() {
1691
+ const n = Number(document.getElementById('br-n').value);
1692
+ setStressResult('br-result', 'Seedingโ€ฆ');
1693
+ await db.set('stress/read-target', { hello: 'world', x: 42 });
1694
+ setStressResult('br-result', 'Runningโ€ฆ'); setProgress('br-prog', 0);
1695
+ const t0 = performance.now(); const latencies = [];
1696
+ const batch = 100;
1697
+ for (let i = 0; i < n; i += batch) {
1698
+ const end = Math.min(i + batch, n); const bt = performance.now();
1699
+ await Promise.all(Array.from({ length: end - i }, () => db.get('stress/read-target')));
1700
+ latencies.push(performance.now() - bt);
1701
+ setProgress('br-prog', Math.round((end / n) * 100));
1702
+ }
1703
+ const elapsedMs = performance.now() - t0;
1704
+ const avgBatch = latencies.reduce((a, b) => a + b, 0) / latencies.length;
1705
+ setStressResult('br-result', fmtStressResult({ n, elapsedMs, extraLines: [`avg batch: ${avgBatch.toFixed(1)}ms`] }));
1706
+ setProgress('br-prog', 100);
1707
+ }
1708
+
1709
+ async function runMixed() {
1710
+ const n = Number(document.getElementById('mw-n').value);
1711
+ const writePct = Number(document.getElementById('mw-wp').value) / 100;
1712
+ setStressResult('mw-result', 'Runningโ€ฆ'); setProgress('mw-prog', 0);
1713
+ const t0 = performance.now(); let writes = 0, reads = 0;
1714
+ const batch = 50;
1715
+ for (let i = 0; i < n; i += batch) {
1716
+ const end = Math.min(i + batch, n);
1717
+ await Promise.all(Array.from({ length: end - i }, (_, j) => {
1718
+ if (Math.random() < writePct) { writes++; return db.set(`stress/mixed/${i+j}`, i+j); }
1719
+ reads++; return db.get(`stress/mixed/${(i+j) % Math.max(1, i)}`);
1720
+ }));
1721
+ setProgress('mw-prog', Math.round((end / n) * 100));
1722
+ }
1723
+ setStressResult('mw-result', fmtStressResult({ n, elapsedMs: performance.now() - t0, extraLines: [`writes: ${writes}`, `reads: ${reads}`] }));
1724
+ setProgress('mw-prog', 100); loadTree();
1725
+ }
1726
+
1727
+ async function runQueryLoad() {
1728
+ const n = Number(document.getElementById('ql-n').value);
1729
+ setStressResult('ql-result', 'Seeding ' + n + ' recordsโ€ฆ'); setProgress('ql-prog', 5);
1730
+ const batch = 50;
1731
+ for (let i = 0; i < n; i += batch) {
1732
+ const end = Math.min(i + batch, n);
1733
+ await Promise.all(Array.from({ length: end - i }, (_, j) => {
1734
+ const idx = i + j;
1735
+ return db.set(`stress/qload/u${idx}`, { name: `User${idx}`, score: Math.floor(Math.random() * 100), role: idx % 3 === 0 ? 'admin' : 'user' });
1736
+ }));
1737
+ setProgress('ql-prog', 5 + Math.round((Math.min(i + batch, n) / n) * 45));
1738
+ }
1739
+ setStressResult('ql-result', 'Running queriesโ€ฆ');
1740
+ const queries = [
1741
+ { filters: [{ field: 'role', op: '==', value: 'admin' }] },
1742
+ { filters: [{ field: 'score', op: '>=', value: 50 }], order: { field: 'score', dir: 'desc' }, limit: 10 },
1743
+ { order: { field: 'name', dir: 'asc' }, limit: 20 },
1744
+ ];
1745
+ const t0 = performance.now(); const qRuns = 30;
1746
+ for (let i = 0; i < qRuns; i++) {
1747
+ const qp = queries[i % queries.length];
1748
+ let q = db.query('stress/qload');
1749
+ for (const f of qp.filters ?? []) q = q.where(f.field, f.op, f.value);
1750
+ if (qp.order) q = q.order(qp.order.field, qp.order.dir);
1751
+ if (qp.limit) q = q.limit(qp.limit);
1752
+ await q.get();
1753
+ setProgress('ql-prog', 50 + Math.round((i / qRuns) * 50));
1754
+ }
1755
+ const elapsedMs = performance.now() - t0;
1756
+ setStressResult('ql-result', fmtStressResult({ n: qRuns, elapsedMs, extraLines: [`records: ${n}`, `avg/query: ${(elapsedMs/qRuns).toFixed(1)}ms`] }));
1757
+ setProgress('ql-prog', 100);
1758
+ }
1759
+
1760
+ async function runBulkUpdate() {
1761
+ const keysPerBatch = Number(document.getElementById('bu-n').value);
1762
+ const batches = Number(document.getElementById('bu-b').value);
1763
+ setStressResult('bu-result', 'Runningโ€ฆ'); setProgress('bu-prog', 0);
1764
+ const t0 = performance.now();
1765
+ for (let b = 0; b < batches; b++) {
1766
+ const updates = {};
1767
+ for (let k = 0; k < keysPerBatch; k++) updates[`stress/bulk/k${k}`] = { batch: b, val: k * b };
1768
+ await db.update(updates);
1769
+ setProgress('bu-prog', Math.round(((b + 1) / batches) * 100));
1770
+ }
1771
+ const elapsedMs = performance.now() - t0;
1772
+ setStressResult('bu-result', fmtStressResult({ n: keysPerBatch * batches, elapsedMs, extraLines: [`batches: ${batches}`, `keys/batch: ${keysPerBatch}`] }));
1773
+ setProgress('bu-prog', 100); loadTree();
1774
+ }
1775
+
1776
+ async function runDeepPaths() {
1777
+ const n = Number(document.getElementById('dp-n').value);
1778
+ const depth = Number(document.getElementById('dp-d').value);
1779
+ setStressResult('dp-result', 'Runningโ€ฆ'); setProgress('dp-prog', 0);
1780
+ const t0 = performance.now(); const batch = 20;
1781
+ for (let i = 0; i < n; i += batch) {
1782
+ const end = Math.min(i + batch, n);
1783
+ await Promise.all(Array.from({ length: end - i }, (_, j) => {
1784
+ const idx = i + j;
1785
+ const segs = Array.from({ length: depth }, (_, d) => `l${d}_${idx % (10 + d)}`);
1786
+ return db.set('stress/deep/' + segs.join('/'), idx);
1787
+ }));
1788
+ setProgress('dp-prog', Math.round((end / n) * 100));
1789
+ }
1790
+ setStressResult('dp-result', fmtStressResult({ n, elapsedMs: performance.now() - t0, extraLines: [`depth: ${depth}`] }));
1791
+ setProgress('dp-prog', 100); loadTree();
1792
+ }
1793
+
1794
+ // โ”€โ”€ Streams โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1795
+ function fillCompact(path, age, count, key) {
1796
+ document.getElementById('st-compact-path').value = path;
1797
+ document.getElementById('st-compact-age').value = age;
1798
+ document.getElementById('st-compact-count').value = count;
1799
+ document.getElementById('st-compact-key').value = key;
1800
+ }
1801
+
1802
+ async function doStreamSnapshot() {
1803
+ const path = document.getElementById('st-snap-path').value.trim();
1804
+ if (!path) return showStatus('st-snap-status', 'Path required', false);
1805
+ const t0 = performance.now();
1806
+ try {
1807
+ const snap = await db.streamSnapshot(path);
1808
+ const el = document.getElementById('st-snap-result');
1809
+ if (snap) {
1810
+ showStatus('st-snap-status', `Snapshot at key ${snap.key} โ€” ${Object.keys(snap.data).length} entries`, true, performance.now() - t0);
1811
+ el.textContent = JSON.stringify(snap, null, 2);
1812
+ } else {
1813
+ showStatus('st-snap-status', 'No snapshot (not yet compacted)', true, performance.now() - t0);
1814
+ el.textContent = 'null';
1815
+ }
1816
+ el.style.display = 'block';
1817
+ } catch(e) { showStatus('st-snap-status', e.message, false, performance.now() - t0); }
1818
+ }
1819
+
1820
+ async function doStreamMaterialize() {
1821
+ const path = document.getElementById('st-snap-path').value.trim();
1822
+ const keepKey = document.getElementById('st-snap-keepkey').value.trim() || undefined;
1823
+ if (!path) return showStatus('st-snap-status', 'Path required', false);
1824
+ const t0 = performance.now();
1825
+ try {
1826
+ const view = await db.streamMaterialize(path, keepKey);
1827
+ const keys = Object.keys(view);
1828
+ showStatus('st-snap-status', `Materialized view โ€” ${keys.length} entries`, true, performance.now() - t0);
1829
+ const el = document.getElementById('st-snap-result');
1830
+ el.textContent = JSON.stringify(view, null, 2);
1831
+ el.style.display = 'block';
1832
+ } catch(e) { showStatus('st-snap-status', e.message, false, performance.now() - t0); }
1833
+ }
1834
+
1835
+ async function doStreamCompact() {
1836
+ const path = document.getElementById('st-compact-path').value.trim();
1837
+ const maxAge = document.getElementById('st-compact-age').value.trim();
1838
+ const maxCount = document.getElementById('st-compact-count').value.trim();
1839
+ const keepKey = document.getElementById('st-compact-key').value.trim();
1840
+ if (!path) return showStatus('st-compact-status', 'Path required', false);
1841
+ const opts = {};
1842
+ if (maxAge) opts.maxAge = Number(maxAge);
1843
+ if (maxCount) opts.maxCount = Number(maxCount);
1844
+ if (keepKey) opts.keepKey = keepKey;
1845
+ if (!Object.keys(opts).length) return showStatus('st-compact-status', 'At least one option required', false);
1846
+ const t0 = performance.now();
1847
+ try {
1848
+ const result = await db.streamCompact(path, opts);
1849
+ showStatus('st-compact-status', `Compacted โ€” folded ${result.deleted} events into snapshot (${result.snapshotSize} keys)`, true, performance.now() - t0);
1850
+ refreshPath(path);
1851
+ } catch(e) { showStatus('st-compact-status', e.message, false, performance.now() - t0); }
1852
+ }
1853
+
1854
+ async function doStreamReset() {
1855
+ const path = document.getElementById('st-compact-path').value.trim();
1856
+ if (!path) return showStatus('st-compact-status', 'Path required', false);
1857
+ if (!confirm(`Reset "${path}"? This deletes ALL events, snapshot, and consumer offsets.`)) return;
1858
+ try {
1859
+ await db.streamReset(path);
1860
+ showStatus('st-compact-status', `Reset "${path}" โ€” all events, snapshot, and offsets deleted`, true);
1861
+ refreshPath(path);
1862
+ } catch(e) { showStatus('st-compact-status', e.message, false); }
1863
+ }
1864
+
1865
+ function fillStreamPush(path, value, idem) {
1866
+ document.getElementById('st-push-path').value = path;
1867
+ document.getElementById('st-push-value').value = value;
1868
+ document.getElementById('st-push-idem').value = idem;
1869
+ }
1870
+
1871
+ async function doStreamPush() {
1872
+ const path = document.getElementById('st-push-path').value.trim();
1873
+ const raw = document.getElementById('st-push-value').value.trim();
1874
+ const idem = document.getElementById('st-push-idem').value.trim();
1875
+ let value;
1876
+ try { value = JSON.parse(raw); } catch { return showStatus('st-push-status', 'Invalid JSON', false); }
1877
+ const t0 = performance.now();
1878
+ try {
1879
+ const params = { path, value };
1880
+ if (idem) params.idempotencyKey = idem;
1881
+ const key = await db._send('push', params);
1882
+ showStatus('st-push-status', `Pushed โ†’ ${key}${idem ? ' (idempotent)' : ''}`, true, performance.now() - t0);
1883
+ refreshPath(path);
1884
+ } catch(e) { showStatus('st-push-status', e.message, false, performance.now() - t0); }
1885
+ }
1886
+
1887
+ async function doStreamRead() {
1888
+ const path = document.getElementById('st-read-path').value.trim();
1889
+ const groupId = document.getElementById('st-read-group').value.trim();
1890
+ const limit = Number(document.getElementById('st-read-limit').value) || 50;
1891
+ if (!path || !groupId) return showStatus('st-read-status', 'Path and group ID required', false);
1892
+ const t0 = performance.now();
1893
+ try {
1894
+ const events = await db.streamRead(path, groupId, limit);
1895
+ showStatus('st-read-status', `${events.length} events`, true, performance.now() - t0);
1896
+ const el = document.getElementById('st-read-result');
1897
+ el.textContent = JSON.stringify(events, null, 2);
1898
+ el.style.display = 'block';
1899
+ // Auto-fill ack key with last event
1900
+ if (events.length > 0) {
1901
+ document.getElementById('st-ack-key').value = events[events.length - 1].key;
1902
+ document.getElementById('st-ack-path').value = path;
1903
+ document.getElementById('st-ack-group').value = groupId;
1904
+ }
1905
+ } catch(e) { showStatus('st-read-status', e.message, false, performance.now() - t0); }
1906
+ }
1907
+
1908
+ async function doStreamAck() {
1909
+ const path = document.getElementById('st-ack-path').value.trim();
1910
+ const groupId = document.getElementById('st-ack-group').value.trim();
1911
+ const key = document.getElementById('st-ack-key').value.trim();
1912
+ if (!path || !groupId || !key) return showStatus('st-ack-status', 'All fields required', false);
1913
+ const t0 = performance.now();
1914
+ try {
1915
+ await db.streamAck(path, groupId, key);
1916
+ showStatus('st-ack-status', `Acked โ†’ ${key}`, true, performance.now() - t0);
1917
+ } catch(e) { showStatus('st-ack-status', e.message, false, performance.now() - t0); }
1918
+ }
1919
+
1920
+ const activeStreamSubs = {};
1921
+
1922
+ async function doStreamSub() {
1923
+ const path = document.getElementById('st-sub-path').value.trim();
1924
+ const groupId = document.getElementById('st-sub-group').value.trim();
1925
+ if (!path || !groupId) return;
1926
+ const subKey = `${path}:${groupId}`;
1927
+ if (activeStreamSubs[subKey]) return;
1928
+
1929
+ // Register callback
1930
+ if (!db._streamCbs) db._streamCbs = new Map();
1931
+ if (!db._streamCbs.has(subKey)) db._streamCbs.set(subKey, new Set());
1932
+
1933
+ const id = 'ssub-' + Math.random().toString(36).slice(2);
1934
+ const div = document.createElement('div');
1935
+ div.className = 'sub-item'; div.id = id;
1936
+ div.innerHTML = `
1937
+ <div class="sub-item-header">
1938
+ <b>${escHtml(path)}</b>
1939
+ <span style="color:#555;font-size:11px;margin-left:4px">[stream:${escHtml(groupId)}]</span>
1940
+ <button class="danger sm" onclick="doStreamUnsub('${id}','${subKey}','${path}','${groupId}')">โœ•</button>
1941
+ </div>
1942
+ <div class="sub-log" id="log-${id}"><div style="color:#555">Waiting for eventsโ€ฆ</div></div>`;
1943
+ document.getElementById('st-sub-list').appendChild(div);
1944
+
1945
+ const cb = (events) => {
1946
+ const logEl = document.getElementById('log-' + id);
1947
+ for (const ev of events) {
1948
+ const time = new Date().toLocaleTimeString();
1949
+ const entry = document.createElement('div');
1950
+ entry.innerHTML = `<span>${time}</span><span style="color:#4ec9b0;margin-right:6px">${escHtml(ev.key)}</span>${escHtml(JSON.stringify(ev.data))}`;
1951
+ logEl.prepend(entry);
1952
+ if (logEl.children.length > 100) logEl.lastChild.remove();
1953
+ }
1954
+ };
1955
+ db._streamCbs.get(subKey).add(cb);
1956
+
1957
+ try {
1958
+ await db.streamSub(path, groupId);
1959
+ activeStreamSubs[subKey] = { id, cb };
1960
+ } catch(e) {
1961
+ div.remove();
1962
+ db._streamCbs.get(subKey)?.delete(cb);
1963
+ }
1964
+ }
1965
+
1966
+ function doStreamUnsub(id, subKey, path, groupId) {
1967
+ const sub = activeStreamSubs[subKey];
1968
+ if (sub) {
1969
+ db._streamCbs?.get(subKey)?.delete(sub.cb);
1970
+ db.streamUnsub(path, groupId).catch(() => {});
1971
+ delete activeStreamSubs[subKey];
1972
+ }
1973
+ document.getElementById(id)?.remove();
1974
+ }
1975
+
1976
+ async function doStreamDemo() {
1977
+ const TOPIC = 'events/demo-orders';
1978
+ const el = document.getElementById('st-demo-result');
1979
+ el.style.display = 'block';
1980
+ el.textContent = '';
1981
+ const wait = (ms = 500) => new Promise(r => setTimeout(r, ms));
1982
+ const log = async (s, delay = 500) => {
1983
+ el.textContent += (el.textContent ? '\n' : '') + s;
1984
+ el.scrollTop = el.scrollHeight;
1985
+ if (delay > 0) await wait(delay);
1986
+ };
1987
+ const t0 = performance.now();
1988
+
1989
+ try {
1990
+ // Clean slate
1991
+ await db.streamReset(TOPIC);
1992
+ await log('๐Ÿงน Reset topic โ€” clean slate');
1993
+
1994
+ // 1. Push 5 order events
1995
+ const keys = [];
1996
+ for (let i = 1; i <= 5; i++) {
1997
+ const k = await db._send('push', { path: TOPIC, value: { orderId: `order-${i}`, amount: i * 100, status: 'created' } });
1998
+ keys.push(k);
1999
+ await log(`๐Ÿ“ฅ Pushed order-${i}: ${k.slice(0,8)} ($${i * 100})`, 300);
2000
+ }
2001
+
2002
+ // 2. Consumer group A reads all
2003
+ let eventsA = await db.streamRead(TOPIC, 'group-analytics', 10);
2004
+ await log(`\n๐Ÿ“Š Group "analytics" read ${eventsA.length} events`);
2005
+
2006
+ // 3. Consumer group B reads all
2007
+ let eventsB = await db.streamRead(TOPIC, 'group-billing', 10);
2008
+ await log(`๐Ÿ’ฐ Group "billing" read ${eventsB.length} events (same events โ€” fan-out!)`);
2009
+
2010
+ // 4. Analytics acks up to event 3
2011
+ await db.streamAck(TOPIC, 'group-analytics', keys[2]);
2012
+ await log(`\n๐Ÿ“Š Analytics acked up to ${keys[2].slice(0,8)} (3 of 5 processed)`);
2013
+
2014
+ // 5. Billing acks all
2015
+ await db.streamAck(TOPIC, 'group-billing', keys[4]);
2016
+ await log(`๐Ÿ’ฐ Billing acked up to ${keys[4].slice(0,8)} (all 5 processed)`);
2017
+
2018
+ // 6. Analytics re-reads โ€” should get remaining 2
2019
+ eventsA = await db.streamRead(TOPIC, 'group-analytics', 10);
2020
+ await log(`\n๐Ÿ“Š Analytics re-read: ${eventsA.length} unprocessed events remaining`);
2021
+ for (const ev of eventsA) {
2022
+ await log(` โ†’ ${ev.key.slice(0,8)}: order-${ev.data?.orderId?.split('-')[1]} ($${ev.data?.amount})`, 250);
2023
+ }
2024
+
2025
+ // 7. Billing re-reads โ€” should get 0
2026
+ eventsB = await db.streamRead(TOPIC, 'group-billing', 10);
2027
+ await log(`๐Ÿ’ฐ Billing re-read: ${eventsB.length} (all caught up)`);
2028
+
2029
+ // 8. Push more events
2030
+ await log('\n๐Ÿ“ฅ Pushing 3 more events...');
2031
+ for (let i = 6; i <= 8; i++) {
2032
+ await db._send('push', { path: TOPIC, value: { orderId: `order-${i}`, amount: i * 100, status: 'created' } });
2033
+ await log(` โ†’ order-${i} ($${i * 100})`, 250);
2034
+ }
2035
+
2036
+ // 9. Idempotent push
2037
+ const idemKey1 = await db._send('push', { path: TOPIC, value: { orderId: 'order-9', amount: 900 }, idempotencyKey: 'order-9-created' });
2038
+ await log(`\n๐Ÿ”‘ Idempotent push order-9: ${idemKey1.slice(0,8)}`);
2039
+ const idemKey2 = await db._send('push', { path: TOPIC, value: { orderId: 'order-9', amount: 900 }, idempotencyKey: 'order-9-created' });
2040
+ await log(`๐Ÿ”‘ Duplicate push order-9: ${idemKey2.slice(0,8)} (same key โ€” deduped!)`);
2041
+
2042
+ // 10. Compact โ€” fold old events into snapshot
2043
+ await log('\n๐Ÿ—œ๏ธ Compacting: keep last 3 events, keepKey=orderId...');
2044
+ const compactResult = await db.streamCompact(TOPIC, { maxCount: 3, keepKey: 'orderId' });
2045
+ await log(` Folded ${compactResult.deleted} events into snapshot (${compactResult.snapshotSize} unique orders)`);
2046
+
2047
+ // 11. View snapshot
2048
+ const snap = await db.streamSnapshot(TOPIC);
2049
+ await log(`\n๐Ÿ“ธ Snapshot has ${snap ? Object.keys(snap.data).length : 0} entries`);
2050
+
2051
+ // 12. Materialize โ€” merged view
2052
+ const view = await db.streamMaterialize(TOPIC, 'orderId');
2053
+ const viewKeys = Object.keys(view);
2054
+ await log(`๐Ÿ”ฎ Materialized view: ${viewKeys.length} total orders`);
2055
+ for (const k of viewKeys.slice(0, 5)) {
2056
+ await log(` โ†’ ${k}: $${view[k]?.amount}`, 200);
2057
+ }
2058
+ if (viewKeys.length > 5) await log(` ... and ${viewKeys.length - 5} more`, 200);
2059
+
2060
+ // Cleanup
2061
+ await db.streamReset(TOPIC);
2062
+ await log('\n๐Ÿงน Cleaned up topic');
2063
+
2064
+ const ms = (performance.now() - t0).toFixed(1);
2065
+ await log(`๐ŸŽ‰ Demo complete in ${ms}ms โ€” fan-out, offsets, idempotency, compaction, materialization`, 0);
2066
+ showStatus('st-demo-status', 'Demo complete', true, performance.now() - t0);
2067
+ } catch (e) {
2068
+ await log(`\nโŒ Error: ${e.message}`, 0);
2069
+ showStatus('st-demo-status', e.message, false);
2070
+ }
2071
+ }
2072
+
2073
+ // โ”€โ”€ MQ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2074
+ function fillMqPush(path, value, idem) {
2075
+ document.getElementById('mq-push-path').value = path;
2076
+ document.getElementById('mq-push-value').value = value;
2077
+ document.getElementById('mq-push-idem').value = idem;
2078
+ }
2079
+
2080
+ async function doMqPush() {
2081
+ const path = document.getElementById('mq-push-path').value.trim();
2082
+ const idem = document.getElementById('mq-push-idem').value.trim();
2083
+ if (!path) return showStatus('mq-push-status', 'Path required', false);
2084
+ const t0 = performance.now();
2085
+ try {
2086
+ const value = JSON.parse(document.getElementById('mq-push-value').value);
2087
+ const key = await db.mqPush(path, value, idem);
2088
+ showStatus('mq-push-status', `Pushed โ†’ ${key}`, true, performance.now() - t0);
2089
+ } catch (e) { showStatus('mq-push-status', e.message, false); }
2090
+ }
2091
+
2092
+ async function doMqFetch() {
2093
+ const path = document.getElementById('mq-fetch-path').value.trim();
2094
+ const count = parseInt(document.getElementById('mq-fetch-count').value) || 1;
2095
+ if (!path) return showStatus('mq-fetch-status', 'Path required', false);
2096
+ const t0 = performance.now();
2097
+ try {
2098
+ const msgs = await db.mqFetch(path, count);
2099
+ const el = document.getElementById('mq-fetch-result');
2100
+ el.style.display = 'block';
2101
+ el.textContent = JSON.stringify(msgs, null, 2);
2102
+ showStatus('mq-fetch-status', `${msgs.length} message(s) claimed`, true, performance.now() - t0);
2103
+ // Auto-fill ack key
2104
+ if (msgs.length > 0) {
2105
+ document.getElementById('mq-ack-path').value = path;
2106
+ document.getElementById('mq-ack-key').value = msgs[0].key;
2107
+ }
2108
+ } catch (e) { showStatus('mq-fetch-status', e.message, false); }
2109
+ }
2110
+
2111
+ async function doMqAck() {
2112
+ const path = document.getElementById('mq-ack-path').value.trim();
2113
+ const key = document.getElementById('mq-ack-key').value.trim();
2114
+ if (!path || !key) return showStatus('mq-ack-status', 'Path and key required', false);
2115
+ const t0 = performance.now();
2116
+ try {
2117
+ await db.mqAck(path, key);
2118
+ showStatus('mq-ack-status', `Acked ${key}`, true, performance.now() - t0);
2119
+ } catch (e) { showStatus('mq-ack-status', e.message, false); }
2120
+ }
2121
+
2122
+ async function doMqNack() {
2123
+ const path = document.getElementById('mq-ack-path').value.trim();
2124
+ const key = document.getElementById('mq-ack-key').value.trim();
2125
+ if (!path || !key) return showStatus('mq-ack-status', 'Path and key required', false);
2126
+ const t0 = performance.now();
2127
+ try {
2128
+ await db.mqNack(path, key);
2129
+ showStatus('mq-ack-status', `Nacked ${key} โ€” released to pending`, true, performance.now() - t0);
2130
+ } catch (e) { showStatus('mq-ack-status', e.message, false); }
2131
+ }
2132
+
2133
+ async function doMqPeek() {
2134
+ const path = document.getElementById('mq-peek-path').value.trim();
2135
+ const count = parseInt(document.getElementById('mq-peek-count').value) || 10;
2136
+ if (!path) return showStatus('mq-peek-status', 'Path required', false);
2137
+ const t0 = performance.now();
2138
+ try {
2139
+ const msgs = await db.mqPeek(path, count);
2140
+ const el = document.getElementById('mq-peek-result');
2141
+ el.style.display = 'block';
2142
+ el.textContent = JSON.stringify(msgs, null, 2);
2143
+ showStatus('mq-peek-status', `${msgs.length} message(s)`, true, performance.now() - t0);
2144
+ } catch (e) { showStatus('mq-peek-status', e.message, false); }
2145
+ }
2146
+
2147
+ async function doMqDlq() {
2148
+ const path = document.getElementById('mq-dlq-path').value.trim();
2149
+ if (!path) return showStatus('mq-dlq-status', 'Path required', false);
2150
+ const t0 = performance.now();
2151
+ try {
2152
+ const msgs = await db.mqDlq(path);
2153
+ const el = document.getElementById('mq-dlq-result');
2154
+ el.style.display = 'block';
2155
+ el.textContent = msgs.length ? JSON.stringify(msgs, null, 2) : '(empty)';
2156
+ showStatus('mq-dlq-status', `${msgs.length} dead letter(s)`, true, performance.now() - t0);
2157
+ } catch (e) { showStatus('mq-dlq-status', e.message, false); }
2158
+ }
2159
+
2160
+ async function doMqPurge() {
2161
+ const path = document.getElementById('mq-purge-path').value.trim();
2162
+ if (!path) return showStatus('mq-purge-status', 'Path required', false);
2163
+ const t0 = performance.now();
2164
+ try {
2165
+ const count = await db.mqPurge(path);
2166
+ showStatus('mq-purge-status', `Purged ${count} message(s)`, true, performance.now() - t0);
2167
+ } catch (e) { showStatus('mq-purge-status', e.message, false); }
2168
+ }
2169
+
2170
+ async function doMqDemo() {
2171
+ const Q = 'queues/demo';
2172
+ const el = document.getElementById('mq-demo-result');
2173
+ el.style.display = 'block';
2174
+ el.textContent = '';
2175
+ const wait = (ms = 500) => new Promise(r => setTimeout(r, ms));
2176
+ const log = async (s, delay = 500) => {
2177
+ el.textContent += (el.textContent ? '\n' : '') + s;
2178
+ el.scrollTop = el.scrollHeight;
2179
+ if (delay > 0) await wait(delay);
2180
+ };
2181
+ const t0 = performance.now();
2182
+
2183
+ try {
2184
+ await db.mqPurge(Q, { all: true });
2185
+ await log('๐Ÿงน Purged queue โ€” clean slate');
2186
+
2187
+ // 1. Push 5 jobs
2188
+ const keys = [];
2189
+ for (let i = 1; i <= 5; i++) {
2190
+ const k = await db.mqPush(Q, { task: `job-${i}`, priority: i > 3 ? 'high' : 'normal' });
2191
+ keys.push(k);
2192
+ await log(`๐Ÿ“ฅ Pushed job-${i}: ${k.slice(0,8)}`, 300);
2193
+ }
2194
+
2195
+ // 2. Peek โ€” all pending
2196
+ let peek = await db.mqPeek(Q, 10);
2197
+ await log(`\n๐Ÿ“‹ Peek: ${peek.length} msgs โ€” all ${peek.map(m => m.status).join(', ')}`);
2198
+
2199
+ // 3. Worker 1 fetches 3
2200
+ const batch1 = await db.mqFetch(Q, 3);
2201
+ await log(`\n๐Ÿ‘ท Worker 1 claimed ${batch1.length}: ${batch1.map(m => m.key.slice(0,8)).join(', ')}`);
2202
+
2203
+ // 4. Worker 2 fetches remaining
2204
+ const batch2 = await db.mqFetch(Q, 5);
2205
+ await log(`๐Ÿ‘ท Worker 2 claimed ${batch2.length}: ${batch2.map(m => m.key.slice(0,8)).join(', ')}`);
2206
+
2207
+ // 5. Worker 3 gets nothing
2208
+ const batch3 = await db.mqFetch(Q, 5);
2209
+ await log(`๐Ÿ‘ท Worker 3 claimed ${batch3.length} (none left!)`);
2210
+
2211
+ // 6. Peek โ€” all inflight
2212
+ peek = await db.mqPeek(Q, 10);
2213
+ await log(`๐Ÿ“‹ Peek: all ${peek.map(m => m.status).join(', ')}`);
2214
+
2215
+ // 7. Worker 1: ack 1 & 3, nack 2
2216
+ await db.mqAck(Q, batch1[0].key);
2217
+ await log(`\nโœ… Acked ${batch1[0].key.slice(0,8)} (job-1 done)`);
2218
+
2219
+ await db.mqNack(Q, batch1[1].key);
2220
+ await log(`๐Ÿ”„ Nacked ${batch1[1].key.slice(0,8)} (job-2 โ†’ back to pending)`);
2221
+
2222
+ await db.mqAck(Q, batch1[2].key);
2223
+ await log(`โœ… Acked ${batch1[2].key.slice(0,8)} (job-3 done)`);
2224
+
2225
+ // 8. Worker 2: ack both
2226
+ await db.mqAck(Q, batch2[0].key);
2227
+ await log(`โœ… Acked ${batch2[0].key.slice(0,8)} (job-4 done)`);
2228
+ await db.mqAck(Q, batch2[1].key);
2229
+ await log(`โœ… Acked ${batch2[1].key.slice(0,8)} (job-5 done)`);
2230
+
2231
+ // 9. Peek โ€” only job-2 remains
2232
+ peek = await db.mqPeek(Q, 10);
2233
+ await log(`\n๐Ÿ“‹ Remaining: ${peek.length} msg โ€” status: ${peek[0]?.status}, deliveries: ${peek[0]?.deliveryCount}`);
2234
+
2235
+ // 10. Retry job-2 multiple times
2236
+ const retry1 = await db.mqFetch(Q, 1);
2237
+ await log(`\n๐Ÿ”„ Retry 1: fetched, deliveryCount=${retry1[0].deliveryCount}`);
2238
+ await db.mqNack(Q, retry1[0].key);
2239
+ await log(` โ†’ nacked back to pending`, 300);
2240
+
2241
+ const retry2 = await db.mqFetch(Q, 1);
2242
+ await log(`๐Ÿ”„ Retry 2: fetched, deliveryCount=${retry2[0].deliveryCount}`);
2243
+ await db.mqNack(Q, retry2[0].key);
2244
+ await log(` โ†’ nacked back to pending`, 300);
2245
+
2246
+ const retry3 = await db.mqFetch(Q, 1);
2247
+ await log(`๐Ÿ”„ Retry 3: fetched, deliveryCount=${retry3[0].deliveryCount}`);
2248
+ await log(` โ†’ simulating worker crash (left inflight)...`, 300);
2249
+
2250
+ // 11. State check
2251
+ await log(`\nโณ In production, sweep reclaims expired inflight msgs every 60s`);
2252
+ peek = await db.mqPeek(Q, 10);
2253
+ await log(`๐Ÿ“‹ Peek: ${peek.length} msg โ€” status: ${peek[0]?.status}, deliveries: ${peek[0]?.deliveryCount}`);
2254
+ await log(` After maxDeliveries (default 5), sweep moves to DLQ`);
2255
+
2256
+ // 12. Check DLQ
2257
+ const dlq = await db.mqDlq(Q);
2258
+ await log(`\n๐Ÿ’€ DLQ: ${dlq.length} dead letters`);
2259
+
2260
+ // Cleanup
2261
+ await db.mqPurge(Q, { all: true });
2262
+ await log(`\n๐Ÿงน Cleaned up queue`);
2263
+
2264
+ const ms = (performance.now() - t0).toFixed(1);
2265
+ await log(`๐ŸŽ‰ Demo complete in ${ms}ms โ€” 5 pushed, 4 acked, 1 retried 3x`, 0);
2266
+ showStatus('mq-demo-status', `Demo complete`, true, performance.now() - t0);
2267
+ } catch (e) {
2268
+ await log(`\nโŒ Error: ${e.message}`, 0);
2269
+ showStatus('mq-demo-status', e.message, false);
2270
+ }
2271
+ }
2272
+
2273
+ // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2274
+ function showStatus(id, msg, ok, ms) {
2275
+ const el = document.getElementById(id);
2276
+ el.className = 'status ' + (ok ? 'ok' : 'err');
2277
+ el.textContent = ms != null ? `${msg} (${ms.toFixed(1)}ms)` : msg;
2278
+ }
2279
+ </script>
2280
+ </body>
2281
+ </html>