sqlite-zod-orm 3.7.2 → 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,19 +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 lastFingerprint = null;
363
+ let lastRevision = null;
365
364
  let stopped = false;
366
365
  const poll = async () => {
367
366
  if (stopped)
368
367
  return;
369
368
  try {
370
- const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
371
- const fpRow = fpRows[0];
372
369
  const rev = this.revisionGetter?.() ?? "0";
373
- const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
374
- if (currentFingerprint !== lastFingerprint) {
375
- lastFingerprint = currentFingerprint;
370
+ if (rev !== lastRevision) {
371
+ lastRevision = rev;
376
372
  const rows = this.all();
377
373
  await callback(rows);
378
374
  }
@@ -448,11 +444,6 @@ class QueryBuilder {
448
444
  }
449
445
  return { sql: "", params: [] };
450
446
  }
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 };
455
- }
456
447
  then(onfulfilled, onrejected) {
457
448
  try {
458
449
  const result = this.all();
@@ -4741,6 +4732,7 @@ class _Database {
4741
4732
  this.pollInterval = options.pollInterval ?? 500;
4742
4733
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
4743
4734
  this.initializeTables();
4735
+ this.initializeChangeTracking();
4744
4736
  this.runMigrations();
4745
4737
  if (options.indexes)
4746
4738
  this.createIndexes(options.indexes);
@@ -4775,6 +4767,24 @@ class _Database {
4775
4767
  this.db.run(`CREATE TABLE IF NOT EXISTS ${entityName} (id INTEGER PRIMARY KEY AUTOINCREMENT, ${allCols}${allConstraints})`);
4776
4768
  }
4777
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
+ }
4778
4788
  runMigrations() {
4779
4789
  for (const [entityName, schema] of Object.entries(this.schemas)) {
4780
4790
  const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all();
@@ -4802,8 +4812,9 @@ class _Database {
4802
4812
  }
4803
4813
  _getRevision(entityName) {
4804
4814
  const rev = this._revisions[entityName] ?? 0;
4805
- const dataVersion = this.db.query("PRAGMA data_version").get()?.data_version ?? 0;
4806
- 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}`;
4807
4818
  }
4808
4819
  insert(entityName, data) {
4809
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.2",
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,36 +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 lastFingerprint: string | null = null;
508
+ let lastRevision: string | null = null;
511
509
  let stopped = false;
512
510
 
513
511
  const poll = async () => {
514
512
  if (stopped) return;
515
513
  try {
516
- // Run lightweight fingerprint check
517
- const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
518
- const fpRow = fpRows[0] as any;
519
- // Include revision in fingerprint (combines in-memory counter + PRAGMA data_version).
520
- // This detects ALL changes: same-process and cross-process.
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.
521
517
  const rev = this.revisionGetter?.() ?? '0';
522
- const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
523
518
 
524
- if (currentFingerprint !== lastFingerprint) {
525
- lastFingerprint = currentFingerprint;
526
- // Fingerprint changed → re-execute the full query
519
+ if (rev !== lastRevision) {
520
+ lastRevision = rev;
527
521
  const rows = this.all();
528
522
  await callback(rows);
529
523
  }
530
524
  } catch {
531
525
  // Silently skip on error (table might be in transition)
532
526
  }
533
- // Self-scheduling: next poll only after this one completes
534
527
  if (!stopped) setTimeout(poll, interval);
535
528
  };
536
529
 
537
- // Immediate first execution
538
530
  if (immediate) {
539
531
  poll();
540
532
  } else {
@@ -653,12 +645,6 @@ export class QueryBuilder<T extends Record<string, any>> {
653
645
  return { sql: '', params: [] };
654
646
  }
655
647
 
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 };
661
- }
662
648
 
663
649
  // ---------- Thenable (async/await support) ----------
664
650