@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.
- package/.claude/settings.local.json +23 -0
- package/.claude/skills/config-file.md +54 -0
- package/.claude/skills/deploying-bod-db.md +29 -0
- package/.claude/skills/developing-bod-db.md +127 -0
- package/.claude/skills/using-bod-db.md +403 -0
- package/CLAUDE.md +110 -0
- package/README.md +252 -0
- package/admin/rules.ts +12 -0
- package/admin/server.ts +523 -0
- package/admin/ui.html +2281 -0
- package/cli.ts +177 -0
- package/client.ts +2 -0
- package/config.ts +20 -0
- package/deploy/.env.example +1 -0
- package/deploy/base.yaml +18 -0
- package/deploy/boddb-logs.yaml +10 -0
- package/deploy/boddb.yaml +10 -0
- package/deploy/demo.html +196 -0
- package/deploy/deploy.ts +32 -0
- package/deploy/prod-logs.config.ts +15 -0
- package/deploy/prod.config.ts +15 -0
- package/index.ts +20 -0
- package/mcp.ts +78 -0
- package/package.json +29 -0
- package/react.ts +1 -0
- package/src/client/BodClient.ts +515 -0
- package/src/react/hooks.ts +121 -0
- package/src/server/BodDB.ts +319 -0
- package/src/server/ExpressionRules.ts +250 -0
- package/src/server/FTSEngine.ts +76 -0
- package/src/server/FileAdapter.ts +116 -0
- package/src/server/MCPAdapter.ts +409 -0
- package/src/server/MQEngine.ts +286 -0
- package/src/server/QueryEngine.ts +45 -0
- package/src/server/RulesEngine.ts +108 -0
- package/src/server/StorageEngine.ts +464 -0
- package/src/server/StreamEngine.ts +320 -0
- package/src/server/SubscriptionEngine.ts +120 -0
- package/src/server/Transport.ts +479 -0
- package/src/server/VectorEngine.ts +115 -0
- package/src/shared/errors.ts +15 -0
- package/src/shared/pathUtils.ts +94 -0
- package/src/shared/protocol.ts +59 -0
- package/src/shared/transforms.ts +99 -0
- package/tests/batch.test.ts +60 -0
- package/tests/bench.ts +205 -0
- package/tests/e2e.test.ts +284 -0
- package/tests/expression-rules.test.ts +114 -0
- package/tests/file-adapter.test.ts +57 -0
- package/tests/fts.test.ts +58 -0
- package/tests/mq-flow.test.ts +204 -0
- package/tests/mq.test.ts +326 -0
- package/tests/push.test.ts +55 -0
- package/tests/query.test.ts +60 -0
- package/tests/rules.test.ts +78 -0
- package/tests/sse.test.ts +78 -0
- package/tests/storage.test.ts +199 -0
- package/tests/stream.test.ts +385 -0
- package/tests/stress.test.ts +202 -0
- package/tests/subscriptions.test.ts +86 -0
- package/tests/transforms.test.ts +92 -0
- package/tests/transport.test.ts +209 -0
- package/tests/ttl.test.ts +70 -0
- package/tests/vector.test.ts +69 -0
- 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
|
+
}
|