sqlite-zod-orm 3.7.0 → 3.7.2

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
@@ -362,7 +362,10 @@ class QueryBuilder {
362
362
  const { interval = this.defaultPollInterval, immediate = true } = options;
363
363
  const fingerprintSQL = this.buildFingerprintSQL();
364
364
  let lastFingerprint = null;
365
- const poll = () => {
365
+ let stopped = false;
366
+ const poll = async () => {
367
+ if (stopped)
368
+ return;
366
369
  try {
367
370
  const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
368
371
  const fpRow = fpRows[0];
@@ -371,26 +374,60 @@ class QueryBuilder {
371
374
  if (currentFingerprint !== lastFingerprint) {
372
375
  lastFingerprint = currentFingerprint;
373
376
  const rows = this.all();
374
- callback(rows);
377
+ await callback(rows);
375
378
  }
376
379
  } catch {}
380
+ if (!stopped)
381
+ setTimeout(poll, interval);
377
382
  };
378
383
  if (immediate) {
379
384
  poll();
385
+ } else {
386
+ setTimeout(poll, interval);
380
387
  }
381
- const timer = setInterval(poll, interval);
382
388
  return () => {
383
- clearInterval(timer);
389
+ stopped = true;
384
390
  };
385
391
  }
386
- buildFingerprintSQL() {
392
+ each(callback, options = {}) {
393
+ const { interval = this.defaultPollInterval } = options;
394
+ const userWhere = this.buildWhereClause();
395
+ const maxRows = this.executor(`SELECT MAX(id) as _max FROM ${this.tableName} ${userWhere.sql ? `WHERE ${userWhere.sql}` : ""}`, userWhere.params, true);
396
+ let lastMaxId = maxRows[0]?._max ?? 0;
397
+ let lastRevision = this.revisionGetter?.() ?? "0";
398
+ let stopped = false;
399
+ const poll = async () => {
400
+ if (stopped)
401
+ return;
402
+ const rev = this.revisionGetter?.() ?? "0";
403
+ if (rev !== lastRevision) {
404
+ lastRevision = rev;
405
+ const params = [...userWhere.params, lastMaxId];
406
+ const whereClause = userWhere.sql ? `WHERE ${userWhere.sql} AND id > ? ORDER BY id ASC` : `WHERE id > ? ORDER BY id ASC`;
407
+ const sql = `SELECT * FROM ${this.tableName} ${whereClause}`;
408
+ const newRows = this.executor(sql, params, false);
409
+ for (const row of newRows) {
410
+ if (stopped)
411
+ return;
412
+ await callback(row);
413
+ lastMaxId = row.id;
414
+ }
415
+ }
416
+ if (!stopped)
417
+ setTimeout(poll, interval);
418
+ };
419
+ setTimeout(poll, interval);
420
+ return () => {
421
+ stopped = true;
422
+ };
423
+ }
424
+ buildWhereClause() {
387
425
  const params = [];
388
- let sql = `SELECT COUNT(*) as _cnt, MAX(id) as _max FROM ${this.tableName}`;
389
426
  if (this.iqo.whereAST) {
390
427
  const compiled = compileAST(this.iqo.whereAST);
391
- sql += ` WHERE ${compiled.sql}`;
392
- params.push(...compiled.params);
393
- } else if (this.iqo.wheres.length > 0) {
428
+ return { sql: compiled.sql, params: compiled.params };
429
+ }
430
+ if (this.iqo.wheres.length > 0) {
394
431
  const whereParts = [];
395
432
  for (const w of this.iqo.wheres) {
396
433
  if (w.operator === "IN") {
@@ -407,9 +444,14 @@ class QueryBuilder {
407
444
  params.push(transformValueForStorage(w.value));
408
445
  }
409
446
  }
410
- sql += ` WHERE ${whereParts.join(" AND ")}`;
447
+ return { sql: whereParts.join(" AND "), params };
411
448
  }
412
- return { sql, params };
449
+ return { sql: "", params: [] };
450
+ }
451
+ buildFingerprintSQL() {
452
+ const where = this.buildWhereClause();
453
+ const sql = `SELECT COUNT(*) as _cnt, MAX(id) as _max FROM ${this.tableName}${where.sql ? ` WHERE ${where.sql}` : ""}`;
454
+ return { sql, params: where.params };
413
455
  }
414
456
  then(onfulfilled, onrejected) {
415
457
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
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,12 +495,12 @@ 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;
@@ -505,8 +508,10 @@ export class QueryBuilder<T extends Record<string, any>> {
505
508
  // Build the fingerprint SQL (COUNT + MAX(id)) using the same WHERE
506
509
  const fingerprintSQL = this.buildFingerprintSQL();
507
510
  let lastFingerprint: string | null = null;
511
+ let stopped = false;
508
512
 
509
- const poll = () => {
513
+ const poll = async () => {
514
+ if (stopped) return;
510
515
  try {
511
516
  // Run lightweight fingerprint check
512
517
  const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
@@ -520,36 +525,112 @@ export class QueryBuilder<T extends Record<string, any>> {
520
525
  lastFingerprint = currentFingerprint;
521
526
  // Fingerprint changed → re-execute the full query
522
527
  const rows = this.all();
523
- callback(rows);
528
+ await callback(rows);
524
529
  }
525
530
  } catch {
526
531
  // Silently skip on error (table might be in transition)
527
532
  }
533
+ // Self-scheduling: next poll only after this one completes
534
+ if (!stopped) setTimeout(poll, interval);
528
535
  };
529
536
 
530
537
  // Immediate first execution
531
538
  if (immediate) {
532
539
  poll();
540
+ } else {
541
+ setTimeout(poll, interval);
533
542
  }
534
543
 
535
- const timer = setInterval(poll, interval);
544
+ return () => { stopped = true; };
545
+ }
536
546
 
537
- // Return unsubscribe function
538
- return () => {
539
- clearInterval(timer);
547
+ /**
548
+ * Stream new rows one at a time via a watermark (last seen id).
549
+ *
550
+ * Unlike `.subscribe()` (which gives you an array snapshot), `.each()`
551
+ * calls your callback once per new row, in insertion order. The SQL
552
+ * `WHERE id > watermark` is rebuilt each poll with the latest value,
553
+ * so it's always O(new_rows) — not O(table_size).
554
+ *
555
+ * Composes with the query builder chain: any `.where()` conditions
556
+ * are combined with the watermark clause.
557
+ *
558
+ * ```ts
559
+ * // All new messages
560
+ * const unsub = db.messages.select().each((msg) => {
561
+ * console.log('New:', msg.text);
562
+ * });
563
+ *
564
+ * // Only new messages by Alice
565
+ * const unsub2 = db.messages.select()
566
+ * .where({ author: 'Alice' })
567
+ * .each((msg) => console.log(msg.text));
568
+ * ```
569
+ *
570
+ * @param callback Called once per new row. Async callbacks are awaited.
571
+ * @param options `interval` in ms (default: pollInterval).
572
+ * @returns Unsubscribe function.
573
+ */
574
+ each(
575
+ callback: (row: T) => void | Promise<void>,
576
+ options: { interval?: number } = {},
577
+ ): () => void {
578
+ const { interval = this.defaultPollInterval } = options;
579
+
580
+ // Compile the user's WHERE clause (if any) so we can combine with watermark
581
+ const userWhere = this.buildWhereClause();
582
+
583
+ // Initialize watermark to current max id, respecting user's WHERE clause
584
+ const maxRows = this.executor(
585
+ `SELECT MAX(id) as _max FROM ${this.tableName} ${userWhere.sql ? `WHERE ${userWhere.sql}` : ''}`,
586
+ userWhere.params,
587
+ true
588
+ );
589
+ let lastMaxId: number = (maxRows[0] as any)?._max ?? 0;
590
+ let lastRevision = this.revisionGetter?.() ?? '0';
591
+ let stopped = false;
592
+
593
+ const poll = async () => {
594
+ if (stopped) return;
595
+
596
+ const rev = this.revisionGetter?.() ?? '0';
597
+ if (rev !== lastRevision) {
598
+ lastRevision = rev;
599
+
600
+ // Combine user WHERE with watermark: WHERE (user_conditions) AND id > ?
601
+ const params = [...userWhere.params, lastMaxId];
602
+ const whereClause = userWhere.sql
603
+ ? `WHERE ${userWhere.sql} AND id > ? ORDER BY id ASC`
604
+ : `WHERE id > ? ORDER BY id ASC`;
605
+ const sql = `SELECT * FROM ${this.tableName} ${whereClause}`;
606
+
607
+ // raw=false → rows go through transform + get .update()/.delete() methods
608
+ const newRows = this.executor(sql, params, false);
609
+
610
+ for (const row of newRows) {
611
+ if (stopped) return;
612
+ await callback(row as T);
613
+ lastMaxId = (row as any).id;
614
+ }
615
+ }
616
+
617
+ if (!stopped) setTimeout(poll, interval);
540
618
  };
619
+
620
+ setTimeout(poll, interval);
621
+ return () => { stopped = true; };
541
622
  }
542
623
 
543
- /** Build a lightweight fingerprint query (COUNT + MAX(id)) that shares the same WHERE clause. */
544
- private buildFingerprintSQL(): { sql: string; params: any[] } {
624
+ /** Compile the IQO's WHERE conditions into a SQL fragment + params (without the WHERE keyword). */
625
+ private buildWhereClause(): { sql: string; params: any[] } {
545
626
  const params: any[] = [];
546
- let sql = `SELECT COUNT(*) as _cnt, MAX(id) as _max FROM ${this.tableName}`;
547
627
 
548
628
  if (this.iqo.whereAST) {
549
629
  const compiled = compileAST(this.iqo.whereAST);
550
- sql += ` WHERE ${compiled.sql}`;
551
- params.push(...compiled.params);
552
- } else if (this.iqo.wheres.length > 0) {
630
+ return { sql: compiled.sql, params: compiled.params };
631
+ }
632
+
633
+ if (this.iqo.wheres.length > 0) {
553
634
  const whereParts: string[] = [];
554
635
  for (const w of this.iqo.wheres) {
555
636
  if (w.operator === 'IN') {
@@ -566,10 +647,17 @@ export class QueryBuilder<T extends Record<string, any>> {
566
647
  params.push(transformValueForStorage(w.value));
567
648
  }
568
649
  }
569
- sql += ` WHERE ${whereParts.join(' AND ')}`;
650
+ return { sql: whereParts.join(' AND '), params };
570
651
  }
571
652
 
572
- return { sql, params };
653
+ return { sql: '', params: [] };
654
+ }
655
+
656
+ /** Build a lightweight fingerprint query (COUNT + MAX(id)) that shares the same WHERE clause. */
657
+ private buildFingerprintSQL(): { sql: string; params: any[] } {
658
+ const where = this.buildWhereClause();
659
+ const sql = `SELECT COUNT(*) as _cnt, MAX(id) as _max FROM ${this.tableName}${where.sql ? ` WHERE ${where.sql}` : ''}`;
660
+ return { sql, params: where.params };
573
661
  }
574
662
 
575
663
  // ---------- Thenable (async/await support) ----------