@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,320 @@
|
|
|
1
|
+
import type { StorageEngine } from './StorageEngine.ts';
|
|
2
|
+
import type { SubscriptionEngine } from './SubscriptionEngine.ts';
|
|
3
|
+
import { validatePath, prefixEnd } from '../shared/pathUtils.ts';
|
|
4
|
+
|
|
5
|
+
export interface StreamEvent {
|
|
6
|
+
key: string;
|
|
7
|
+
data: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CompactOptions {
|
|
11
|
+
/** Delete events older than this many seconds */
|
|
12
|
+
maxAge?: number;
|
|
13
|
+
/** Keep only the last N events (rest folded into snapshot) */
|
|
14
|
+
maxCount?: number;
|
|
15
|
+
/** Kafka-style: snapshot keyed by this field, latest value per key wins */
|
|
16
|
+
keepKey?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CompactResult {
|
|
20
|
+
deleted: number;
|
|
21
|
+
snapshotSize: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StreamSnapshot {
|
|
25
|
+
/** Push key of the last event folded into this snapshot */
|
|
26
|
+
key: string;
|
|
27
|
+
/** Merged final state. With keepKey: keyed by field value. Without: single merged object. */
|
|
28
|
+
data: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class StreamEngineOptions {
|
|
32
|
+
defaultLimit: number = 100;
|
|
33
|
+
/** Auto-compact topics: { "events/orders": { maxAge: 86400 } } */
|
|
34
|
+
compact?: Record<string, CompactOptions>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class StreamEngine {
|
|
38
|
+
readonly options: StreamEngineOptions;
|
|
39
|
+
private activeSubs = new Map<string, () => void>(); // subKey → unsub
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private storage: StorageEngine,
|
|
43
|
+
private subs: SubscriptionEngine,
|
|
44
|
+
options?: Partial<StreamEngineOptions>,
|
|
45
|
+
) {
|
|
46
|
+
this.options = { ...new StreamEngineOptions(), ...options };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read events from a topic after the group's stored offset */
|
|
50
|
+
read(topic: string, groupId: string, limit?: number): StreamEvent[] {
|
|
51
|
+
const offset = this.getOffset(topic, groupId);
|
|
52
|
+
return this.storage.queryAfterKey(topic, offset, limit ?? this.options.defaultLimit)
|
|
53
|
+
.map(r => ({ key: r.key, data: r.value }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Get the snapshot for a topic (base state before events) */
|
|
57
|
+
snapshot(topic: string): StreamSnapshot | null {
|
|
58
|
+
const val = this.storage.get(this.snapshotPath(topic));
|
|
59
|
+
if (val && typeof val === 'object' && 'key' in (val as any)) return val as StreamSnapshot;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Get materialized view: snapshot merged with events on top */
|
|
64
|
+
materialize(topic: string, opts?: { keepKey?: string }): Record<string, unknown> {
|
|
65
|
+
const snap = this.snapshot(topic);
|
|
66
|
+
const result: Record<string, unknown> = snap ? { ...snap.data } : {};
|
|
67
|
+
const keepKey = opts?.keepKey;
|
|
68
|
+
|
|
69
|
+
// Replay all events after snapshot
|
|
70
|
+
const afterKey = snap?.key ?? null;
|
|
71
|
+
const events = this.storage.queryAfterKey(topic, afterKey, Number.MAX_SAFE_INTEGER);
|
|
72
|
+
for (const ev of events) {
|
|
73
|
+
if (keepKey && ev.value && typeof ev.value === 'object' && keepKey in (ev.value as any)) {
|
|
74
|
+
result[String((ev.value as any)[keepKey])] = ev.value;
|
|
75
|
+
} else if (ev.value && typeof ev.value === 'object' && !Array.isArray(ev.value)) {
|
|
76
|
+
Object.assign(result, ev.value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Acknowledge processing up to (and including) a key */
|
|
83
|
+
ack(topic: string, groupId: string, key: string): void {
|
|
84
|
+
this.setOffset(topic, groupId, key);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Subscribe to a topic with replay from last offset.
|
|
89
|
+
* Uses subscribe-then-backfill to prevent race conditions.
|
|
90
|
+
* Returns an unsubscribe function.
|
|
91
|
+
*/
|
|
92
|
+
subscribe(topic: string, groupId: string, send: (events: StreamEvent[]) => void): () => void {
|
|
93
|
+
const subKey = `${topic}:${groupId}`;
|
|
94
|
+
|
|
95
|
+
// If already subscribed, unsub first
|
|
96
|
+
this.activeSubs.get(subKey)?.();
|
|
97
|
+
|
|
98
|
+
// 1. Attach child subscription, buffer events
|
|
99
|
+
const buffer: StreamEvent[] = [];
|
|
100
|
+
let replayed = false;
|
|
101
|
+
let lastReplayedKey: string | null = null;
|
|
102
|
+
|
|
103
|
+
const childUnsub = this.subs.onChild(topic, (event) => {
|
|
104
|
+
if (event.type !== 'added') return;
|
|
105
|
+
const streamEvent: StreamEvent = { key: event.key, data: event.val() };
|
|
106
|
+
if (!replayed) {
|
|
107
|
+
buffer.push(streamEvent);
|
|
108
|
+
} else {
|
|
109
|
+
send([streamEvent]);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// 2. Fetch replay batch
|
|
114
|
+
const offset = this.getOffset(topic, groupId);
|
|
115
|
+
const replayBatch = this.storage.queryAfterKey(topic, offset, this.options.defaultLimit);
|
|
116
|
+
|
|
117
|
+
// 3. Send replay batch
|
|
118
|
+
if (replayBatch.length > 0) {
|
|
119
|
+
lastReplayedKey = replayBatch[replayBatch.length - 1].key;
|
|
120
|
+
send(replayBatch.map(r => ({ key: r.key, data: r.value })));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. Drain buffer, dedup against last replayed key
|
|
124
|
+
replayed = true;
|
|
125
|
+
if (buffer.length > 0) {
|
|
126
|
+
const deduped = lastReplayedKey
|
|
127
|
+
? buffer.filter(e => e.key > lastReplayedKey!)
|
|
128
|
+
: buffer;
|
|
129
|
+
if (deduped.length > 0) send(deduped);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Store unsub
|
|
133
|
+
const unsub = () => {
|
|
134
|
+
childUnsub();
|
|
135
|
+
this.activeSubs.delete(subKey);
|
|
136
|
+
};
|
|
137
|
+
this.activeSubs.set(subKey, unsub);
|
|
138
|
+
return unsub;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Compact a topic — fold events into snapshot, delete folded events.
|
|
143
|
+
* Snapshot is the base state; events layer on top.
|
|
144
|
+
* Safety: never deletes events beyond the minimum consumer group offset.
|
|
145
|
+
*/
|
|
146
|
+
compact(topic: string, opts: CompactOptions): CompactResult {
|
|
147
|
+
topic = validatePath(topic);
|
|
148
|
+
const prefix = topic + '/';
|
|
149
|
+
const end = prefixEnd(prefix);
|
|
150
|
+
|
|
151
|
+
// Get all event rows for this topic (ordered by path = chronological)
|
|
152
|
+
const rows = this.storage.db
|
|
153
|
+
.prepare('SELECT path, value, updated_at FROM nodes WHERE path >= ? AND path < ? ORDER BY path ASC')
|
|
154
|
+
.all(prefix, end) as Array<{ path: string; value: string; updated_at: number }>;
|
|
155
|
+
|
|
156
|
+
if (rows.length === 0) return { deleted: 0, snapshotSize: 0 };
|
|
157
|
+
|
|
158
|
+
// Determine which events to fold into snapshot
|
|
159
|
+
const toFold = new Set<number>(); // indices into rows
|
|
160
|
+
|
|
161
|
+
if (opts.maxAge != null) {
|
|
162
|
+
const cutoff = Date.now() - opts.maxAge * 1000;
|
|
163
|
+
for (let i = 0; i < rows.length; i++) {
|
|
164
|
+
if (rows[i].updated_at < cutoff) toFold.add(i);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (opts.maxCount != null && rows.length > opts.maxCount) {
|
|
169
|
+
const excess = rows.length - opts.maxCount;
|
|
170
|
+
for (let i = 0; i < excess; i++) {
|
|
171
|
+
toFold.add(i);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (opts.keepKey != null) {
|
|
176
|
+
const field = opts.keepKey;
|
|
177
|
+
const latestIdx = new Map<string, number>(); // fieldValue → latest index
|
|
178
|
+
for (let i = 0; i < rows.length; i++) {
|
|
179
|
+
try {
|
|
180
|
+
const data = JSON.parse(rows[i].value);
|
|
181
|
+
if (data != null && typeof data === 'object' && field in data) {
|
|
182
|
+
const keyVal = String(data[field]);
|
|
183
|
+
const prev = latestIdx.get(keyVal);
|
|
184
|
+
if (prev != null) toFold.add(prev);
|
|
185
|
+
latestIdx.set(keyVal, i);
|
|
186
|
+
}
|
|
187
|
+
} catch { /* skip */ }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (toFold.size === 0) return { deleted: 0, snapshotSize: 0 };
|
|
192
|
+
|
|
193
|
+
// Safety: never fold events beyond the minimum consumer group offset
|
|
194
|
+
const minSafeOffset = this.getMinOffset(topic);
|
|
195
|
+
|
|
196
|
+
// Load existing snapshot
|
|
197
|
+
const existingSnap = this.snapshot(topic);
|
|
198
|
+
const snapData: Record<string, unknown> = existingSnap ? { ...existingSnap.data } : {};
|
|
199
|
+
let lastFoldedKey = existingSnap?.key ?? '';
|
|
200
|
+
|
|
201
|
+
// Sort fold indices and process in order
|
|
202
|
+
const sortedIndices = [...toFold].sort((a, b) => a - b);
|
|
203
|
+
|
|
204
|
+
const stmtDel = this.storage.db.prepare('DELETE FROM nodes WHERE path = ?');
|
|
205
|
+
let deleted = 0;
|
|
206
|
+
|
|
207
|
+
const tx = this.storage.db.transaction(() => {
|
|
208
|
+
for (const idx of sortedIndices) {
|
|
209
|
+
const row = rows[idx];
|
|
210
|
+
const key = row.path.slice(prefix.length);
|
|
211
|
+
|
|
212
|
+
// Safety check: only fold if all groups have acked past this event
|
|
213
|
+
if (minSafeOffset !== null && key > minSafeOffset) continue;
|
|
214
|
+
|
|
215
|
+
// Merge into snapshot
|
|
216
|
+
try {
|
|
217
|
+
const data = JSON.parse(row.value);
|
|
218
|
+
if (opts.keepKey && data != null && typeof data === 'object' && opts.keepKey in data) {
|
|
219
|
+
// Keyed mode: group by field value, last write wins per key
|
|
220
|
+
const keyVal = String(data[opts.keepKey]);
|
|
221
|
+
snapData[keyVal] = data;
|
|
222
|
+
} else if (data != null && typeof data === 'object' && !Array.isArray(data)) {
|
|
223
|
+
// No keepKey: merge all events into a single state object
|
|
224
|
+
Object.assign(snapData, data);
|
|
225
|
+
}
|
|
226
|
+
} catch { /* skip unparseable */ }
|
|
227
|
+
|
|
228
|
+
// Delete the event row
|
|
229
|
+
stmtDel.run(row.path);
|
|
230
|
+
deleted++;
|
|
231
|
+
if (key > lastFoldedKey) lastFoldedKey = key;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
tx();
|
|
235
|
+
|
|
236
|
+
// Write snapshot as single JSON row (like push rows — avoids leaf flattening)
|
|
237
|
+
if (deleted > 0 && lastFoldedKey) {
|
|
238
|
+
const snapshot: StreamSnapshot = { key: lastFoldedKey, data: snapData };
|
|
239
|
+
const snapshotPath = this.snapshotPath(topic);
|
|
240
|
+
this.storage.db.prepare('INSERT OR REPLACE INTO nodes (path, value, updated_at) VALUES (?, ?, ?)').run(
|
|
241
|
+
snapshotPath, JSON.stringify(snapshot), Date.now()
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const snapshotSize = Object.keys(snapData).length;
|
|
246
|
+
return { deleted, snapshotSize };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Run auto-compact for all configured topics. Called from BodDB sweep. */
|
|
250
|
+
autoCompact(): CompactResult {
|
|
251
|
+
if (!this.options.compact) return { deleted: 0, snapshotSize: 0 };
|
|
252
|
+
let totalDeleted = 0;
|
|
253
|
+
let totalSnap = 0;
|
|
254
|
+
for (const [topic, opts] of Object.entries(this.options.compact)) {
|
|
255
|
+
const r = this.compact(topic, opts);
|
|
256
|
+
totalDeleted += r.deleted;
|
|
257
|
+
totalSnap += r.snapshotSize;
|
|
258
|
+
}
|
|
259
|
+
return { deleted: totalDeleted, snapshotSize: totalSnap };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Reset a topic — delete all events, snapshot, and consumer offsets. Notifies subscribers. */
|
|
263
|
+
reset(topic: string): void {
|
|
264
|
+
topic = validatePath(topic);
|
|
265
|
+
const prefix = topic + '/';
|
|
266
|
+
const end = prefixEnd(prefix);
|
|
267
|
+
// Collect event paths before deleting (for subscriber notification)
|
|
268
|
+
const eventPaths = (this.storage.db
|
|
269
|
+
.prepare('SELECT path FROM nodes WHERE path >= ? AND path < ?')
|
|
270
|
+
.all(prefix, end) as Array<{ path: string }>).map(r => r.path);
|
|
271
|
+
// Delete all event rows
|
|
272
|
+
this.storage.db.prepare('DELETE FROM nodes WHERE path >= ? AND path < ?').run(prefix, end);
|
|
273
|
+
// Delete snapshot + all group offsets
|
|
274
|
+
const metaPrefix = `_streams/${topic}/`;
|
|
275
|
+
const metaEnd = prefixEnd(metaPrefix);
|
|
276
|
+
this.storage.db.prepare('DELETE FROM nodes WHERE path >= ? AND path < ?').run(metaPrefix, metaEnd);
|
|
277
|
+
// Notify subscribers of removed events
|
|
278
|
+
if (eventPaths.length > 0 && this.subs.hasSubscriptions) {
|
|
279
|
+
this.subs.notify(eventPaths, (p) => this.storage.get(p));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Unsubscribe all active stream subscriptions (e.g. on disconnect) */
|
|
284
|
+
unsubscribeAll(): void {
|
|
285
|
+
for (const unsub of this.activeSubs.values()) unsub();
|
|
286
|
+
this.activeSubs.clear();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Get the minimum (earliest) offset across all consumer groups for a topic */
|
|
290
|
+
private getMinOffset(topic: string): string | null {
|
|
291
|
+
const groupsPath = `_streams/${topic}/groups`;
|
|
292
|
+
const groups = this.storage.getShallow(groupsPath);
|
|
293
|
+
if (groups.length === 0) return null;
|
|
294
|
+
let min: string | null = null;
|
|
295
|
+
for (const g of groups) {
|
|
296
|
+
const offset = this.getOffset(topic, g.key);
|
|
297
|
+
if (offset !== null && (min === null || offset < min)) {
|
|
298
|
+
min = offset;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return min;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private snapshotPath(topic: string): string {
|
|
305
|
+
return `_streams/${topic}/snapshot`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private offsetPath(topic: string, groupId: string): string {
|
|
309
|
+
return `_streams/${topic}/groups/${groupId}/offset`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private getOffset(topic: string, groupId: string): string | null {
|
|
313
|
+
const val = this.storage.get(this.offsetPath(topic, groupId));
|
|
314
|
+
return typeof val === 'string' ? val : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private setOffset(topic: string, groupId: string, key: string): void {
|
|
318
|
+
this.storage.set(this.offsetPath(topic, groupId), key);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ancestors } from '../shared/pathUtils.ts';
|
|
2
|
+
|
|
3
|
+
export type SubscriptionCallback = (snapshot: { path: string; val: () => unknown }) => void;
|
|
4
|
+
export type ChildCallback = (event: { type: 'added' | 'changed' | 'removed'; key: string; path: string; val: () => unknown }) => void;
|
|
5
|
+
|
|
6
|
+
export class SubscriptionEngine {
|
|
7
|
+
private valueSubs = new Map<string, Set<SubscriptionCallback>>();
|
|
8
|
+
private childSubs = new Map<string, Set<ChildCallback>>();
|
|
9
|
+
|
|
10
|
+
get hasSubscriptions(): boolean {
|
|
11
|
+
return this.valueSubs.size > 0 || this.childSubs.size > 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get hasChildSubscriptions(): boolean {
|
|
15
|
+
return this.childSubs.size > 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Count subscribers, optionally filtered by path prefix */
|
|
19
|
+
subscriberCount(prefix?: string): number {
|
|
20
|
+
let count = 0;
|
|
21
|
+
if (prefix) {
|
|
22
|
+
const p = prefix + '/';
|
|
23
|
+
for (const [key, subs] of this.valueSubs) if (key === prefix || key.startsWith(p)) count += subs.size;
|
|
24
|
+
for (const [key, subs] of this.childSubs) if (key === prefix || key.startsWith(p)) count += subs.size;
|
|
25
|
+
} else {
|
|
26
|
+
for (const subs of this.valueSubs.values()) count += subs.size;
|
|
27
|
+
for (const subs of this.childSubs.values()) count += subs.size;
|
|
28
|
+
}
|
|
29
|
+
return count;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Subscribe to value changes at a path (fires for exact + descendant writes) */
|
|
33
|
+
onValue(path: string, cb: SubscriptionCallback): () => void {
|
|
34
|
+
if (!this.valueSubs.has(path)) this.valueSubs.set(path, new Set());
|
|
35
|
+
this.valueSubs.get(path)!.add(cb);
|
|
36
|
+
return () => {
|
|
37
|
+
this.valueSubs.get(path)?.delete(cb);
|
|
38
|
+
if (this.valueSubs.get(path)?.size === 0) this.valueSubs.delete(path);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Subscribe to child events at a path */
|
|
43
|
+
onChild(path: string, cb: ChildCallback): () => void {
|
|
44
|
+
if (!this.childSubs.has(path)) this.childSubs.set(path, new Set());
|
|
45
|
+
this.childSubs.get(path)!.add(cb);
|
|
46
|
+
return () => {
|
|
47
|
+
this.childSubs.get(path)?.delete(cb);
|
|
48
|
+
if (this.childSubs.get(path)?.size === 0) this.childSubs.delete(path);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Notify after a write.
|
|
54
|
+
* @param changedPaths - all leaf paths that were written
|
|
55
|
+
* @param getFn - reads fresh from storage
|
|
56
|
+
* @param existedBefore - set of paths that existed before the write (for added/changed detection)
|
|
57
|
+
*/
|
|
58
|
+
notify(changedPaths: string[], getFn: (path: string) => unknown, existedBefore?: Set<string>) {
|
|
59
|
+
const notified = new Set<string>();
|
|
60
|
+
|
|
61
|
+
for (const leafPath of changedPaths) {
|
|
62
|
+
const pathsToNotify = [leafPath, ...ancestors(leafPath)];
|
|
63
|
+
|
|
64
|
+
for (const p of pathsToNotify) {
|
|
65
|
+
if (notified.has(p)) continue;
|
|
66
|
+
notified.add(p);
|
|
67
|
+
|
|
68
|
+
const subs = this.valueSubs.get(p);
|
|
69
|
+
if (subs?.size) {
|
|
70
|
+
const snapshot = { path: p, val: () => getFn(p) };
|
|
71
|
+
for (const cb of subs) cb(snapshot);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Child events
|
|
77
|
+
const childNotified = new Set<string>();
|
|
78
|
+
for (const leafPath of changedPaths) {
|
|
79
|
+
const parts = leafPath.split('/');
|
|
80
|
+
if (parts.length < 2) continue;
|
|
81
|
+
|
|
82
|
+
for (let i = 1; i < parts.length; i++) {
|
|
83
|
+
const parentPath = parts.slice(0, i).join('/');
|
|
84
|
+
const childKey = parts[i];
|
|
85
|
+
const dedupKey = `${parentPath}/${childKey}`;
|
|
86
|
+
|
|
87
|
+
if (childNotified.has(dedupKey)) continue;
|
|
88
|
+
childNotified.add(dedupKey);
|
|
89
|
+
|
|
90
|
+
const subs = this.childSubs.get(parentPath);
|
|
91
|
+
if (subs?.size) {
|
|
92
|
+
const childPath = `${parentPath}/${childKey}`;
|
|
93
|
+
const val = getFn(childPath);
|
|
94
|
+
let type: 'added' | 'changed' | 'removed';
|
|
95
|
+
if (val === null) {
|
|
96
|
+
type = 'removed';
|
|
97
|
+
} else if (existedBefore && !existedBefore.has(childPath)) {
|
|
98
|
+
type = 'added';
|
|
99
|
+
} else {
|
|
100
|
+
type = 'changed';
|
|
101
|
+
}
|
|
102
|
+
const event = { type, key: childKey, path: childPath, val: () => val };
|
|
103
|
+
for (const cb of subs) cb(event);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get size(): number {
|
|
110
|
+
let count = 0;
|
|
111
|
+
for (const s of this.valueSubs.values()) count += s.size;
|
|
112
|
+
for (const s of this.childSubs.values()) count += s.size;
|
|
113
|
+
return count;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
clear() {
|
|
117
|
+
this.valueSubs.clear();
|
|
118
|
+
this.childSubs.clear();
|
|
119
|
+
}
|
|
120
|
+
}
|