sqlite-zod-orm 3.7.3 → 3.8.0

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
@@ -360,26 +360,15 @@ class QueryBuilder {
360
360
  }
361
361
  subscribe(callback, options = {}) {
362
362
  const { interval = this.defaultPollInterval, immediate = true } = options;
363
- const fingerprintSQL = this.buildFingerprintSQL();
364
- let lastCount = null;
365
- let lastMax = null;
366
- let lastInMemoryRev = null;
363
+ let lastRevision = null;
367
364
  let stopped = false;
368
365
  const poll = async () => {
369
366
  if (stopped)
370
367
  return;
371
368
  try {
372
369
  const rev = this.revisionGetter?.() ?? "0";
373
- const inMemoryChanged = rev !== lastInMemoryRev;
374
- lastInMemoryRev = rev;
375
- const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
376
- const fpRow = fpRows[0];
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) {
370
+ if (rev !== lastRevision) {
371
+ lastRevision = rev;
383
372
  const rows = this.all();
384
373
  await callback(rows);
385
374
  }
@@ -455,11 +444,6 @@ class QueryBuilder {
455
444
  }
456
445
  return { sql: "", params: [] };
457
446
  }
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 };
462
- }
463
447
  then(onfulfilled, onrejected) {
464
448
  try {
465
449
  const result = this.all();
@@ -4748,6 +4732,7 @@ class _Database {
4748
4732
  this.pollInterval = options.pollInterval ?? 500;
4749
4733
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
4750
4734
  this.initializeTables();
4735
+ this.initializeChangeTracking();
4751
4736
  this.runMigrations();
4752
4737
  if (options.indexes)
4753
4738
  this.createIndexes(options.indexes);
@@ -4782,6 +4767,24 @@ class _Database {
4782
4767
  this.db.run(`CREATE TABLE IF NOT EXISTS ${entityName} (id INTEGER PRIMARY KEY AUTOINCREMENT, ${allCols}${allConstraints})`);
4783
4768
  }
4784
4769
  }
4770
+ initializeChangeTracking() {
4771
+ this.db.run(`CREATE TABLE IF NOT EXISTS _satidb_changes (
4772
+ tbl TEXT PRIMARY KEY,
4773
+ seq INTEGER NOT NULL DEFAULT 0
4774
+ )`);
4775
+ for (const entityName of Object.keys(this.schemas)) {
4776
+ this.db.run(`INSERT OR IGNORE INTO _satidb_changes (tbl, seq) VALUES (?, 0)`, entityName);
4777
+ for (const op2 of ["insert", "update", "delete"]) {
4778
+ const triggerName = `_satidb_${entityName}_${op2}`;
4779
+ const event = op2.toUpperCase();
4780
+ this.db.run(`CREATE TRIGGER IF NOT EXISTS ${triggerName}
4781
+ AFTER ${event} ON ${entityName}
4782
+ BEGIN
4783
+ UPDATE _satidb_changes SET seq = seq + 1 WHERE tbl = '${entityName}';
4784
+ END`);
4785
+ }
4786
+ }
4787
+ }
4785
4788
  runMigrations() {
4786
4789
  for (const [entityName, schema] of Object.entries(this.schemas)) {
4787
4790
  const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all();
@@ -4809,8 +4812,9 @@ class _Database {
4809
4812
  }
4810
4813
  _getRevision(entityName) {
4811
4814
  const rev = this._revisions[entityName] ?? 0;
4812
- const dataVersion = this.db.query("PRAGMA data_version").get()?.data_version ?? 0;
4813
- return `${rev}:${dataVersion}`;
4815
+ const row = this.db.query("SELECT seq FROM _satidb_changes WHERE tbl = ?").get(entityName);
4816
+ const seq = row?.seq ?? 0;
4817
+ return `${rev}:${seq}`;
4814
4818
  }
4815
4819
  insert(entityName, data) {
4816
4820
  const schema = this.schemas[entityName];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.7.3",
3
+ "version": "3.8.0",
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",
package/src/database.ts CHANGED
@@ -31,8 +31,8 @@ class _Database<Schemas extends SchemaMap> {
31
31
  private options: DatabaseOptions;
32
32
  private pollInterval: number;
33
33
 
34
- /** In-memory revision counter per table — bumps on every write (insert/update/delete).
35
- * Used by QueryBuilder.subscribe() fingerprint to detect ALL changes with zero overhead. */
34
+ /** In-memory revision counter per table — same-process fast path.
35
+ * Complements the trigger-based _satidb_changes table for cross-process detection. */
36
36
  private _revisions: Record<string, number> = {};
37
37
 
38
38
  constructor(dbFile: string, schemas: Schemas, options: DatabaseOptions = {}) {
@@ -44,6 +44,7 @@ class _Database<Schemas extends SchemaMap> {
44
44
  this.pollInterval = options.pollInterval ?? 500;
45
45
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
46
46
  this.initializeTables();
47
+ this.initializeChangeTracking();
47
48
  this.runMigrations();
48
49
  if (options.indexes) this.createIndexes(options.indexes);
49
50
 
@@ -89,6 +90,36 @@ class _Database<Schemas extends SchemaMap> {
89
90
  }
90
91
  }
91
92
 
93
+ /**
94
+ * Initialize per-table change tracking using triggers.
95
+ *
96
+ * Creates a `_satidb_changes` table with one row per user table and a monotonic `seq` counter.
97
+ * INSERT/UPDATE/DELETE triggers on each user table auto-increment the seq.
98
+ * This enables table-specific, cross-process change detection — no PRAGMA data_version needed.
99
+ */
100
+ private initializeChangeTracking(): void {
101
+ this.db.run(`CREATE TABLE IF NOT EXISTS _satidb_changes (
102
+ tbl TEXT PRIMARY KEY,
103
+ seq INTEGER NOT NULL DEFAULT 0
104
+ )`);
105
+
106
+ for (const entityName of Object.keys(this.schemas)) {
107
+ // Ensure a row exists for this table
108
+ this.db.run(`INSERT OR IGNORE INTO _satidb_changes (tbl, seq) VALUES (?, 0)`, entityName);
109
+
110
+ // Create triggers (idempotent via IF NOT EXISTS)
111
+ for (const op of ['insert', 'update', 'delete'] as const) {
112
+ const triggerName = `_satidb_${entityName}_${op}`;
113
+ const event = op.toUpperCase();
114
+ this.db.run(`CREATE TRIGGER IF NOT EXISTS ${triggerName}
115
+ AFTER ${event} ON ${entityName}
116
+ BEGIN
117
+ UPDATE _satidb_changes SET seq = seq + 1 WHERE tbl = '${entityName}';
118
+ END`);
119
+ }
120
+ }
121
+ }
122
+
92
123
  private runMigrations(): void {
93
124
  for (const [entityName, schema] of Object.entries(this.schemas)) {
94
125
  const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all() as any[];
@@ -115,28 +146,27 @@ class _Database<Schemas extends SchemaMap> {
115
146
  }
116
147
 
117
148
  // ===========================================================================
118
- // Revision Tracking (in-memory + cross-process)
149
+ // Revision Tracking (trigger-based + in-memory fast path)
119
150
  // ===========================================================================
120
151
 
121
- /** Bump the revision counter for a table. Called on every write. */
152
+ /** Bump the in-memory revision counter. Called by our CRUD methods (same-process fast path). */
122
153
  private _bumpRevision(entityName: string): void {
123
154
  this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
124
155
  }
125
156
 
126
157
  /**
127
- * Get a composite revision string for a table.
158
+ * Get the change sequence for a table.
128
159
  *
129
- * Combines two signals:
130
- * - In-memory counter: catches writes from THIS process (our CRUD methods bump it)
131
- * - PRAGMA data_version: catches writes from OTHER processes (SQLite bumps it
132
- * whenever another connection commits, but NOT for the current connection)
160
+ * Reads from `_satidb_changes` — a per-table seq counter bumped by triggers
161
+ * on every INSERT/UPDATE/DELETE, regardless of which connection performed the write.
133
162
  *
134
- * Together they detect ALL changes regardless of source, with zero disk overhead.
163
+ * Combined with the in-memory counter for instant same-process detection.
135
164
  */
136
165
  public _getRevision(entityName: string): string {
137
166
  const rev = this._revisions[entityName] ?? 0;
138
- const dataVersion = (this.db.query('PRAGMA data_version').get() as any)?.data_version ?? 0;
139
- return `${rev}:${dataVersion}`;
167
+ const row = this.db.query('SELECT seq FROM _satidb_changes WHERE tbl = ?').get(entityName) as any;
168
+ const seq = row?.seq ?? 0;
169
+ return `${rev}:${seq}`;
140
170
  }
141
171
 
142
172
  // ===========================================================================
@@ -505,51 +505,28 @@ export class QueryBuilder<T extends Record<string, any>> {
505
505
  ): () => void {
506
506
  const { interval = this.defaultPollInterval, immediate = true } = options;
507
507
 
508
- // Build the fingerprint SQL (COUNT + MAX(id)) using the same WHERE
509
- const fingerprintSQL = this.buildFingerprintSQL();
510
- let lastCount: number | null = null;
511
- let lastMax: number | null = null;
512
- let lastInMemoryRev: string | null = null;
508
+ let lastRevision: string | null = null;
513
509
  let stopped = false;
514
510
 
515
511
  const poll = async () => {
516
512
  if (stopped) return;
517
513
  try {
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.
514
+ // Single check: revision combines in-memory counter (same-process)
515
+ // + trigger-based seq from _satidb_changes (cross-process).
516
+ // Both are table-specific no false positives from other tables.
525
517
  const rev = this.revisionGetter?.() ?? '0';
526
518
 
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) {
519
+ if (rev !== lastRevision) {
520
+ lastRevision = rev;
542
521
  const rows = this.all();
543
522
  await callback(rows);
544
523
  }
545
524
  } catch {
546
525
  // Silently skip on error (table might be in transition)
547
526
  }
548
- // Self-scheduling: next poll only after this one completes
549
527
  if (!stopped) setTimeout(poll, interval);
550
528
  };
551
529
 
552
- // Immediate first execution
553
530
  if (immediate) {
554
531
  poll();
555
532
  } else {
@@ -668,12 +645,6 @@ export class QueryBuilder<T extends Record<string, any>> {
668
645
  return { sql: '', params: [] };
669
646
  }
670
647
 
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 };
676
- }
677
648
 
678
649
  // ---------- Thenable (async/await support) ----------
679
650