@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.
@@ -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
- // Get all rows under this path
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
- // Replay all events after snapshot
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
- const notified = new Set<string>();
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
- 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);
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: p, val: () => getFn(p) };
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
- const childNotified = new Set<string>();
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
- for (let i = 1; i < parts.length; i++) {
83
- const parentPath = parts.slice(0, i).join('/');
84
- const childKey = parts[i];
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)) 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';
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;