@bod.ee/db 0.10.1 → 0.11.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.
@@ -11,6 +11,7 @@ import { ReplicationEngine, type ReplicationOptions, type WriteEvent } from './R
11
11
  import { VFSEngine, type VFSEngineOptions } from './VFSEngine.ts';
12
12
  import { KeyAuthEngine, type KeyAuthEngineOptions } from './KeyAuthEngine.ts';
13
13
  import { validatePath } from '../shared/pathUtils.ts';
14
+ import { Logger, type LogConfig } from '../shared/logger.ts';
14
15
 
15
16
  export interface TransactionProxy {
16
17
  get(path: string): unknown;
@@ -46,6 +47,8 @@ export class BodDBOptions {
46
47
  port?: number;
47
48
  auth?: TransportOptions['auth'];
48
49
  transport?: Partial<TransportOptions>;
50
+ /** Logging config — disabled by default */
51
+ log?: LogConfig;
49
52
  }
50
53
 
51
54
  export class BodDB {
@@ -59,6 +62,7 @@ export class BodDB {
59
62
  readonly vfs: VFSEngine | null = null;
60
63
  readonly keyAuth: KeyAuthEngine | null = null;
61
64
  readonly options: BodDBOptions;
65
+ readonly log: Logger;
62
66
  replication: ReplicationEngine | null = null;
63
67
  private _replaying = false;
64
68
  private _onWriteHooks: Array<(ev: WriteEvent) => void> = [];
@@ -66,11 +70,16 @@ export class BodDB {
66
70
  get transport(): Transport | null { return this._transport; }
67
71
  private sweepTimer: ReturnType<typeof setInterval> | null = null;
68
72
  private _statsInterval: ReturnType<typeof setInterval> | null = null;
73
+ private _statsStmtCount: any = null;
69
74
  private _lastCpuUsage = process.cpuUsage();
70
75
  private _lastCpuTime = performance.now();
76
+ private _lastStats: Record<string, unknown> | null = null;
71
77
 
72
78
  constructor(options?: Partial<BodDBOptions>) {
73
79
  this.options = { ...new BodDBOptions(), ...options };
80
+ this.log = new Logger(this.options.log);
81
+ const _log = this.log.forComponent('db');
82
+ _log.info(`Initializing BodDB (path: ${this.options.path})`);
74
83
  this.storage = new StorageEngine({ path: this.options.path });
75
84
  this.subs = new SubscriptionEngine();
76
85
  this.stream = new StreamEngine(this.storage, this.subs, { compact: this.options.compact });
@@ -88,6 +97,7 @@ export class BodDB {
88
97
  ? BodDB.loadRulesFileSync(rulesConfig)
89
98
  : (rulesConfig ?? {});
90
99
  this.rules = new RulesEngine({ rules: resolvedRules, defaultDeny: this.options.defaultDeny });
100
+ _log.debug(`Rules loaded (${Object.keys(resolvedRules).length} paths, defaultDeny: ${!!this.options.defaultDeny})`);
91
101
 
92
102
  // Auto-create indexes from config
93
103
  if (this.options.indexes) {
@@ -96,21 +106,25 @@ export class BodDB {
96
106
  this.storage.createIndex(basePath, field);
97
107
  }
98
108
  }
109
+ _log.debug(`Indexes created: ${JSON.stringify(this.options.indexes)}`);
99
110
  }
100
111
 
101
112
  // Initialize FTS if configured
102
113
  if (this.options.fts) {
103
114
  this.fts = new FTSEngine(this.storage.db, this.options.fts);
115
+ _log.info('FTS5 engine enabled');
104
116
  }
105
117
 
106
118
  // Initialize vectors if configured
107
119
  if (this.options.vectors) {
108
120
  this.vectors = new VectorEngine(this.storage.db, this.options.vectors);
121
+ _log.info(`Vector engine enabled (dimensions: ${this.options.vectors.dimensions})`);
109
122
  }
110
123
 
111
124
  // Initialize VFS if configured
112
125
  if (this.options.vfs) {
113
126
  (this as { vfs: VFSEngine }).vfs = new VFSEngine(this, this.options.vfs);
127
+ _log.info(`VFS enabled (root: ${this.options.vfs.storageRoot})`);
114
128
  }
115
129
 
116
130
  // Initialize KeyAuth if configured
@@ -119,11 +133,13 @@ export class BodDB {
119
133
  if (this.options.keyAuth.rootPublicKey) {
120
134
  this.keyAuth!.initRoot(this.options.keyAuth.rootPublicKey);
121
135
  }
136
+ _log.info('KeyAuth engine enabled');
122
137
  }
123
138
 
124
139
  // Start TTL sweep
125
140
  if (this.options.sweepInterval > 0) {
126
141
  this.sweepTimer = setInterval(() => this.sweep(), this.options.sweepInterval);
142
+ _log.debug(`TTL sweep interval: ${this.options.sweepInterval}ms`);
127
143
  }
128
144
 
129
145
  // Init replication
@@ -134,7 +150,10 @@ export class BodDB {
134
150
  const compactOpts = this.options.replication.compact ?? { keepKey: 'path', maxCount: 10000 };
135
151
  this.stream.options.compact = { ...this.stream.options.compact, _repl: compactOpts };
136
152
  }
153
+ _log.info(`Replication enabled (role: ${this.replication.isPrimary ? 'primary' : 'replica'})`);
137
154
  }
155
+
156
+ _log.info('BodDB ready');
138
157
  }
139
158
 
140
159
  /** Load rules from a JSON or TS file synchronously */
@@ -195,6 +214,7 @@ export class BodDB {
195
214
  }
196
215
 
197
216
  get(path: string): unknown {
217
+ if (path === '_admin/stats' && this._lastStats) return this._lastStats;
198
218
  return this.storage.get(path);
199
219
  }
200
220
 
@@ -204,7 +224,7 @@ export class BodDB {
204
224
 
205
225
  set(path: string, value: unknown, options?: { ttl?: number }): void {
206
226
  const p = validatePath(path);
207
- const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : new Set<string>();
227
+ const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : undefined;
208
228
  const changed = this.storage.set(path, value);
209
229
  if (options?.ttl) {
210
230
  this.storage.setExpiry(path, options.ttl);
@@ -218,12 +238,15 @@ export class BodDB {
218
238
  /** Manually trigger TTL sweep + stream auto-compact, returns expired paths */
219
239
  sweep(): string[] {
220
240
  const expired = this.storage.sweep();
221
- if (expired.length > 0 && this.subs.hasSubscriptions) {
222
- this.subs.notify(expired, (p) => this.storage.get(p));
223
- }
224
- // Fire delete events for expired paths (replication)
225
- for (const p of expired) {
226
- this._fireWrite({ op: 'delete', path: p });
241
+ if (expired.length > 0) {
242
+ if (this.subs.hasSubscriptions) {
243
+ this.subs.notify(expired, (p) => this.storage.get(p));
244
+ }
245
+ // Fire delete events for expired paths (replication)
246
+ for (const p of expired) {
247
+ this._fireWrite({ op: 'delete', path: p });
248
+ }
249
+ this.log.forComponent('storage').debug(`Sweep: ${expired.length} expired paths removed`);
227
250
  }
228
251
  this.stream.autoCompact();
229
252
  this.mq.sweep();
@@ -250,7 +273,7 @@ export class BodDB {
250
273
 
251
274
  delete(path: string): void {
252
275
  const p = validatePath(path);
253
- const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : new Set<string>();
276
+ const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : undefined;
254
277
  this.storage.delete(path);
255
278
  if (this.subs.hasSubscriptions) {
256
279
  this.subs.notify([p], (pp) => this.storage.get(pp), existedBefore);
@@ -261,7 +284,7 @@ export class BodDB {
261
284
  /** Push a value with auto-generated time-sortable key (stored as single JSON row, not flattened) */
262
285
  push(path: string, value: unknown, opts?: { idempotencyKey?: string }): string {
263
286
  const p = validatePath(path);
264
- const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : new Set<string>();
287
+ const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : undefined;
265
288
  const { key, changedPaths, duplicate } = this.storage.push(path, value, opts);
266
289
  if (!duplicate && this.subs.hasSubscriptions) {
267
290
  this.subs.notify(changedPaths, (pp) => this.storage.get(pp), existedBefore);
@@ -350,13 +373,21 @@ export class BodDB {
350
373
  },
351
374
  };
352
375
 
376
+ this.replication?.beginBatch();
353
377
  const sqliteTx = this.storage.db.transaction(() => fn(proxy));
354
- const result = sqliteTx();
378
+ let result: T;
379
+ try {
380
+ result = sqliteTx();
381
+ } catch (e) {
382
+ this.replication?.discardBatch();
383
+ throw e;
384
+ }
355
385
 
356
386
  if (this.subs.hasSubscriptions && allChanged.length > 0) {
357
387
  this.subs.notify(allChanged, (p) => this.storage.get(p), allExistedBefore);
358
388
  }
359
389
  for (const ev of txWriteEvents) this._fireWrite(ev);
390
+ this.replication?.flushBatch();
360
391
  return result;
361
392
  }
362
393
 
@@ -366,53 +397,95 @@ export class BodDB {
366
397
  const { statSync } = require('fs');
367
398
  const { cpus, totalmem } = require('os');
368
399
  let lastOsCpus = cpus();
400
+ // Clean up old flattened _admin/stats/* leaf rows (migrated to single JSON row)
401
+ this.storage.db.prepare("DELETE FROM nodes WHERE path LIKE '_admin/stats/%'").run();
402
+
403
+ // Reuse prepared statement across start/stop cycles
404
+ if (!this._statsStmtCount) {
405
+ this._statsStmtCount = this.storage.db.prepare('SELECT COUNT(*) as n FROM nodes WHERE mq_status IS NULL');
406
+ }
407
+ const stmtCount = this._statsStmtCount;
408
+ const statsPath = '_admin/stats';
409
+
410
+ // Reuse a single stats object to minimize allocations
411
+ const statsData: Record<string, unknown> = {
412
+ process: {}, db: {}, system: {},
413
+ subs: 0, clients: 0, repl: null, ts: 0,
414
+ };
415
+ const proc = statsData.process as Record<string, number>;
416
+ const dbStats = statsData.db as Record<string, number>;
417
+ const sys = statsData.system as Record<string, number>;
418
+
419
+ // Heavy calls (cpus, statSync, stmtCount) run every 5s; lightweight calls every 1s
420
+ let tick = 0;
421
+ const MB = 1 / (1024 * 1024);
422
+ sys.cpuCores = cpus().length;
423
+ sys.totalMemMb = Math.round(totalmem() * MB);
369
424
 
370
425
  this._statsInterval = setInterval(() => {
371
- if (!this.subs.subscriberCount('_admin')) return;
426
+ if (!this._transport) return;
427
+ tick++;
372
428
 
373
429
  const now = performance.now();
374
430
  const cpu = process.cpuUsage();
375
431
  const elapsedUs = (now - this._lastCpuTime) * 1000;
376
- const cpuPercent = +((cpu.user - this._lastCpuUsage.user + cpu.system - this._lastCpuUsage.system) / elapsedUs * 100).toFixed(1);
432
+ proc.cpuPercent = Math.round((cpu.user - this._lastCpuUsage.user + cpu.system - this._lastCpuUsage.system) / elapsedUs * 1000) / 10;
377
433
  this._lastCpuUsage = cpu;
378
434
  this._lastCpuTime = now;
379
435
 
380
436
  const mem = process.memoryUsage();
381
- let nodeCount = 0;
382
- try { nodeCount = (this.storage.db.query('SELECT COUNT(*) as n FROM nodes WHERE mq_status IS NULL').get() as any).n; } catch {}
383
- let dbSizeMb = 0;
384
- try { dbSizeMb = +(statSync(this.options.path).size / 1024 / 1024).toFixed(2); } catch {}
385
-
386
- // System CPU
387
- const cur = cpus();
388
- let idleDelta = 0, totalDelta = 0;
389
- for (let i = 0; i < cur.length; i++) {
390
- const prev = lastOsCpus[i]?.times ?? cur[i].times;
391
- const c = cur[i].times;
392
- idleDelta += c.idle - prev.idle;
393
- totalDelta += (c.user + c.nice + c.sys + c.irq + c.idle) - (prev.user + prev.nice + prev.sys + prev.irq + prev.idle);
437
+ proc.heapUsedMb = Math.round(mem.heapUsed * MB * 100) / 100;
438
+ proc.rssMb = Math.round(mem.rss * MB * 100) / 100;
439
+ proc.uptimeSec = Math.floor(process.uptime());
440
+
441
+ // Expensive calls: only every 5 ticks
442
+ if (tick % 5 === 0) {
443
+ try { dbStats.nodeCount = (stmtCount.get() as any).n; } catch {}
444
+ try { dbStats.sizeMb = Math.round(statSync(this.options.path).size * MB * 100) / 100; } catch {}
445
+ const cur = cpus();
446
+ let idleDelta = 0, totalDelta = 0;
447
+ for (let i = 0; i < cur.length; i++) {
448
+ const prev = lastOsCpus[i]?.times ?? cur[i].times;
449
+ const c = cur[i].times;
450
+ idleDelta += c.idle - prev.idle;
451
+ totalDelta += (c.user + c.nice + c.sys + c.irq + c.idle) - (prev.user + prev.nice + prev.sys + prev.irq + prev.idle);
452
+ }
453
+ lastOsCpus = cur;
454
+ sys.cpuPercent = totalDelta > 0 ? Math.round((1 - idleDelta / totalDelta) * 1000) / 10 : 0;
394
455
  }
395
- lastOsCpus = cur;
396
- const sysCpu = totalDelta > 0 ? +((1 - idleDelta / totalDelta) * 100).toFixed(1) : 0;
397
-
398
- this.set('_admin/stats', {
399
- process: { cpuPercent, heapUsedMb: +(mem.heapUsed / 1024 / 1024).toFixed(2), rssMb: +(mem.rss / 1024 / 1024).toFixed(2), uptimeSec: Math.floor(process.uptime()) },
400
- db: { nodeCount, sizeMb: dbSizeMb },
401
- system: { cpuCores: cur.length, totalMemMb: +(totalmem() / 1024 / 1024).toFixed(0), cpuPercent: sysCpu },
402
- subs: this.subs.subscriberCount(),
403
- clients: this._transport?.clientCount ?? 0,
404
- repl: this.replication?.stats() ?? null,
405
- ts: Date.now(),
406
- });
456
+
457
+ statsData.subs = this.subs.subscriberCount();
458
+ statsData.clients = this._transport?.clientCount ?? 0;
459
+ statsData.repl = this.replication?.stats() ?? null;
460
+ statsData.ts = Date.now();
461
+
462
+ // Push directly to WS clients bypass SubscriptionEngine entirely
463
+ this._lastStats = statsData;
464
+ if (this._transport) this._transport.broadcastStats(statsData, Date.now());
465
+
407
466
  }, 1000);
467
+ this.log.forComponent('stats').info('Stats publisher started');
468
+ }
469
+
470
+ /** Stop the stats publisher. */
471
+ stopStatsPublisher(): void {
472
+ if (!this._statsInterval) return;
473
+ clearInterval(this._statsInterval);
474
+ this._statsInterval = null;
475
+ this._lastStats = null;
476
+ this.subs.pushValue('_admin/stats', null);
477
+ this.log.forComponent('stats').info('Stats publisher stopped');
408
478
  }
409
479
 
480
+ get statsEnabled(): boolean { return this._statsInterval !== null; }
481
+
410
482
  /** Start standalone WebSocket + REST server on its own port */
411
483
  serve(options?: Partial<TransportOptions>) {
412
484
  const port = options?.port ?? this.options.port;
413
485
  const auth = options?.auth ?? this.options.auth ?? (this.keyAuth ? this.keyAuth.authCallback() : undefined);
414
486
  this._transport = new Transport(this, this.rules, { ...this.options.transport, ...options, port, auth, keyAuth: this.keyAuth ?? undefined });
415
487
  this.startStatsPublisher();
488
+ this.log.forComponent('transport').info(`Server starting on port ${port}`);
416
489
  return this._transport.start();
417
490
  }
418
491
 
@@ -467,13 +540,11 @@ export class BodDB {
467
540
  }
468
541
 
469
542
  private snapshotExisting(path: string): Set<string> {
470
- const existing = new Set<string>();
471
- if (this.storage.exists(path)) existing.add(path);
543
+ const paths = [path];
472
544
  const parts = path.split('/');
473
545
  for (let i = 1; i < parts.length; i++) {
474
- const childPath = parts.slice(0, i + 1).join('/');
475
- if (this.storage.exists(childPath)) existing.add(childPath);
546
+ paths.push(parts.slice(0, i + 1).join('/'));
476
547
  }
477
- return existing;
548
+ return this.storage.existsMany(paths);
478
549
  }
479
550
  }
@@ -19,6 +19,12 @@ export class KeyAuthEngineOptions {
19
19
  clockSkew: number = 30;
20
20
  /** Allow unauthenticated device registration (default true) */
21
21
  allowOpenRegistration: boolean = true;
22
+ /** PBKDF2 iterations for password-based key derivation (default 100000) */
23
+ pbkdf2Iterations: number = 100_000;
24
+ /** Max failed auth attempts before lockout (0 = disabled) */
25
+ maxAuthFailures: number = 0;
26
+ /** Lockout duration in seconds after max failures */
27
+ authLockoutSeconds: number = 60;
22
28
  }
23
29
 
24
30
  /** Reserved prefix — _auth/ writes blocked for external clients */
@@ -80,7 +86,7 @@ export class KeyAuthEngine {
80
86
  * If this is the first account ever created, it is auto-elevated to root. */
81
87
  createAccount(password: string, roles: string[] = [], displayName?: string, devicePublicKey?: string): { publicKey: string; fingerprint: string; isRoot: boolean; deviceFingerprint?: string } {
82
88
  const kp = generateEd25519KeyPair();
83
- const enc = encryptPrivateKey(kp.privateKey, password);
89
+ const enc = encryptPrivateKey(kp.privateKey, password, this.options.pbkdf2Iterations);
84
90
  const fp = fingerprint(kp.publicKeyBase64);
85
91
 
86
92
  // First account becomes root automatically
@@ -145,6 +151,14 @@ export class KeyAuthEngine {
145
151
 
146
152
  /** Verify a signed nonce. Returns token + expiry or null. */
147
153
  verify(publicKeyBase64: string, signatureBase64: string, nonce: string): { token: string; expiresAt: number } | null {
154
+ const fp = fingerprint(publicKeyBase64);
155
+
156
+ // Rate limit check
157
+ if (this.options.maxAuthFailures > 0) {
158
+ const rlData = this.db.get(`_auth/rateLimit/${fp}`) as { count: number } | null;
159
+ if (rlData && rlData.count >= this.options.maxAuthFailures) return null;
160
+ }
161
+
148
162
  // Check nonce exists (one-time use)
149
163
  const nonceData = this.db.get(`_auth/nonces/${nonce}`);
150
164
  if (!nonceData) return null;
@@ -154,9 +168,10 @@ export class KeyAuthEngine {
154
168
  // Verify signature
155
169
  const pubKeyDer = Buffer.from(publicKeyBase64, 'base64');
156
170
  const sig = Buffer.from(signatureBase64, 'base64');
157
- if (!verifySignature(Buffer.from(nonce), sig, new Uint8Array(pubKeyDer))) return null;
158
-
159
- const fp = fingerprint(publicKeyBase64);
171
+ if (!verifySignature(Buffer.from(nonce), sig, new Uint8Array(pubKeyDer))) {
172
+ this._recordAuthFailure(fp);
173
+ return null;
174
+ }
160
175
 
161
176
  // Check if root
162
177
  const root = this.db.get('_auth/root') as { fingerprint: string } | null;
@@ -200,6 +215,8 @@ export class KeyAuthEngine {
200
215
  sid, fp, accountFp, roles, root: isRoot, exp: expiresAt,
201
216
  };
202
217
  const token = encodeToken(payload, this.serverPrivateKey);
218
+ // Clear rate limit on success
219
+ if (this.options.maxAuthFailures > 0) this.db.delete(`_auth/rateLimit/${fp}`);
203
220
  return { token, expiresAt };
204
221
  }
205
222
 
@@ -213,7 +230,7 @@ export class KeyAuthEngine {
213
230
  let accountPrivateKey: Uint8Array;
214
231
  try {
215
232
  accountPrivateKey = decryptPrivateKey(
216
- account.encryptedPrivateKey, account.salt, account.iv, account.authTag, password,
233
+ account.encryptedPrivateKey, account.salt, account.iv, account.authTag, password, this.options.pbkdf2Iterations,
217
234
  );
218
235
  } catch {
219
236
  return null; // Wrong password
@@ -269,12 +286,12 @@ export class KeyAuthEngine {
269
286
 
270
287
  let privateKey: Uint8Array;
271
288
  try {
272
- privateKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, oldPassword);
289
+ privateKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, oldPassword, this.options.pbkdf2Iterations);
273
290
  } catch {
274
291
  return false;
275
292
  }
276
293
 
277
- const enc = encryptPrivateKey(privateKey, newPassword);
294
+ const enc = encryptPrivateKey(privateKey, newPassword, this.options.pbkdf2Iterations);
278
295
  privateKey.fill(0); // Wipe
279
296
 
280
297
  // Atomic: single update call writes all fields together
@@ -460,6 +477,14 @@ export class KeyAuthEngine {
460
477
  return { accountFp, roles: account.roles ?? [] };
461
478
  }
462
479
 
480
+ /** Record a failed auth attempt for rate limiting */
481
+ private _recordAuthFailure(fp: string): void {
482
+ if (this.options.maxAuthFailures <= 0) return;
483
+ const rlData = this.db.get(`_auth/rateLimit/${fp}`) as { count: number } | null;
484
+ const count = (rlData?.count ?? 0) + 1;
485
+ this.db.set(`_auth/rateLimit/${fp}`, { count }, { ttl: this.options.authLockoutSeconds });
486
+ }
487
+
463
488
  /** Invalidate all sessions for a given device fingerprint */
464
489
  private _invalidateDeviceSessions(deviceFp: string): void {
465
490
  const sessions = this.db.getShallow('_auth/sessions');
@@ -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) {