sqlite-zod-orm 3.7.1 → 3.7.3

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/dist/index.js CHANGED
@@ -361,31 +361,45 @@ class QueryBuilder {
361
361
  subscribe(callback, options = {}) {
362
362
  const { interval = this.defaultPollInterval, immediate = true } = options;
363
363
  const fingerprintSQL = this.buildFingerprintSQL();
364
- let lastFingerprint = null;
365
- const poll = () => {
364
+ let lastCount = null;
365
+ let lastMax = null;
366
+ let lastInMemoryRev = null;
367
+ let stopped = false;
368
+ const poll = async () => {
369
+ if (stopped)
370
+ return;
366
371
  try {
372
+ const rev = this.revisionGetter?.() ?? "0";
373
+ const inMemoryChanged = rev !== lastInMemoryRev;
374
+ lastInMemoryRev = rev;
367
375
  const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
368
376
  const fpRow = fpRows[0];
369
- const rev = this.revisionGetter?.() ?? "0";
370
- const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
371
- if (currentFingerprint !== lastFingerprint) {
372
- lastFingerprint = currentFingerprint;
377
+ const cnt = fpRow?._cnt ?? 0;
378
+ const max = fpRow?._max ?? 0;
379
+ const fpChanged = cnt !== lastCount || max !== lastMax;
380
+ lastCount = cnt;
381
+ lastMax = max;
382
+ if (inMemoryChanged || fpChanged) {
373
383
  const rows = this.all();
374
- callback(rows);
384
+ await callback(rows);
375
385
  }
376
386
  } catch {}
387
+ if (!stopped)
388
+ setTimeout(poll, interval);
377
389
  };
378
390
  if (immediate) {
379
391
  poll();
392
+ } else {
393
+ setTimeout(poll, interval);
380
394
  }
381
- const timer = setInterval(poll, interval);
382
395
  return () => {
383
- clearInterval(timer);
396
+ stopped = true;
384
397
  };
385
398
  }
386
399
  each(callback, options = {}) {
387
400
  const { interval = this.defaultPollInterval } = options;
388
- const maxRows = this.executor(`SELECT MAX(id) as _max FROM ${this.tableName}`, [], true);
401
+ const userWhere = this.buildWhereClause();
402
+ const maxRows = this.executor(`SELECT MAX(id) as _max FROM ${this.tableName} ${userWhere.sql ? `WHERE ${userWhere.sql}` : ""}`, userWhere.params, true);
389
403
  let lastMaxId = maxRows[0]?._max ?? 0;
390
404
  let lastRevision = this.revisionGetter?.() ?? "0";
391
405
  let stopped = false;
@@ -395,12 +409,15 @@ class QueryBuilder {
395
409
  const rev = this.revisionGetter?.() ?? "0";
396
410
  if (rev !== lastRevision) {
397
411
  lastRevision = rev;
398
- const newRows = this.executor(`SELECT * FROM ${this.tableName} WHERE id > ? ORDER BY id ASC`, [lastMaxId], true);
399
- for (const rawRow of newRows) {
412
+ const params = [...userWhere.params, lastMaxId];
413
+ const whereClause = userWhere.sql ? `WHERE ${userWhere.sql} AND id > ? ORDER BY id ASC` : `WHERE id > ? ORDER BY id ASC`;
414
+ const sql = `SELECT * FROM ${this.tableName} ${whereClause}`;
415
+ const newRows = this.executor(sql, params, false);
416
+ for (const row of newRows) {
400
417
  if (stopped)
401
418
  return;
402
- await callback(rawRow);
403
- lastMaxId = rawRow.id;
419
+ await callback(row);
420
+ lastMaxId = row.id;
404
421
  }
405
422
  }
406
423
  if (!stopped)
@@ -411,14 +428,13 @@ class QueryBuilder {
411
428
  stopped = true;
412
429
  };
413
430
  }
414
- buildFingerprintSQL() {
431
+ buildWhereClause() {
415
432
  const params = [];
416
- let sql = `SELECT COUNT(*) as _cnt, MAX(id) as _max FROM ${this.tableName}`;
417
433
  if (this.iqo.whereAST) {
418
434
  const compiled = compileAST(this.iqo.whereAST);
419
- sql += ` WHERE ${compiled.sql}`;
420
- params.push(...compiled.params);
421
- } else if (this.iqo.wheres.length > 0) {
435
+ return { sql: compiled.sql, params: compiled.params };
436
+ }
437
+ if (this.iqo.wheres.length > 0) {
422
438
  const whereParts = [];
423
439
  for (const w of this.iqo.wheres) {
424
440
  if (w.operator === "IN") {
@@ -435,9 +451,14 @@ class QueryBuilder {
435
451
  params.push(transformValueForStorage(w.value));
436
452
  }
437
453
  }
438
- sql += ` WHERE ${whereParts.join(" AND ")}`;
454
+ return { sql: whereParts.join(" AND "), params };
439
455
  }
440
- return { sql, params };
456
+ return { sql: "", params: [] };
457
+ }
458
+ buildFingerprintSQL() {
459
+ const where = this.buildWhereClause();
460
+ const sql = `SELECT COUNT(*) as _cnt, MAX(id) as _max FROM ${this.tableName}${where.sql ? ` WHERE ${where.sql}` : ""}`;
461
+ return { sql, params: where.params };
441
462
  }
442
463
  then(onfulfilled, onrejected) {
443
464
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.7.1",
3
+ "version": "3.7.3",
4
4
  "description": "Type-safe SQLite ORM for Bun — Zod schemas, fluent queries, auto relationships, zero SQL",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -479,6 +479,9 @@ export class QueryBuilder<T extends Record<string, any>> {
479
479
  * in-memory revision counter to detect ALL changes (inserts, updates, deletes)
480
480
  * with zero disk overhead.
481
481
  *
482
+ * Uses a self-scheduling async loop: each callback (sync or async) completes
483
+ * before the next poll starts. No overlapping polls.
484
+ *
482
485
  * ```ts
483
486
  * const unsub = db.messages.select()
484
487
  * .where({ groupId: 1 })
@@ -492,52 +495,68 @@ export class QueryBuilder<T extends Record<string, any>> {
492
495
  * unsub();
493
496
  * ```
494
497
  *
495
- * @param callback Called with the full result set whenever the data changes.
498
+ * @param callback Called with the full result set whenever the data changes. Async callbacks are awaited.
496
499
  * @param options `interval` in ms (default 500). Set `immediate` to false to skip the first call.
497
- * @returns An unsubscribe function that clears the polling interval.
500
+ * @returns An unsubscribe function.
498
501
  */
499
502
  subscribe(
500
- callback: (rows: T[]) => void,
503
+ callback: (rows: T[]) => void | Promise<void>,
501
504
  options: { interval?: number; immediate?: boolean } = {},
502
505
  ): () => void {
503
506
  const { interval = this.defaultPollInterval, immediate = true } = options;
504
507
 
505
508
  // Build the fingerprint SQL (COUNT + MAX(id)) using the same WHERE
506
509
  const fingerprintSQL = this.buildFingerprintSQL();
507
- let lastFingerprint: string | null = null;
510
+ let lastCount: number | null = null;
511
+ let lastMax: number | null = null;
512
+ let lastInMemoryRev: string | null = null;
513
+ let stopped = false;
508
514
 
509
- const poll = () => {
515
+ const poll = async () => {
516
+ if (stopped) return;
510
517
  try {
511
- // Run lightweight fingerprint check
512
- const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
513
- const fpRow = fpRows[0] as any;
514
- // Include revision in fingerprint (combines in-memory counter + PRAGMA data_version).
515
- // This detects ALL changes: same-process and cross-process.
518
+ // Two-signal change detection:
519
+ // 1. In-memory revision (table-specific) catches same-process writes
520
+ // 2. COUNT+MAX fingerprint (table-specific) catches cross-process inserts/deletes
521
+ //
522
+ // Note: cross-process UPDATEs that don't change count/max are only caught
523
+ // by PRAGMA data_version, which is database-wide. We accept this tradeoff
524
+ // to avoid re-querying on writes to OTHER tables.
516
525
  const rev = this.revisionGetter?.() ?? '0';
517
- const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
518
526
 
519
- if (currentFingerprint !== lastFingerprint) {
520
- lastFingerprint = currentFingerprint;
521
- // Fingerprint changed → re-execute the full query
527
+ // Fast path: in-memory revision changed → our CRUD wrote to this table
528
+ const inMemoryChanged = rev !== lastInMemoryRev;
529
+ lastInMemoryRev = rev;
530
+
531
+ // Check table-specific fingerprint (COUNT + MAX(id))
532
+ const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
533
+ const fpRow = fpRows[0] as any;
534
+ const cnt = fpRow?._cnt ?? 0;
535
+ const max = fpRow?._max ?? 0;
536
+ const fpChanged = cnt !== lastCount || max !== lastMax;
537
+ lastCount = cnt;
538
+ lastMax = max;
539
+
540
+ // Fire callback only if THIS table actually changed
541
+ if (inMemoryChanged || fpChanged) {
522
542
  const rows = this.all();
523
- callback(rows);
543
+ await callback(rows);
524
544
  }
525
545
  } catch {
526
546
  // Silently skip on error (table might be in transition)
527
547
  }
548
+ // Self-scheduling: next poll only after this one completes
549
+ if (!stopped) setTimeout(poll, interval);
528
550
  };
529
551
 
530
552
  // Immediate first execution
531
553
  if (immediate) {
532
554
  poll();
555
+ } else {
556
+ setTimeout(poll, interval);
533
557
  }
534
558
 
535
- const timer = setInterval(poll, interval);
536
-
537
- // Return unsubscribe function
538
- return () => {
539
- clearInterval(timer);
540
- };
559
+ return () => { stopped = true; };
541
560
  }
542
561
 
543
562
  /**
@@ -548,10 +567,19 @@ export class QueryBuilder<T extends Record<string, any>> {
548
567
  * `WHERE id > watermark` is rebuilt each poll with the latest value,
549
568
  * so it's always O(new_rows) — not O(table_size).
550
569
  *
570
+ * Composes with the query builder chain: any `.where()` conditions
571
+ * are combined with the watermark clause.
572
+ *
551
573
  * ```ts
574
+ * // All new messages
552
575
  * const unsub = db.messages.select().each((msg) => {
553
576
  * console.log('New:', msg.text);
554
577
  * });
578
+ *
579
+ * // Only new messages by Alice
580
+ * const unsub2 = db.messages.select()
581
+ * .where({ author: 'Alice' })
582
+ * .each((msg) => console.log(msg.text));
555
583
  * ```
556
584
  *
557
585
  * @param callback Called once per new row. Async callbacks are awaited.
@@ -564,9 +592,14 @@ export class QueryBuilder<T extends Record<string, any>> {
564
592
  ): () => void {
565
593
  const { interval = this.defaultPollInterval } = options;
566
594
 
567
- // Initialize watermark to current max id
595
+ // Compile the user's WHERE clause (if any) so we can combine with watermark
596
+ const userWhere = this.buildWhereClause();
597
+
598
+ // Initialize watermark to current max id, respecting user's WHERE clause
568
599
  const maxRows = this.executor(
569
- `SELECT MAX(id) as _max FROM ${this.tableName}`, [], true
600
+ `SELECT MAX(id) as _max FROM ${this.tableName} ${userWhere.sql ? `WHERE ${userWhere.sql}` : ''}`,
601
+ userWhere.params,
602
+ true
570
603
  );
571
604
  let lastMaxId: number = (maxRows[0] as any)?._max ?? 0;
572
605
  let lastRevision = this.revisionGetter?.() ?? '0';
@@ -579,16 +612,20 @@ export class QueryBuilder<T extends Record<string, any>> {
579
612
  if (rev !== lastRevision) {
580
613
  lastRevision = rev;
581
614
 
582
- // Fetch only new rows since watermark O(new_rows)
583
- const newRows = this.executor(
584
- `SELECT * FROM ${this.tableName} WHERE id > ? ORDER BY id ASC`,
585
- [lastMaxId], true
586
- );
615
+ // Combine user WHERE with watermark: WHERE (user_conditions) AND id > ?
616
+ const params = [...userWhere.params, lastMaxId];
617
+ const whereClause = userWhere.sql
618
+ ? `WHERE ${userWhere.sql} AND id > ? ORDER BY id ASC`
619
+ : `WHERE id > ? ORDER BY id ASC`;
620
+ const sql = `SELECT * FROM ${this.tableName} ${whereClause}`;
587
621
 
588
- for (const rawRow of newRows) {
622
+ // raw=false rows go through transform + get .update()/.delete() methods
623
+ const newRows = this.executor(sql, params, false);
624
+
625
+ for (const row of newRows) {
589
626
  if (stopped) return;
590
- await callback(rawRow as unknown as T);
591
- lastMaxId = (rawRow as any).id;
627
+ await callback(row as T);
628
+ lastMaxId = (row as any).id;
592
629
  }
593
630
  }
594
631
 
@@ -599,16 +636,16 @@ export class QueryBuilder<T extends Record<string, any>> {
599
636
  return () => { stopped = true; };
600
637
  }
601
638
 
602
- /** Build a lightweight fingerprint query (COUNT + MAX(id)) that shares the same WHERE clause. */
603
- private buildFingerprintSQL(): { sql: string; params: any[] } {
639
+ /** Compile the IQO's WHERE conditions into a SQL fragment + params (without the WHERE keyword). */
640
+ private buildWhereClause(): { sql: string; params: any[] } {
604
641
  const params: any[] = [];
605
- let sql = `SELECT COUNT(*) as _cnt, MAX(id) as _max FROM ${this.tableName}`;
606
642
 
607
643
  if (this.iqo.whereAST) {
608
644
  const compiled = compileAST(this.iqo.whereAST);
609
- sql += ` WHERE ${compiled.sql}`;
610
- params.push(...compiled.params);
611
- } else if (this.iqo.wheres.length > 0) {
645
+ return { sql: compiled.sql, params: compiled.params };
646
+ }
647
+
648
+ if (this.iqo.wheres.length > 0) {
612
649
  const whereParts: string[] = [];
613
650
  for (const w of this.iqo.wheres) {
614
651
  if (w.operator === 'IN') {
@@ -625,10 +662,17 @@ export class QueryBuilder<T extends Record<string, any>> {
625
662
  params.push(transformValueForStorage(w.value));
626
663
  }
627
664
  }
628
- sql += ` WHERE ${whereParts.join(' AND ')}`;
665
+ return { sql: whereParts.join(' AND '), params };
629
666
  }
630
667
 
631
- return { sql, params };
668
+ return { sql: '', params: [] };
669
+ }
670
+
671
+ /** Build a lightweight fingerprint query (COUNT + MAX(id)) that shares the same WHERE clause. */
672
+ private buildFingerprintSQL(): { sql: string; params: any[] } {
673
+ const where = this.buildWhereClause();
674
+ const sql = `SELECT COUNT(*) as _cnt, MAX(id) as _max FROM ${this.tableName}${where.sql ? ` WHERE ${where.sql}` : ''}`;
675
+ return { sql, params: where.params };
632
676
  }
633
677
 
634
678
  // ---------- Thenable (async/await support) ----------