@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
@@ -0,0 +1,515 @@
1
+ import type { QueryFilter, OrderClause, BatchOp } from '../shared/protocol.ts';
2
+ import { pathKey } from '../shared/pathUtils.ts';
3
+
4
+ export class BodClientOptions {
5
+ url: string = 'ws://localhost:4400';
6
+ auth?: () => string | Promise<string>;
7
+ reconnect: boolean = true;
8
+ reconnectInterval: number = 1000;
9
+ maxReconnectInterval: number = 30000;
10
+ }
11
+
12
+ type PendingRequest = {
13
+ resolve: (data: unknown) => void;
14
+ reject: (err: Error) => void;
15
+ };
16
+
17
+ export class BodClient {
18
+ readonly options: BodClientOptions;
19
+ private ws: WebSocket | null = null;
20
+ private msgId = 0;
21
+ private pending = new Map<string, PendingRequest>();
22
+ private valueCbs = new Map<string, Set<(snap: ValueSnapshot) => void>>();
23
+ private childCbs = new Map<string, Set<(event: ChildEvent) => void>>();
24
+ private activeSubs = new Set<string>(); // tracks 'value:path' and 'child:path' keys
25
+ private streamCbs = new Map<string, Set<(events: Array<{ key: string; data: unknown }>) => void>>();
26
+ private activeStreamSubs = new Set<string>(); // tracks 'path:groupId' keys
27
+ private closed = false;
28
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
29
+ private reconnectDelay: number;
30
+ private connectPromise: Promise<void> | null = null;
31
+
32
+ constructor(options?: Partial<BodClientOptions>) {
33
+ this.options = { ...new BodClientOptions(), ...options };
34
+ this.reconnectDelay = this.options.reconnectInterval;
35
+ }
36
+
37
+ async connect(): Promise<void> {
38
+ if (this.ws?.readyState === WebSocket.OPEN) return;
39
+ if (this.connectPromise) return this.connectPromise;
40
+
41
+ this.closed = false;
42
+ this.connectPromise = new Promise<void>((resolve, reject) => {
43
+ const ws = new WebSocket(this.options.url);
44
+
45
+ ws.onopen = async () => {
46
+ this.ws = ws;
47
+ this.reconnectDelay = this.options.reconnectInterval;
48
+
49
+ try {
50
+ // Auth if configured
51
+ if (this.options.auth) {
52
+ const token = await this.options.auth();
53
+ await this.send('auth', { token });
54
+ }
55
+ // Re-subscribe all active subscriptions
56
+ for (const key of this.activeSubs) {
57
+ const [event, ...pathParts] = key.split(':');
58
+ const path = pathParts.join(':');
59
+ await this.send('sub', { path, event });
60
+ }
61
+ // Re-subscribe stream subs
62
+ for (const key of this.activeStreamSubs) {
63
+ const [path, groupId] = this.splitStreamKey(key);
64
+ await this.send('stream-sub', { path, groupId });
65
+ }
66
+ this.connectPromise = null;
67
+ resolve();
68
+ } catch (e) {
69
+ this.connectPromise = null;
70
+ reject(e);
71
+ }
72
+ };
73
+
74
+ ws.onmessage = (e: MessageEvent) => {
75
+ const msg = JSON.parse(typeof e.data === 'string' ? e.data : e.data.toString());
76
+
77
+ // Push message (subscription)
78
+ if (msg.type === 'value') {
79
+ const cbs = this.valueCbs.get(msg.path);
80
+ if (cbs) {
81
+ const snap = new ValueSnapshot(msg.path, msg.data);
82
+ for (const cb of cbs) cb(snap);
83
+ }
84
+ return;
85
+ }
86
+ if (msg.type === 'stream') {
87
+ const streamKey = `${msg.path}:${msg.groupId}`;
88
+ const cbs = this.streamCbs.get(streamKey);
89
+ if (cbs) {
90
+ for (const cb of cbs) cb(msg.events);
91
+ }
92
+ return;
93
+ }
94
+ if (msg.type === 'child') {
95
+ const cbs = this.childCbs.get(msg.path);
96
+ if (cbs) {
97
+ const event: ChildEvent = {
98
+ type: msg.event,
99
+ key: msg.key,
100
+ path: msg.path + '/' + msg.key,
101
+ val: () => msg.data,
102
+ };
103
+ for (const cb of cbs) cb(event);
104
+ }
105
+ return;
106
+ }
107
+
108
+ // Response message
109
+ const p = this.pending.get(msg.id);
110
+ if (p) {
111
+ this.pending.delete(msg.id);
112
+ if (msg.ok) p.resolve(msg.data ?? null);
113
+ else p.reject(new Error(msg.error || 'Unknown error'));
114
+ }
115
+ };
116
+
117
+ ws.onclose = () => {
118
+ this.ws = null;
119
+ // Reject all pending requests
120
+ for (const [, p] of this.pending) {
121
+ p.reject(new Error('Connection closed'));
122
+ }
123
+ this.pending.clear();
124
+
125
+ if (!this.closed && this.options.reconnect) {
126
+ this.scheduleReconnect();
127
+ }
128
+
129
+ if (!this.connectPromise) return;
130
+ this.connectPromise = null;
131
+ // Only reject if this was the initial connect
132
+ };
133
+
134
+ ws.onerror = () => {
135
+ // onclose will fire after this
136
+ };
137
+ });
138
+
139
+ return this.connectPromise;
140
+ }
141
+
142
+ disconnect() {
143
+ this.closed = true;
144
+ if (this.reconnectTimer) {
145
+ clearTimeout(this.reconnectTimer);
146
+ this.reconnectTimer = null;
147
+ }
148
+ this.ws?.close();
149
+ this.ws = null;
150
+ this.pending.clear();
151
+ }
152
+
153
+ private scheduleReconnect() {
154
+ if (this.closed) return;
155
+ this.reconnectTimer = setTimeout(() => {
156
+ this.reconnectTimer = null;
157
+ this.connect().catch(() => {
158
+ // Exponential backoff
159
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.options.maxReconnectInterval);
160
+ });
161
+ }, this.reconnectDelay);
162
+ }
163
+
164
+ private nextId(): string {
165
+ return String(++this.msgId);
166
+ }
167
+
168
+ private send(op: string, params: Record<string, unknown> = {}): Promise<unknown> {
169
+ return new Promise((resolve, reject) => {
170
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
171
+ return reject(new Error('Not connected'));
172
+ }
173
+ const id = this.nextId();
174
+ this.pending.set(id, { resolve, reject });
175
+ this.ws.send(JSON.stringify({ id, op, ...params }));
176
+ });
177
+ }
178
+
179
+ // CRUD
180
+
181
+ async get(path: string): Promise<unknown> {
182
+ return this.send('get', { path });
183
+ }
184
+
185
+ async getShallow(path?: string): Promise<Array<{ key: string; isLeaf: boolean; value?: unknown }>> {
186
+ return this.send('get', { path: path ?? '', shallow: true }) as Promise<Array<{ key: string; isLeaf: boolean; value?: unknown }>>;
187
+ }
188
+
189
+ async set(path: string, value: unknown, opts?: { ttl?: number }): Promise<void> {
190
+ await this.send('set', { path, value, ttl: opts?.ttl });
191
+ }
192
+
193
+ async update(updates: Record<string, unknown>): Promise<void> {
194
+ await this.send('update', { updates });
195
+ }
196
+
197
+ async delete(path: string): Promise<void> {
198
+ await this.send('delete', { path });
199
+ }
200
+
201
+ async batch(operations: BatchOp[]): Promise<unknown> {
202
+ return this.send('batch', { operations });
203
+ }
204
+
205
+ async push(path: string, value: unknown, opts?: { idempotencyKey?: string }): Promise<string> {
206
+ return this.send('push', { path, value, idempotencyKey: opts?.idempotencyKey }) as Promise<string>;
207
+ }
208
+
209
+ // Query
210
+
211
+ query(path: string): ClientQueryBuilder {
212
+ return new ClientQueryBuilder(this, path);
213
+ }
214
+
215
+ /** @internal — used by ClientQueryBuilder */
216
+ _query(path: string, filters?: QueryFilter[], order?: OrderClause, limit?: number, offset?: number): Promise<unknown> {
217
+ return this.send('query', { path, filters, order, limit, offset });
218
+ }
219
+
220
+ // Subscriptions
221
+
222
+ on(path: string, cb: (snap: ValueSnapshot) => void): () => void {
223
+ const subKey = `value:${path}`;
224
+
225
+ if (!this.valueCbs.has(path)) this.valueCbs.set(path, new Set());
226
+ this.valueCbs.get(path)!.add(cb);
227
+
228
+ // Subscribe on server if first listener for this path
229
+ if (!this.activeSubs.has(subKey)) {
230
+ this.activeSubs.add(subKey);
231
+ if (this.ws?.readyState === WebSocket.OPEN) {
232
+ this.send('sub', { path, event: 'value' }).catch(() => {});
233
+ }
234
+ }
235
+
236
+ return () => {
237
+ this.valueCbs.get(path)?.delete(cb);
238
+ if (this.valueCbs.get(path)?.size === 0) {
239
+ this.valueCbs.delete(path);
240
+ this.activeSubs.delete(subKey);
241
+ if (this.ws?.readyState === WebSocket.OPEN) {
242
+ this.send('unsub', { path, event: 'value' }).catch(() => {});
243
+ }
244
+ }
245
+ };
246
+ }
247
+
248
+ onChild(path: string, cb: (event: ChildEvent) => void): () => void {
249
+ const subKey = `child:${path}`;
250
+
251
+ if (!this.childCbs.has(path)) this.childCbs.set(path, new Set());
252
+ this.childCbs.get(path)!.add(cb);
253
+
254
+ if (!this.activeSubs.has(subKey)) {
255
+ this.activeSubs.add(subKey);
256
+ if (this.ws?.readyState === WebSocket.OPEN) {
257
+ this.send('sub', { path, event: 'child' }).catch(() => {});
258
+ }
259
+ }
260
+
261
+ return () => {
262
+ this.childCbs.get(path)?.delete(cb);
263
+ if (this.childCbs.get(path)?.size === 0) {
264
+ this.childCbs.delete(path);
265
+ this.activeSubs.delete(subKey);
266
+ if (this.ws?.readyState === WebSocket.OPEN) {
267
+ this.send('unsub', { path, event: 'child' }).catch(() => {});
268
+ }
269
+ }
270
+ };
271
+ }
272
+
273
+ // MQ
274
+
275
+ mq(queue: string): MQReader {
276
+ return new MQReader(this, queue);
277
+ }
278
+
279
+ /** @internal */
280
+ _mqSend(op: string, params: Record<string, unknown>): Promise<unknown> {
281
+ return this.send(op, params);
282
+ }
283
+
284
+ // Streams
285
+
286
+ stream(path: string, groupId: string): StreamReader {
287
+ return new StreamReader(this, path, groupId);
288
+ }
289
+
290
+ /** @internal */
291
+ _streamOn(path: string, groupId: string, cb: (events: Array<{ key: string; data: unknown }>) => void): () => void {
292
+ const streamKey = `${path}:${groupId}`;
293
+ if (!this.streamCbs.has(streamKey)) this.streamCbs.set(streamKey, new Set());
294
+ this.streamCbs.get(streamKey)!.add(cb);
295
+
296
+ if (!this.activeStreamSubs.has(streamKey)) {
297
+ this.activeStreamSubs.add(streamKey);
298
+ if (this.ws?.readyState === WebSocket.OPEN) {
299
+ this.send('stream-sub', { path, groupId }).catch(() => {});
300
+ }
301
+ }
302
+
303
+ return () => {
304
+ this.streamCbs.get(streamKey)?.delete(cb);
305
+ if (this.streamCbs.get(streamKey)?.size === 0) {
306
+ this.streamCbs.delete(streamKey);
307
+ this.activeStreamSubs.delete(streamKey);
308
+ if (this.ws?.readyState === WebSocket.OPEN) {
309
+ this.send('stream-unsub', { path, groupId }).catch(() => {});
310
+ }
311
+ }
312
+ };
313
+ }
314
+
315
+ /** @internal */
316
+ _streamRead(path: string, groupId: string, limit?: number): Promise<unknown> {
317
+ return this.send('stream-read', { path, groupId, limit });
318
+ }
319
+
320
+ /** @internal */
321
+ _streamAck(path: string, groupId: string, key: string): Promise<void> {
322
+ return this.send('stream-ack', { path, groupId, key }) as Promise<void>;
323
+ }
324
+
325
+ private splitStreamKey(key: string): [string, string] {
326
+ const lastColon = key.lastIndexOf(':');
327
+ return [key.slice(0, lastColon), key.slice(lastColon + 1)];
328
+ }
329
+
330
+ // FTS
331
+
332
+ async search(opts: { text: string; path?: string; limit?: number }): Promise<Array<{ path: string; data: unknown; rank: number }>> {
333
+ return this.send('fts-search', opts) as Promise<Array<{ path: string; data: unknown; rank: number }>>;
334
+ }
335
+
336
+ async index(path: string, content: string): Promise<void>;
337
+ async index(path: string, fields: string[]): Promise<void>;
338
+ async index(path: string, contentOrFields: string | string[]): Promise<void> {
339
+ const params: Record<string, unknown> = { path };
340
+ if (Array.isArray(contentOrFields)) params.fields = contentOrFields;
341
+ else params.content = contentOrFields;
342
+ await this.send('fts-index', params);
343
+ }
344
+
345
+ // Vectors
346
+
347
+ async vectorSearch(opts: { query: number[]; path?: string; limit?: number; threshold?: number }): Promise<Array<{ path: string; data: unknown; score: number }>> {
348
+ return this.send('vector-search', opts) as Promise<Array<{ path: string; data: unknown; score: number }>>;
349
+ }
350
+
351
+ async vectorStore(path: string, embedding: number[]): Promise<void> {
352
+ await this.send('vector-store', { path, embedding });
353
+ }
354
+
355
+ // Stream extended
356
+
357
+ async streamSnapshot(path: string): Promise<unknown> {
358
+ return this.send('stream-snapshot', { path });
359
+ }
360
+
361
+ async streamMaterialize(path: string, opts?: { keepKey?: string }): Promise<Record<string, unknown>> {
362
+ return this.send('stream-materialize', { path, keepKey: opts?.keepKey }) as Promise<Record<string, unknown>>;
363
+ }
364
+
365
+ async streamCompact(path: string, opts?: { maxAge?: number; maxCount?: number; keepKey?: string }): Promise<unknown> {
366
+ return this.send('stream-compact', { path, ...opts });
367
+ }
368
+
369
+ async streamReset(path: string): Promise<void> {
370
+ await this.send('stream-reset', { path });
371
+ }
372
+
373
+ get connected(): boolean {
374
+ return this.ws?.readyState === WebSocket.OPEN;
375
+ }
376
+ }
377
+
378
+ export class ValueSnapshot {
379
+ constructor(
380
+ readonly path: string,
381
+ private data: unknown,
382
+ ) {}
383
+
384
+ val(): unknown { return this.data; }
385
+ get key(): string { return pathKey(this.path); }
386
+ exists(): boolean { return this.data !== null && this.data !== undefined; }
387
+ }
388
+
389
+ export interface ChildEvent {
390
+ type: 'added' | 'changed' | 'removed';
391
+ key: string;
392
+ path: string;
393
+ val: () => unknown;
394
+ }
395
+
396
+ export interface StreamEventSnapshot {
397
+ key: string;
398
+ val: () => unknown;
399
+ }
400
+
401
+ export class StreamReader {
402
+ constructor(
403
+ private client: BodClient,
404
+ private path: string,
405
+ private groupId: string,
406
+ ) {}
407
+
408
+ on(cb: (events: StreamEventSnapshot[]) => void): () => void {
409
+ return this.client._streamOn(this.path, this.groupId, (events) => {
410
+ cb(events.map(e => ({ key: e.key, val: () => e.data })));
411
+ });
412
+ }
413
+
414
+ async read(limit?: number): Promise<StreamEventSnapshot[]> {
415
+ const events = await this.client._streamRead(this.path, this.groupId, limit) as Array<{ key: string; data: unknown }>;
416
+ return events.map(e => ({ key: e.key, val: () => e.data }));
417
+ }
418
+
419
+ async ack(key: string): Promise<void> {
420
+ return this.client._streamAck(this.path, this.groupId, key);
421
+ }
422
+ }
423
+
424
+ export class ClientQueryBuilder {
425
+ private filters: QueryFilter[] = [];
426
+ private orderClause?: OrderClause;
427
+ private limitVal?: number;
428
+ private offsetVal?: number;
429
+
430
+ constructor(
431
+ private client: BodClient,
432
+ private basePath: string,
433
+ ) {}
434
+
435
+ where(field: string, op: QueryFilter['op'], value: unknown): ClientQueryBuilder {
436
+ this.filters.push({ field, op, value });
437
+ return this;
438
+ }
439
+
440
+ order(field: string, dir: 'asc' | 'desc' = 'asc'): ClientQueryBuilder {
441
+ this.orderClause = { field, dir };
442
+ return this;
443
+ }
444
+
445
+ limit(n: number): ClientQueryBuilder {
446
+ this.limitVal = n;
447
+ return this;
448
+ }
449
+
450
+ offset(n: number): ClientQueryBuilder {
451
+ this.offsetVal = n;
452
+ return this;
453
+ }
454
+
455
+ get(): Promise<unknown> {
456
+ return this.client._query(
457
+ this.basePath,
458
+ this.filters.length ? this.filters : undefined,
459
+ this.orderClause,
460
+ this.limitVal,
461
+ this.offsetVal,
462
+ );
463
+ }
464
+ }
465
+
466
+ export class MQMessageSnapshot {
467
+ constructor(
468
+ readonly key: string,
469
+ readonly path: string,
470
+ readonly deliveryCount: number,
471
+ readonly status: 'pending' | 'inflight' | 'dead',
472
+ private data: unknown,
473
+ ) {}
474
+
475
+ val(): unknown { return this.data; }
476
+ exists(): boolean { return this.data !== null && this.data !== undefined; }
477
+ }
478
+
479
+ export class MQReader {
480
+ constructor(
481
+ private client: BodClient,
482
+ private queue: string,
483
+ ) {}
484
+
485
+ async push(value: unknown, opts?: { idempotencyKey?: string }): Promise<string> {
486
+ return this.client._mqSend('mq-push', { path: this.queue, value, idempotencyKey: opts?.idempotencyKey }) as Promise<string>;
487
+ }
488
+
489
+ async fetch(count?: number): Promise<MQMessageSnapshot[]> {
490
+ const msgs = await this.client._mqSend('mq-fetch', { path: this.queue, count }) as Array<{ key: string; path: string; data: unknown; deliveryCount: number }>;
491
+ return msgs.map(m => new MQMessageSnapshot(m.key, m.path, m.deliveryCount, m.status, m.data));
492
+ }
493
+
494
+ async ack(key: string): Promise<void> {
495
+ await this.client._mqSend('mq-ack', { path: this.queue, key });
496
+ }
497
+
498
+ async nack(key: string): Promise<void> {
499
+ await this.client._mqSend('mq-nack', { path: this.queue, key });
500
+ }
501
+
502
+ async peek(count?: number): Promise<MQMessageSnapshot[]> {
503
+ const msgs = await this.client._mqSend('mq-peek', { path: this.queue, count }) as Array<{ key: string; path: string; data: unknown; deliveryCount: number }>;
504
+ return msgs.map(m => new MQMessageSnapshot(m.key, m.path, m.deliveryCount, m.status, m.data));
505
+ }
506
+
507
+ async dlq(): Promise<MQMessageSnapshot[]> {
508
+ const msgs = await this.client._mqSend('mq-dlq', { path: this.queue }) as Array<{ key: string; path: string; data: unknown; deliveryCount: number }>;
509
+ return msgs.map(m => new MQMessageSnapshot(m.key, m.path, m.deliveryCount, m.status, m.data));
510
+ }
511
+
512
+ async purge(opts?: { all?: boolean }): Promise<number> {
513
+ return this.client._mqSend('mq-purge', { path: this.queue, all: opts?.all }) as Promise<number>;
514
+ }
515
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * React hooks for BodDB.
3
+ * Requires React 18+ as a peer dependency.
4
+ */
5
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
6
+ import type { BodClient, ValueSnapshot, ChildEvent } from '../client/BodClient.ts';
7
+ import type { QueryFilter, OrderClause } from '../shared/protocol.ts';
8
+
9
+ /** Subscribe to a value at a path. Returns current value (null while loading). */
10
+ export function useValue(client: BodClient, path: string): { data: unknown; loading: boolean } {
11
+ const [data, setData] = useState<unknown>(null);
12
+ const [loading, setLoading] = useState(true);
13
+
14
+ useEffect(() => {
15
+ let mounted = true;
16
+
17
+ // Initial fetch
18
+ client.get(path).then((val) => {
19
+ if (mounted) { setData(val); setLoading(false); }
20
+ }).catch(() => {
21
+ if (mounted) setLoading(false);
22
+ });
23
+
24
+ // Subscribe
25
+ const off = client.on(path, (snap: ValueSnapshot) => {
26
+ if (mounted) { setData(snap.val()); setLoading(false); }
27
+ });
28
+
29
+ return () => { mounted = false; off(); };
30
+ }, [client, path]);
31
+
32
+ return { data, loading };
33
+ }
34
+
35
+ /** Subscribe to child events at a path. Returns map of key → value. */
36
+ export function useChildren(client: BodClient, path: string): {
37
+ children: Map<string, unknown>;
38
+ loading: boolean;
39
+ } {
40
+ const [children, setChildren] = useState<Map<string, unknown>>(new Map());
41
+ const [loading, setLoading] = useState(true);
42
+
43
+ useEffect(() => {
44
+ let mounted = true;
45
+
46
+ // Initial fetch
47
+ client.get(path).then((val) => {
48
+ if (!mounted) return;
49
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
50
+ setChildren(new Map(Object.entries(val as Record<string, unknown>)));
51
+ }
52
+ setLoading(false);
53
+ }).catch(() => {
54
+ if (mounted) setLoading(false);
55
+ });
56
+
57
+ // Subscribe to child events
58
+ const off = client.onChild(path, (event: ChildEvent) => {
59
+ if (!mounted) return;
60
+ setChildren((prev) => {
61
+ const next = new Map(prev);
62
+ if (event.type === 'removed') {
63
+ next.delete(event.key);
64
+ } else {
65
+ next.set(event.key, event.val());
66
+ }
67
+ return next;
68
+ });
69
+ });
70
+
71
+ return () => { mounted = false; off(); };
72
+ }, [client, path]);
73
+
74
+ return { children, loading };
75
+ }
76
+
77
+ /** Execute a query. Re-runs when deps change. */
78
+ export function useQuery(
79
+ client: BodClient,
80
+ path: string,
81
+ build?: (q: { where: (f: string, op: string, v: unknown) => any; order: (f: string, d?: string) => any; limit: (n: number) => any; offset: (n: number) => any }) => any,
82
+ deps: unknown[] = [],
83
+ ): { data: unknown[]; loading: boolean } {
84
+ const [data, setData] = useState<unknown[]>([]);
85
+ const [loading, setLoading] = useState(true);
86
+
87
+ useEffect(() => {
88
+ let mounted = true;
89
+ setLoading(true);
90
+
91
+ let q = client.query(path);
92
+ if (build) q = build(q) ?? q;
93
+ q.get().then((result: unknown) => {
94
+ if (mounted) { setData(result as unknown[]); setLoading(false); }
95
+ }).catch(() => {
96
+ if (mounted) setLoading(false);
97
+ });
98
+
99
+ return () => { mounted = false; };
100
+ }, [client, path, ...deps]);
101
+
102
+ return { data, loading };
103
+ }
104
+
105
+ /** Mutation helpers bound to a client. */
106
+ export function useMutation(client: BodClient) {
107
+ const set = useCallback(
108
+ (path: string, value: unknown) => client.set(path, value),
109
+ [client],
110
+ );
111
+ const update = useCallback(
112
+ (updates: Record<string, unknown>) => client.update(updates),
113
+ [client],
114
+ );
115
+ const del = useCallback(
116
+ (path: string) => client.delete(path),
117
+ [client],
118
+ );
119
+
120
+ return { set, update, delete: del };
121
+ }