@bod.ee/db 0.10.2 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +5 -1
- package/.claude/skills/config-file.md +3 -1
- package/.claude/skills/developing-bod-db.md +10 -1
- package/.claude/skills/using-bod-db.md +18 -1
- package/CLAUDE.md +12 -3
- package/admin/admin.ts +9 -0
- package/admin/demo.config.ts +1 -0
- package/admin/ui.html +67 -37
- package/docs/keyauth-integration.md +141 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +30 -6
- package/src/client/BodClientCached.ts +91 -3
- package/src/server/BodDB.ts +113 -42
- package/src/server/KeyAuthEngine.ts +32 -7
- package/src/server/ReplicationEngine.ts +31 -1
- package/src/server/StorageEngine.ts +117 -6
- package/src/server/StreamEngine.ts +20 -2
- package/src/server/SubscriptionEngine.ts +122 -35
- package/src/server/Transport.ts +125 -15
- package/src/server/VFSEngine.ts +27 -0
- package/src/shared/keyAuth.ts +4 -4
- package/src/shared/logger.ts +68 -0
- package/src/shared/protocol.ts +4 -1
- package/tests/optimization.test.ts +392 -0
|
@@ -52,6 +52,7 @@ export class ReplicationEngine {
|
|
|
52
52
|
private _started = false;
|
|
53
53
|
private _seq = 0;
|
|
54
54
|
private _emitting = false;
|
|
55
|
+
private _pendingReplEvents: WriteEvent[] | null = null;
|
|
55
56
|
|
|
56
57
|
get isReplica(): boolean { return this.options.role === 'replica'; }
|
|
57
58
|
get isPrimary(): boolean { return this.options.role === 'primary'; }
|
|
@@ -154,11 +155,21 @@ export class ReplicationEngine {
|
|
|
154
155
|
return this.options.excludePrefixes.some(p => path.startsWith(p));
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
/** Buffer replication events during transactions, emit immediately otherwise */
|
|
157
159
|
private emit(ev: WriteEvent): void {
|
|
158
|
-
// Guard against recursion (db.push('_repl') triggers _fireWrite → emit)
|
|
159
160
|
if (this._emitting) return;
|
|
160
161
|
if (this.isExcluded(ev.path)) return;
|
|
161
162
|
|
|
163
|
+
// If buffering (transaction in progress), collect events
|
|
164
|
+
if (this._pendingReplEvents) {
|
|
165
|
+
this._pendingReplEvents.push(ev);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this._emitNow(ev);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private _emitNow(ev: WriteEvent): void {
|
|
162
173
|
this._emitting = true;
|
|
163
174
|
try {
|
|
164
175
|
const replEvent: ReplEvent = { ...ev, ts: Date.now() };
|
|
@@ -170,6 +181,25 @@ export class ReplicationEngine {
|
|
|
170
181
|
}
|
|
171
182
|
}
|
|
172
183
|
|
|
184
|
+
/** Start buffering replication events (call before transaction) */
|
|
185
|
+
beginBatch(): void {
|
|
186
|
+
this._pendingReplEvents = [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Flush buffered replication events (call after transaction commit) */
|
|
190
|
+
flushBatch(): void {
|
|
191
|
+
const events = this._pendingReplEvents;
|
|
192
|
+
this._pendingReplEvents = null;
|
|
193
|
+
if (events) {
|
|
194
|
+
for (const ev of events) this._emitNow(ev);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Discard buffered events (call on transaction rollback) */
|
|
199
|
+
discardBatch(): void {
|
|
200
|
+
this._pendingReplEvents = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
173
203
|
// --- Replica mode ---
|
|
174
204
|
|
|
175
205
|
private async startReplica(): Promise<void> {
|
|
@@ -305,11 +305,97 @@ export class StorageEngine {
|
|
|
305
305
|
path = validatePath(path);
|
|
306
306
|
const prefix = path + '/';
|
|
307
307
|
|
|
308
|
-
//
|
|
308
|
+
// Try SQL push-down for push rows (single JSON values at depth 1)
|
|
309
|
+
if (this._canSqlPushDown(prefix, filters)) {
|
|
310
|
+
return this._sqlPushDownQuery(path, prefix, filters, order, limit, offset);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Fallback: full JS-based query
|
|
314
|
+
return this._jsQuery(path, prefix, filters, order, limit, offset);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Check if all filters target top-level JSON fields AND data is push-row format */
|
|
318
|
+
private _canSqlPushDown(prefix: string, filters?: QueryFilter[]): boolean {
|
|
319
|
+
if (!filters?.length) return false;
|
|
320
|
+
// All filter fields must be simple identifiers (no nested paths)
|
|
321
|
+
if (!filters.every(f => /^[a-zA-Z_]\w*$/.test(f.field))) return false;
|
|
322
|
+
// Check if direct children are single JSON rows (push-row format, not flattened)
|
|
323
|
+
// Sample the first row: if its path relative to prefix has no '/', it's a push row
|
|
324
|
+
const sample = this.db.prepare('SELECT path FROM nodes WHERE path >= ? AND path < ? AND mq_status IS NULL LIMIT 1')
|
|
325
|
+
.get(prefix, prefixEnd(prefix)) as { path: string } | null;
|
|
326
|
+
if (!sample) return false;
|
|
327
|
+
const rel = sample.path.slice(prefix.length);
|
|
328
|
+
return !rel.includes('/'); // push row = no nesting
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** SQL push-down query for push rows using json_extract */
|
|
332
|
+
private _sqlPushDownQuery(
|
|
333
|
+
path: string, prefix: string,
|
|
334
|
+
filters?: QueryFilter[], order?: OrderClause,
|
|
335
|
+
limit?: number, offset?: number,
|
|
336
|
+
): Array<{ _path: string; _key: string; [k: string]: unknown }> {
|
|
337
|
+
const params: unknown[] = [prefix, prefixEnd(prefix)];
|
|
338
|
+
const whereClauses = ['path >= ?', 'path < ?', 'mq_status IS NULL'];
|
|
339
|
+
|
|
340
|
+
// Only match direct children (no '/' in relative path)
|
|
341
|
+
// We filter this in JS since SQLite doesn't have a good way to check depth
|
|
342
|
+
|
|
343
|
+
const VALID_OPS: Record<string, string> = { '==': '=', '!=': '!=', '<': '<', '<=': '<=', '>': '>', '>=': '>=' };
|
|
344
|
+
if (filters?.length) {
|
|
345
|
+
for (const f of filters) {
|
|
346
|
+
const sqlOp = VALID_OPS[f.op];
|
|
347
|
+
if (!sqlOp) continue; // skip unknown operators
|
|
348
|
+
const jsonPath = `$.${f.field}`; // f.field already validated by _canSqlPushDown regex
|
|
349
|
+
whereClauses.push(`json_extract(value, '${jsonPath}') ${sqlOp} ?`);
|
|
350
|
+
params.push(f.value);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let sql = `SELECT path, value FROM nodes WHERE ${whereClauses.join(' AND ')}`;
|
|
355
|
+
|
|
356
|
+
if (order && /^[a-zA-Z_]\w*$/.test(order.field)) {
|
|
357
|
+
sql += ` ORDER BY json_extract(value, '$.${order.field}') ${order.dir === 'desc' ? 'DESC' : 'ASC'}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (limit != null) {
|
|
361
|
+
sql += ` LIMIT ?`;
|
|
362
|
+
params.push(limit);
|
|
363
|
+
if (offset != null) {
|
|
364
|
+
sql += ` OFFSET ?`;
|
|
365
|
+
params.push(offset);
|
|
366
|
+
}
|
|
367
|
+
} else if (offset != null) {
|
|
368
|
+
sql += ` LIMIT -1 OFFSET ?`;
|
|
369
|
+
params.push(offset);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const rows = this.db.prepare(sql).all(...params) as Array<{ path: string; value: string }>;
|
|
373
|
+
|
|
374
|
+
// Filter to direct children only and reconstruct
|
|
375
|
+
const results: Array<{ _path: string; _key: string; [k: string]: unknown }> = [];
|
|
376
|
+
for (const row of rows) {
|
|
377
|
+
const rel = row.path.slice(prefix.length);
|
|
378
|
+
if (rel.includes('/')) continue; // skip nested rows
|
|
379
|
+
const key = rel;
|
|
380
|
+
const val = JSON.parse(row.value);
|
|
381
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
382
|
+
results.push({ _path: row.path, _key: key, ...(val as Record<string, unknown>) });
|
|
383
|
+
} else {
|
|
384
|
+
results.push({ _path: row.path, _key: key, _value: val });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return results;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Full JS-based query (original implementation) */
|
|
391
|
+
private _jsQuery(
|
|
392
|
+
path: string, prefix: string,
|
|
393
|
+
filters?: QueryFilter[], order?: OrderClause,
|
|
394
|
+
limit?: number, offset?: number,
|
|
395
|
+
): Array<{ _path: string; _key: string; [k: string]: unknown }> {
|
|
309
396
|
const rows = this.stmtPrefix.all(prefix, prefixEnd(prefix)) as Array<{ path: string; value: string }>;
|
|
310
397
|
if (rows.length === 0) return [];
|
|
311
398
|
|
|
312
|
-
// Group rows by direct child key
|
|
313
399
|
const children = new Map<string, Array<{ path: string; value: string }>>();
|
|
314
400
|
for (const row of rows) {
|
|
315
401
|
const rel = row.path.slice(prefix.length);
|
|
@@ -318,7 +404,6 @@ export class StorageEngine {
|
|
|
318
404
|
children.get(childKey)!.push(row);
|
|
319
405
|
}
|
|
320
406
|
|
|
321
|
-
// Reconstruct each child
|
|
322
407
|
let results: Array<{ _path: string; _key: string; [k: string]: unknown }> = [];
|
|
323
408
|
for (const [key, childRows] of children) {
|
|
324
409
|
const childPath = `${path}/${key}`;
|
|
@@ -330,7 +415,6 @@ export class StorageEngine {
|
|
|
330
415
|
}
|
|
331
416
|
}
|
|
332
417
|
|
|
333
|
-
// Apply filters
|
|
334
418
|
if (filters?.length) {
|
|
335
419
|
for (const f of filters) {
|
|
336
420
|
results = results.filter(item => {
|
|
@@ -348,7 +432,6 @@ export class StorageEngine {
|
|
|
348
432
|
}
|
|
349
433
|
}
|
|
350
434
|
|
|
351
|
-
// Apply ordering
|
|
352
435
|
if (order) {
|
|
353
436
|
const dir = order.dir === 'desc' ? -1 : 1;
|
|
354
437
|
results.sort((a, b) => {
|
|
@@ -361,7 +444,6 @@ export class StorageEngine {
|
|
|
361
444
|
});
|
|
362
445
|
}
|
|
363
446
|
|
|
364
|
-
// Apply offset + limit
|
|
365
447
|
if (offset) results = results.slice(offset);
|
|
366
448
|
if (limit) results = results.slice(0, limit);
|
|
367
449
|
|
|
@@ -438,6 +520,35 @@ export class StorageEngine {
|
|
|
438
520
|
}));
|
|
439
521
|
}
|
|
440
522
|
|
|
523
|
+
/** Check which paths from a list have data (exact or subtree). Returns the set of existing paths. */
|
|
524
|
+
existsMany(paths: string[]): Set<string> {
|
|
525
|
+
if (paths.length === 0) return new Set();
|
|
526
|
+
const result = new Set<string>();
|
|
527
|
+
const validated = paths.map(p => validatePath(p));
|
|
528
|
+
const params: string[] = [];
|
|
529
|
+
const conditions: string[] = [];
|
|
530
|
+
for (const vp of validated) {
|
|
531
|
+
const prefix = vp + '/';
|
|
532
|
+
conditions.push('(path = ? OR (path >= ? AND path < ?))');
|
|
533
|
+
params.push(vp, prefix, prefixEnd(prefix));
|
|
534
|
+
}
|
|
535
|
+
const sql = `SELECT DISTINCT path FROM nodes WHERE (${conditions.join(' OR ')}) AND mq_status IS NULL`;
|
|
536
|
+
const rows = this.db.prepare(sql).all(...params) as Array<{ path: string }>;
|
|
537
|
+
// Build a lookup set for O(1) matching
|
|
538
|
+
const vpSet = new Set(validated);
|
|
539
|
+
for (const row of rows) {
|
|
540
|
+
// Check exact match
|
|
541
|
+
if (vpSet.has(row.path)) { result.add(row.path); continue; }
|
|
542
|
+
// Check if row is a descendant of any requested path
|
|
543
|
+
const parts = row.path.split('/');
|
|
544
|
+
for (let i = parts.length - 1; i >= 1; i--) {
|
|
545
|
+
const ancestor = parts.slice(0, i).join('/');
|
|
546
|
+
if (vpSet.has(ancestor)) { result.add(ancestor); break; }
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return result;
|
|
550
|
+
}
|
|
551
|
+
|
|
441
552
|
close() {
|
|
442
553
|
this.db.close();
|
|
443
554
|
}
|
|
@@ -61,12 +61,30 @@ export class StreamEngine {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** Get materialized view: snapshot merged with events on top */
|
|
64
|
-
materialize(topic: string, opts?: { keepKey?: string }): Record<string, unknown
|
|
64
|
+
materialize(topic: string, opts?: { keepKey?: string }): Record<string, unknown>;
|
|
65
|
+
materialize(topic: string, opts: { keepKey?: string; batchSize: number; cursor?: string }): { data: Record<string, unknown>; nextCursor?: string };
|
|
66
|
+
materialize(topic: string, opts?: { keepKey?: string; batchSize?: number; cursor?: string }): Record<string, unknown> | { data: Record<string, unknown>; nextCursor?: string } {
|
|
65
67
|
const snap = this.snapshot(topic);
|
|
66
68
|
const result: Record<string, unknown> = snap ? { ...snap.data } : {};
|
|
67
69
|
const keepKey = opts?.keepKey;
|
|
70
|
+
const batchSize = opts?.batchSize;
|
|
71
|
+
|
|
72
|
+
if (batchSize != null && batchSize < Infinity) {
|
|
73
|
+
// Cursor-based: return one batch at a time
|
|
74
|
+
const afterKey = opts?.cursor ?? snap?.key ?? null;
|
|
75
|
+
const events = this.storage.queryAfterKey(topic, afterKey, batchSize);
|
|
76
|
+
for (const ev of events) {
|
|
77
|
+
if (keepKey && ev.value && typeof ev.value === 'object' && keepKey in (ev.value as any)) {
|
|
78
|
+
result[String((ev.value as any)[keepKey])] = ev.value;
|
|
79
|
+
} else if (ev.value && typeof ev.value === 'object' && !Array.isArray(ev.value)) {
|
|
80
|
+
Object.assign(result, ev.value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const nextCursor = events.length === batchSize ? events[events.length - 1].key : undefined;
|
|
84
|
+
return { data: result, nextCursor };
|
|
85
|
+
}
|
|
68
86
|
|
|
69
|
-
//
|
|
87
|
+
// Full materialize (backward compatible)
|
|
70
88
|
const afterKey = snap?.key ?? null;
|
|
71
89
|
const events = this.storage.queryAfterKey(topic, afterKey, Number.MAX_SAFE_INTEGER);
|
|
72
90
|
for (const ev of events) {
|
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { ancestors } from '../shared/pathUtils.ts';
|
|
2
2
|
|
|
3
|
-
export type SubscriptionCallback = (snapshot: { path: string; val: () => unknown }) => void;
|
|
3
|
+
export type SubscriptionCallback = (snapshot: { path: string; val: () => unknown; updatedAt?: number }) => void;
|
|
4
4
|
export type ChildCallback = (event: { type: 'added' | 'changed' | 'removed'; key: string; path: string; val: () => unknown }) => void;
|
|
5
5
|
|
|
6
|
+
export class SubscriptionEngineOptions {
|
|
7
|
+
/** Maximum total subscriptions (default 50000) */
|
|
8
|
+
maxSubscriptions: number = 50000;
|
|
9
|
+
/** Enable async batched notifications (default false) */
|
|
10
|
+
asyncNotify: boolean = false;
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export class SubscriptionEngine {
|
|
14
|
+
readonly options: SubscriptionEngineOptions;
|
|
7
15
|
private valueSubs = new Map<string, Set<SubscriptionCallback>>();
|
|
8
16
|
private childSubs = new Map<string, Set<ChildCallback>>();
|
|
17
|
+
private _pendingNotify: Set<string> | null = null;
|
|
18
|
+
private _pendingGetFn: ((path: string) => unknown) | null = null;
|
|
19
|
+
private _pendingExisted: Set<string> | null = null;
|
|
20
|
+
private _flushScheduled = false;
|
|
21
|
+
|
|
22
|
+
constructor(options?: Partial<SubscriptionEngineOptions>) {
|
|
23
|
+
this.options = { ...new SubscriptionEngineOptions(), ...options };
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
get hasSubscriptions(): boolean {
|
|
11
27
|
return this.valueSubs.size > 0 || this.childSubs.size > 0;
|
|
@@ -31,6 +47,7 @@ export class SubscriptionEngine {
|
|
|
31
47
|
|
|
32
48
|
/** Subscribe to value changes at a path (fires for exact + descendant writes) */
|
|
33
49
|
onValue(path: string, cb: SubscriptionCallback): () => void {
|
|
50
|
+
if (this.size >= this.options.maxSubscriptions) throw new Error('Subscription limit reached');
|
|
34
51
|
if (!this.valueSubs.has(path)) this.valueSubs.set(path, new Set());
|
|
35
52
|
this.valueSubs.get(path)!.add(cb);
|
|
36
53
|
return () => {
|
|
@@ -41,6 +58,7 @@ export class SubscriptionEngine {
|
|
|
41
58
|
|
|
42
59
|
/** Subscribe to child events at a path */
|
|
43
60
|
onChild(path: string, cb: ChildCallback): () => void {
|
|
61
|
+
if (this.size >= this.options.maxSubscriptions) throw new Error('Subscription limit reached');
|
|
44
62
|
if (!this.childSubs.has(path)) this.childSubs.set(path, new Set());
|
|
45
63
|
this.childSubs.get(path)!.add(cb);
|
|
46
64
|
return () => {
|
|
@@ -49,6 +67,28 @@ export class SubscriptionEngine {
|
|
|
49
67
|
};
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
/** Push a value directly to subscribers of a specific path + its ancestors. No DB involved. */
|
|
71
|
+
pushValue(path: string, value: unknown): void {
|
|
72
|
+
const valFn = () => value;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const subs = this.valueSubs.get(path);
|
|
75
|
+
if (subs?.size) {
|
|
76
|
+
const snapshot = { path, val: valFn, updatedAt: now };
|
|
77
|
+
for (const cb of subs) { try { cb(snapshot); } catch {} }
|
|
78
|
+
}
|
|
79
|
+
// Walk ancestors by finding '/' separators (avoids split/slice/join allocations)
|
|
80
|
+
let idx = path.lastIndexOf('/');
|
|
81
|
+
while (idx > 0) {
|
|
82
|
+
const ancestor = path.substring(0, idx);
|
|
83
|
+
const asubs = this.valueSubs.get(ancestor);
|
|
84
|
+
if (asubs?.size) {
|
|
85
|
+
const snap = { path: ancestor, val: valFn, updatedAt: now };
|
|
86
|
+
for (const cb of asubs) { try { cb(snap); } catch {} }
|
|
87
|
+
}
|
|
88
|
+
idx = path.lastIndexOf('/', idx - 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
/**
|
|
53
93
|
* Notify after a write.
|
|
54
94
|
* @param changedPaths - all leaf paths that were written
|
|
@@ -56,56 +96,103 @@ export class SubscriptionEngine {
|
|
|
56
96
|
* @param existedBefore - set of paths that existed before the write (for added/changed detection)
|
|
57
97
|
*/
|
|
58
98
|
notify(changedPaths: string[], getFn: (path: string) => unknown, existedBefore?: Set<string>) {
|
|
59
|
-
|
|
99
|
+
// Value subscriptions — walk each leaf + ancestors using indexOf (no array alloc)
|
|
100
|
+
const notified = changedPaths.length > 1 ? new Set<string>() : null;
|
|
60
101
|
|
|
61
102
|
for (const leafPath of changedPaths) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
// Notify exact path
|
|
104
|
+
if (!notified || !notified.has(leafPath)) {
|
|
105
|
+
notified?.add(leafPath);
|
|
106
|
+
const subs = this.valueSubs.get(leafPath);
|
|
107
|
+
if (subs?.size) {
|
|
108
|
+
const snapshot = { path: leafPath, val: () => getFn(leafPath) };
|
|
109
|
+
for (const cb of subs) { try { cb(snapshot); } catch {} }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Walk ancestors via lastIndexOf (no split/slice/join)
|
|
113
|
+
let idx = leafPath.lastIndexOf('/');
|
|
114
|
+
while (idx > 0) {
|
|
115
|
+
const ancestor = leafPath.substring(0, idx);
|
|
116
|
+
if (notified && notified.has(ancestor)) { idx = leafPath.lastIndexOf('/', idx - 1); continue; }
|
|
117
|
+
notified?.add(ancestor);
|
|
118
|
+
const subs = this.valueSubs.get(ancestor);
|
|
69
119
|
if (subs?.size) {
|
|
70
|
-
const snapshot = { path:
|
|
71
|
-
for (const cb of subs) cb(snapshot);
|
|
120
|
+
const snapshot = { path: ancestor, val: () => getFn(ancestor) };
|
|
121
|
+
for (const cb of subs) { try { cb(snapshot); } catch {} }
|
|
72
122
|
}
|
|
123
|
+
idx = leafPath.lastIndexOf('/', idx - 1);
|
|
73
124
|
}
|
|
74
125
|
}
|
|
75
126
|
|
|
76
|
-
// Child events
|
|
77
|
-
|
|
78
|
-
for (const leafPath of changedPaths) {
|
|
79
|
-
const parts = leafPath.split('/');
|
|
80
|
-
if (parts.length < 2) continue;
|
|
127
|
+
// Child events — skip entirely when no child subs
|
|
128
|
+
if (!this.childSubs.size) return;
|
|
81
129
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
130
|
+
const childNotified = changedPaths.length > 1 ? new Set<string>() : null;
|
|
131
|
+
for (const leafPath of changedPaths) {
|
|
132
|
+
// Walk parent/child pairs via indexOf
|
|
133
|
+
let start = 0;
|
|
134
|
+
let slashIdx = leafPath.indexOf('/', start);
|
|
135
|
+
while (slashIdx !== -1) {
|
|
136
|
+
const parentPath = leafPath.substring(0, slashIdx);
|
|
137
|
+
const nextSlash = leafPath.indexOf('/', slashIdx + 1);
|
|
138
|
+
const childKey = nextSlash === -1 ? leafPath.substring(slashIdx + 1) : leafPath.substring(slashIdx + 1, nextSlash);
|
|
85
139
|
const dedupKey = `${parentPath}/${childKey}`;
|
|
86
140
|
|
|
87
|
-
if (childNotified.has(dedupKey))
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
141
|
+
if (!childNotified || !childNotified.has(dedupKey)) {
|
|
142
|
+
childNotified?.add(dedupKey);
|
|
143
|
+
const subs = this.childSubs.get(parentPath);
|
|
144
|
+
if (subs?.size) {
|
|
145
|
+
const childPath = dedupKey;
|
|
146
|
+
const val = getFn(childPath);
|
|
147
|
+
let type: 'added' | 'changed' | 'removed';
|
|
148
|
+
if (val === null) {
|
|
149
|
+
type = 'removed';
|
|
150
|
+
} else if (existedBefore && !existedBefore.has(childPath)) {
|
|
151
|
+
type = 'added';
|
|
152
|
+
} else {
|
|
153
|
+
type = 'changed';
|
|
154
|
+
}
|
|
155
|
+
const event = { type, key: childKey, path: childPath, val: () => val };
|
|
156
|
+
for (const cb of subs) { try { cb(event); } catch {} }
|
|
101
157
|
}
|
|
102
|
-
const event = { type, key: childKey, path: childPath, val: () => val };
|
|
103
|
-
for (const cb of subs) cb(event);
|
|
104
158
|
}
|
|
159
|
+
start = slashIdx + 1;
|
|
160
|
+
slashIdx = leafPath.indexOf('/', start);
|
|
105
161
|
}
|
|
106
162
|
}
|
|
107
163
|
}
|
|
108
164
|
|
|
165
|
+
/** Queue notifications for async batched delivery. Coalesces multiple writes in same tick. */
|
|
166
|
+
queueNotify(changedPaths: string[], getFn: (path: string) => unknown, existedBefore?: Set<string>) {
|
|
167
|
+
if (!this.options.asyncNotify) {
|
|
168
|
+
return this.notify(changedPaths, getFn, existedBefore);
|
|
169
|
+
}
|
|
170
|
+
if (!this._pendingNotify) this._pendingNotify = new Set();
|
|
171
|
+
for (const p of changedPaths) this._pendingNotify.add(p);
|
|
172
|
+
this._pendingGetFn = getFn;
|
|
173
|
+
if (existedBefore) {
|
|
174
|
+
if (!this._pendingExisted) this._pendingExisted = new Set();
|
|
175
|
+
for (const p of existedBefore) this._pendingExisted.add(p);
|
|
176
|
+
}
|
|
177
|
+
if (!this._flushScheduled) {
|
|
178
|
+
this._flushScheduled = true;
|
|
179
|
+
queueMicrotask(() => this._flushNotify());
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private _flushNotify() {
|
|
184
|
+
this._flushScheduled = false;
|
|
185
|
+
const paths = this._pendingNotify;
|
|
186
|
+
const getFn = this._pendingGetFn;
|
|
187
|
+
const existed = this._pendingExisted;
|
|
188
|
+
this._pendingNotify = null;
|
|
189
|
+
this._pendingGetFn = null;
|
|
190
|
+
this._pendingExisted = null;
|
|
191
|
+
if (paths && getFn) {
|
|
192
|
+
this.notify([...paths], getFn, existed ?? undefined);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
109
196
|
get size(): number {
|
|
110
197
|
let count = 0;
|
|
111
198
|
for (const s of this.valueSubs.values()) count += s.size;
|