sqlite-zod-orm 3.2.0 → 3.3.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/README.md CHANGED
@@ -207,29 +207,60 @@ const db = new Database(':memory:', schemas, {
207
207
 
208
208
  ---
209
209
 
210
- ## Change Tracking & Events
210
+ ## Reactivity `select().subscribe()`
211
211
 
212
- ```typescript
213
- const db = new Database(':memory:', schemas, { changeTracking: true });
214
- db.getChangesSince(0);
215
-
216
- db.users.subscribe('insert', (user) => console.log('New:', user.name));
217
- ```
218
-
219
- ---
220
-
221
- ## Smart Polling
212
+ One API to watch any query for changes. Detects **all** mutations (inserts, updates, deletes) with zero disk overhead.
222
213
 
223
214
  ```typescript
224
215
  const unsub = db.users.select()
225
216
  .where({ role: 'admin' })
217
+ .orderBy('name', 'asc')
226
218
  .subscribe((admins) => {
227
- console.log('Admin list changed:', admins);
219
+ console.log('Admin list:', admins.map(a => a.name));
228
220
  }, { interval: 1000 });
229
221
 
222
+ // Stop watching
230
223
  unsub();
231
224
  ```
232
225
 
226
+ **Options:**
227
+
228
+ | Option | Default | Description |
229
+ |---|---|---|
230
+ | `interval` | `500` | Polling interval in milliseconds |
231
+ | `immediate` | `true` | Fire callback immediately with current data |
232
+
233
+ ### How it works
234
+
235
+ ```
236
+ ┌──────────────────────────────────────────────────┐
237
+ │ Every {interval}ms: │
238
+ │ │
239
+ │ 1. Check in-memory revision counter (free) │
240
+ │ 2. Run: SELECT COUNT(*), MAX(id) │
241
+ │ FROM users WHERE role = 'admin' │
242
+ │ │
243
+ │ 3. Combine into fingerprint: "count:max:rev" │
244
+ │ │
245
+ │ 4. If fingerprint changed → re-run full query │
246
+ │ and call your callback │
247
+ └──────────────────────────────────────────────────┘
248
+ ```
249
+
250
+ Since `_Database` controls all writes, it bumps an in-memory revision counter on every insert, update, and delete. The fingerprint includes this counter, so **all changes are always detected** — no triggers, no WAL, no `_changes` table.
251
+
252
+ | Operation | Detected | How |
253
+ |---|---|---|
254
+ | INSERT | ✅ | MAX(id) increases + revision bumps |
255
+ | DELETE | ✅ | COUNT changes + revision bumps |
256
+ | UPDATE | ✅ | revision bumps |
257
+
258
+ **Use cases:**
259
+ - Live dashboards (poll every 1-5s)
260
+ - Real-time chat / message lists
261
+ - Auto-refreshing data tables
262
+ - Watching filtered subsets of data
263
+
233
264
  ---
234
265
 
235
266
  ## Examples & Tests
@@ -262,10 +293,8 @@ bun test # 91 tests
262
293
  | `entity.navMethod()` | Lazy navigation (FK name minus `_id`) |
263
294
  | `entity.update(data)` | Update entity in-place |
264
295
  | `entity.delete()` | Delete entity |
265
- | **Events** | |
266
- | `db.table.subscribe(event, callback)` | Listen for insert/update/delete |
267
- | `db.table.select().subscribe(cb, opts)` | Smart polling |
268
- | `db.getChangesSince(version, table?)` | Change tracking |
296
+ | **Reactivity** | |
297
+ | `select().subscribe(cb, opts?)` | Watch any query for changes (all mutations) |
269
298
 
270
299
  ## License
271
300
 
package/dist/index.js CHANGED
@@ -12,7 +12,6 @@ var __export = (target, all) => {
12
12
 
13
13
  // src/database.ts
14
14
  import { Database as SqliteDatabase } from "bun:sqlite";
15
- import { EventEmitter } from "events";
16
15
 
17
16
  // src/ast.ts
18
17
  var wrapNode = (val) => val !== null && typeof val === "object" && ("type" in val) ? val : { type: "literal", value: val };
@@ -174,12 +173,14 @@ class QueryBuilder {
174
173
  singleExecutor;
175
174
  joinResolver;
176
175
  conditionResolver;
177
- constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver) {
176
+ revisionGetter;
177
+ constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter) {
178
178
  this.tableName = tableName;
179
179
  this.executor = executor;
180
180
  this.singleExecutor = singleExecutor;
181
181
  this.joinResolver = joinResolver ?? null;
182
182
  this.conditionResolver = conditionResolver ?? null;
183
+ this.revisionGetter = revisionGetter ?? null;
183
184
  this.iqo = {
184
185
  selects: [],
185
186
  wheres: [],
@@ -333,7 +334,8 @@ class QueryBuilder {
333
334
  try {
334
335
  const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
335
336
  const fpRow = fpRows[0];
336
- const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}`;
337
+ const rev = this.revisionGetter?.() ?? 0;
338
+ const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
337
339
  if (currentFingerprint !== lastFingerprint) {
338
340
  lastFingerprint = currentFingerprint;
339
341
  const rows = this.all();
@@ -4649,26 +4651,22 @@ function transformFromStorage(row, schema) {
4649
4651
  }
4650
4652
 
4651
4653
  // src/database.ts
4652
- class _Database extends EventEmitter {
4654
+ class _Database {
4653
4655
  db;
4654
4656
  schemas;
4655
4657
  relationships;
4656
- subscriptions;
4657
4658
  options;
4659
+ _revisions = {};
4658
4660
  constructor(dbFile, schemas, options = {}) {
4659
- super();
4660
4661
  this.db = new SqliteDatabase(dbFile);
4661
4662
  this.db.run("PRAGMA foreign_keys = ON");
4662
4663
  this.schemas = schemas;
4663
4664
  this.options = options;
4664
- this.subscriptions = { insert: {}, update: {}, delete: {} };
4665
4665
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
4666
4666
  this.initializeTables();
4667
4667
  this.runMigrations();
4668
4668
  if (options.indexes)
4669
4669
  this.createIndexes(options.indexes);
4670
- if (options.changeTracking)
4671
- this.setupChangeTracking();
4672
4670
  for (const entityName of Object.keys(schemas)) {
4673
4671
  const key = entityName;
4674
4672
  const accessor = {
@@ -4680,8 +4678,6 @@ class _Database extends EventEmitter {
4680
4678
  },
4681
4679
  upsert: (conditions, data) => this.upsert(entityName, data, conditions),
4682
4680
  delete: (id) => this.delete(entityName, id),
4683
- subscribe: (event, callback) => this.subscribe(event, entityName, callback),
4684
- unsubscribe: (event, callback) => this.unsubscribe(event, entityName, callback),
4685
4681
  select: (...cols) => this._createQueryBuilder(entityName, cols),
4686
4682
  _tableName: entityName
4687
4683
  };
@@ -4703,66 +4699,32 @@ class _Database extends EventEmitter {
4703
4699
  }
4704
4700
  }
4705
4701
  runMigrations() {
4706
- this.db.run(`CREATE TABLE IF NOT EXISTS _schema_meta (
4707
- id INTEGER PRIMARY KEY AUTOINCREMENT,
4708
- table_name TEXT NOT NULL,
4709
- column_name TEXT NOT NULL,
4710
- added_at TEXT DEFAULT (datetime('now')),
4711
- UNIQUE(table_name, column_name)
4712
- )`);
4713
4702
  for (const [entityName, schema] of Object.entries(this.schemas)) {
4714
- const existingCols = new Set(this.db.query(`PRAGMA table_info(${entityName})`).all().map((c) => c.name));
4703
+ const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all();
4704
+ const existingNames = new Set(existingColumns.map((c) => c.name));
4715
4705
  const storableFields = getStorableFields(schema);
4716
4706
  for (const field of storableFields) {
4717
- if (!existingCols.has(field.name)) {
4718
- this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${zodTypeToSqlType(field.type)}`);
4719
- this.db.query(`INSERT OR IGNORE INTO _schema_meta (table_name, column_name) VALUES (?, ?)`).run(entityName, field.name);
4707
+ if (!existingNames.has(field.name)) {
4708
+ const sqlType = zodTypeToSqlType(field.type);
4709
+ this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${sqlType}`);
4720
4710
  }
4721
4711
  }
4722
4712
  }
4723
4713
  }
4724
- createIndexes(indexDefs) {
4725
- for (const [tableName, indexes] of Object.entries(indexDefs)) {
4726
- if (!this.schemas[tableName])
4727
- throw new Error(`Cannot create index on unknown table '${tableName}'`);
4728
- const indexList = Array.isArray(indexes) ? indexes : [indexes];
4729
- for (const indexDef of indexList) {
4730
- const columns = Array.isArray(indexDef) ? indexDef : [indexDef];
4731
- const indexName = `idx_${tableName}_${columns.join("_")}`;
4732
- this.db.run(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columns.join(", ")})`);
4714
+ createIndexes(indexes) {
4715
+ for (const [tableName, indexDefs] of Object.entries(indexes)) {
4716
+ for (const def of indexDefs) {
4717
+ const cols = Array.isArray(def) ? def : [def];
4718
+ const idxName = `idx_${tableName}_${cols.join("_")}`;
4719
+ this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${tableName} (${cols.join(", ")})`);
4733
4720
  }
4734
4721
  }
4735
4722
  }
4736
- setupChangeTracking() {
4737
- this.db.run(`CREATE TABLE IF NOT EXISTS _changes (
4738
- id INTEGER PRIMARY KEY AUTOINCREMENT,
4739
- table_name TEXT NOT NULL,
4740
- row_id INTEGER NOT NULL,
4741
- action TEXT NOT NULL CHECK(action IN ('INSERT', 'UPDATE', 'DELETE')),
4742
- changed_at TEXT DEFAULT (datetime('now'))
4743
- )`);
4744
- this.db.run(`CREATE INDEX IF NOT EXISTS idx_changes_table ON _changes (table_name, id)`);
4745
- for (const entityName of Object.keys(this.schemas)) {
4746
- for (const action of ["insert", "update", "delete"]) {
4747
- const ref = action === "delete" ? "OLD" : "NEW";
4748
- this.db.run(`CREATE TRIGGER IF NOT EXISTS _trg_${entityName}_${action}
4749
- AFTER ${action.toUpperCase()} ON ${entityName}
4750
- BEGIN
4751
- INSERT INTO _changes (table_name, row_id, action) VALUES ('${entityName}', ${ref}.id, '${action.toUpperCase()}');
4752
- END`);
4753
- }
4754
- }
4723
+ _bumpRevision(entityName) {
4724
+ this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
4755
4725
  }
4756
- getChangeSeq(tableName) {
4757
- if (!this.options.changeTracking)
4758
- return -1;
4759
- const sql = tableName ? `SELECT MAX(id) as seq FROM _changes WHERE table_name = ?` : `SELECT MAX(id) as seq FROM _changes`;
4760
- const row = this.db.query(sql).get(...tableName ? [tableName] : []);
4761
- return row?.seq ?? 0;
4762
- }
4763
- getChangesSince(sinceSeq, tableName) {
4764
- const sql = tableName ? `SELECT * FROM _changes WHERE id > ? AND table_name = ? ORDER BY id ASC` : `SELECT * FROM _changes WHERE id > ? ORDER BY id ASC`;
4765
- return this.db.query(sql).all(...tableName ? [sinceSeq, tableName] : [sinceSeq]);
4726
+ _getRevision(entityName) {
4727
+ return this._revisions[entityName] ?? 0;
4766
4728
  }
4767
4729
  insert(entityName, data) {
4768
4730
  const schema = this.schemas[entityName];
@@ -4774,8 +4736,7 @@ class _Database extends EventEmitter {
4774
4736
  const newEntity = this._getById(entityName, result.lastInsertRowid);
4775
4737
  if (!newEntity)
4776
4738
  throw new Error("Failed to retrieve entity after insertion");
4777
- this.emit("insert", entityName, newEntity);
4778
- this.subscriptions.insert[entityName]?.forEach((cb) => cb(newEntity));
4739
+ this._bumpRevision(entityName);
4779
4740
  return newEntity;
4780
4741
  }
4781
4742
  _getById(entityName, id) {
@@ -4804,11 +4765,8 @@ class _Database extends EventEmitter {
4804
4765
  return this._getById(entityName, id);
4805
4766
  const setClause = Object.keys(transformed).map((key) => `${key} = ?`).join(", ");
4806
4767
  this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
4768
+ this._bumpRevision(entityName);
4807
4769
  const updatedEntity = this._getById(entityName, id);
4808
- if (updatedEntity) {
4809
- this.emit("update", entityName, updatedEntity);
4810
- this.subscriptions.update[entityName]?.forEach((cb) => cb(updatedEntity));
4811
- }
4812
4770
  return updatedEntity;
4813
4771
  }
4814
4772
  _updateWhere(entityName, data, conditions) {
@@ -4824,12 +4782,8 @@ class _Database extends EventEmitter {
4824
4782
  const setClause = setCols.map((key) => `${key} = ?`).join(", ");
4825
4783
  const result = this.db.query(`UPDATE ${entityName} SET ${setClause} ${clause}`).run(...setCols.map((key) => transformed[key]), ...whereValues);
4826
4784
  const affected = result.changes ?? 0;
4827
- if (affected > 0 && (this.subscriptions.update[entityName]?.length || this.options.changeTracking)) {
4828
- for (const entity of this._findMany(entityName, conditions)) {
4829
- this.emit("update", entityName, entity);
4830
- this.subscriptions.update[entityName]?.forEach((cb) => cb(entity));
4831
- }
4832
- }
4785
+ if (affected > 0)
4786
+ this._bumpRevision(entityName);
4833
4787
  return affected;
4834
4788
  }
4835
4789
  _createUpdateBuilder(entityName, data) {
@@ -4859,8 +4813,7 @@ class _Database extends EventEmitter {
4859
4813
  const entity = this._getById(entityName, id);
4860
4814
  if (entity) {
4861
4815
  this.db.query(`DELETE FROM ${entityName} WHERE id = ?`).run(id);
4862
- this.emit("delete", entityName, entity);
4863
- this.subscriptions.delete[entityName]?.forEach((cb) => cb(entity));
4816
+ this._bumpRevision(entityName);
4864
4817
  }
4865
4818
  }
4866
4819
  _attachMethods(entityName, entity) {
@@ -4957,15 +4910,6 @@ class _Database extends EventEmitter {
4957
4910
  throw new Error(`Transaction failed: ${error.message}`);
4958
4911
  }
4959
4912
  }
4960
- subscribe(event, entityName, callback) {
4961
- this.subscriptions[event][entityName] = this.subscriptions[event][entityName] || [];
4962
- this.subscriptions[event][entityName].push(callback);
4963
- }
4964
- unsubscribe(event, entityName, callback) {
4965
- if (this.subscriptions[event][entityName]) {
4966
- this.subscriptions[event][entityName] = this.subscriptions[event][entityName].filter((cb) => cb !== callback);
4967
- }
4968
- }
4969
4913
  _createQueryBuilder(entityName, initialCols) {
4970
4914
  const schema = this.schemas[entityName];
4971
4915
  const executor = (sql, params, raw) => {
@@ -4987,7 +4931,8 @@ class _Database extends EventEmitter {
4987
4931
  return { fk: "id", pk: reverse.foreignKey };
4988
4932
  return null;
4989
4933
  };
4990
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver);
4934
+ const revisionGetter = () => this._getRevision(entityName);
4935
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
4991
4936
  if (initialCols.length > 0)
4992
4937
  builder.select(...initialCols);
4993
4938
  return builder;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.2.0",
3
+ "version": "3.3.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
@@ -5,7 +5,6 @@
5
5
  * query builders, and event handling.
6
6
  */
7
7
  import { Database as SqliteDatabase } from 'bun:sqlite';
8
- import { EventEmitter } from 'events';
9
8
  import { z } from 'zod';
10
9
  import { QueryBuilder } from './query-builder';
11
10
  import { executeProxyQuery, type ProxyQueryResult } from './proxy-query';
@@ -25,25 +24,25 @@ import {
25
24
  // Database Class
26
25
  // =============================================================================
27
26
 
28
- class _Database<Schemas extends SchemaMap> extends EventEmitter {
27
+ class _Database<Schemas extends SchemaMap> {
29
28
  private db: SqliteDatabase;
30
29
  private schemas: Schemas;
31
30
  private relationships: Relationship[];
32
- private subscriptions: Record<'insert' | 'update' | 'delete', Record<string, ((data: any) => void)[]>>;
33
31
  private options: DatabaseOptions;
34
32
 
33
+ /** In-memory revision counter per table — bumps on every write (insert/update/delete).
34
+ * Used by QueryBuilder.subscribe() fingerprint to detect ALL changes with zero overhead. */
35
+ private _revisions: Record<string, number> = {};
36
+
35
37
  constructor(dbFile: string, schemas: Schemas, options: DatabaseOptions = {}) {
36
- super();
37
38
  this.db = new SqliteDatabase(dbFile);
38
39
  this.db.run('PRAGMA foreign_keys = ON');
39
40
  this.schemas = schemas;
40
41
  this.options = options;
41
- this.subscriptions = { insert: {}, update: {}, delete: {} };
42
42
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
43
43
  this.initializeTables();
44
44
  this.runMigrations();
45
45
  if (options.indexes) this.createIndexes(options.indexes);
46
- if (options.changeTracking) this.setupChangeTracking();
47
46
 
48
47
  // Create typed entity accessors (db.users, db.posts, etc.)
49
48
  for (const entityName of Object.keys(schemas)) {
@@ -56,8 +55,6 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
56
55
  },
57
56
  upsert: (conditions, data) => this.upsert(entityName, data, conditions),
58
57
  delete: (id) => this.delete(entityName, id),
59
- subscribe: (event, callback) => this.subscribe(event, entityName, callback),
60
- unsubscribe: (event, callback) => this.unsubscribe(event, entityName, callback),
61
58
  select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
62
59
  _tableName: entityName,
63
60
  };
@@ -90,85 +87,42 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
90
87
  }
91
88
 
92
89
  private runMigrations(): void {
93
- this.db.run(`CREATE TABLE IF NOT EXISTS _schema_meta (
94
- id INTEGER PRIMARY KEY AUTOINCREMENT,
95
- table_name TEXT NOT NULL,
96
- column_name TEXT NOT NULL,
97
- added_at TEXT DEFAULT (datetime('now')),
98
- UNIQUE(table_name, column_name)
99
- )`);
100
-
101
90
  for (const [entityName, schema] of Object.entries(this.schemas)) {
102
- const existingCols = new Set(
103
- (this.db.query(`PRAGMA table_info(${entityName})`).all() as any[]).map(c => c.name)
104
- );
105
- const storableFields = getStorableFields(schema);
91
+ const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all() as any[];
92
+ const existingNames = new Set(existingColumns.map(c => c.name));
106
93
 
94
+ const storableFields = getStorableFields(schema);
107
95
  for (const field of storableFields) {
108
- if (!existingCols.has(field.name)) {
109
- this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${zodTypeToSqlType(field.type)}`);
110
- this.db.query(`INSERT OR IGNORE INTO _schema_meta (table_name, column_name) VALUES (?, ?)`).run(entityName, field.name);
96
+ if (!existingNames.has(field.name)) {
97
+ const sqlType = zodTypeToSqlType(field.type);
98
+ this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${sqlType}`);
111
99
  }
112
100
  }
113
101
  }
114
102
  }
115
103
 
116
- // ===========================================================================
117
- // Indexes
118
- // ===========================================================================
119
-
120
- private createIndexes(indexDefs: Record<string, string | (string | string[])[]>): void {
121
- for (const [tableName, indexes] of Object.entries(indexDefs)) {
122
- if (!this.schemas[tableName]) throw new Error(`Cannot create index on unknown table '${tableName}'`);
123
- const indexList = Array.isArray(indexes) ? indexes : [indexes];
124
- for (const indexDef of indexList) {
125
- const columns = Array.isArray(indexDef) ? indexDef : [indexDef];
126
- const indexName = `idx_${tableName}_${columns.join('_')}`;
127
- this.db.run(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columns.join(', ')})`);
104
+ private createIndexes(indexes: Record<string, (string | string[])[]>): void {
105
+ for (const [tableName, indexDefs] of Object.entries(indexes)) {
106
+ for (const def of indexDefs) {
107
+ const cols = Array.isArray(def) ? def : [def];
108
+ const idxName = `idx_${tableName}_${cols.join('_')}`;
109
+ this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${tableName} (${cols.join(', ')})`);
128
110
  }
129
111
  }
130
112
  }
131
113
 
132
114
  // ===========================================================================
133
- // Change Tracking
115
+ // Revision Tracking (in-memory, zero overhead)
134
116
  // ===========================================================================
135
117
 
136
- private setupChangeTracking(): void {
137
- this.db.run(`CREATE TABLE IF NOT EXISTS _changes (
138
- id INTEGER PRIMARY KEY AUTOINCREMENT,
139
- table_name TEXT NOT NULL,
140
- row_id INTEGER NOT NULL,
141
- action TEXT NOT NULL CHECK(action IN ('INSERT', 'UPDATE', 'DELETE')),
142
- changed_at TEXT DEFAULT (datetime('now'))
143
- )`);
144
- this.db.run(`CREATE INDEX IF NOT EXISTS idx_changes_table ON _changes (table_name, id)`);
145
-
146
- for (const entityName of Object.keys(this.schemas)) {
147
- for (const action of ['insert', 'update', 'delete'] as const) {
148
- const ref = action === 'delete' ? 'OLD' : 'NEW';
149
- this.db.run(`CREATE TRIGGER IF NOT EXISTS _trg_${entityName}_${action}
150
- AFTER ${action.toUpperCase()} ON ${entityName}
151
- BEGIN
152
- INSERT INTO _changes (table_name, row_id, action) VALUES ('${entityName}', ${ref}.id, '${action.toUpperCase()}');
153
- END`);
154
- }
155
- }
156
- }
157
-
158
- public getChangeSeq(tableName?: string): number {
159
- if (!this.options.changeTracking) return -1;
160
- const sql = tableName
161
- ? `SELECT MAX(id) as seq FROM _changes WHERE table_name = ?`
162
- : `SELECT MAX(id) as seq FROM _changes`;
163
- const row = this.db.query(sql).get(...(tableName ? [tableName] : [])) as any;
164
- return row?.seq ?? 0;
118
+ /** Bump the revision counter for a table. Called on every write. */
119
+ private _bumpRevision(entityName: string): void {
120
+ this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
165
121
  }
166
122
 
167
- public getChangesSince(sinceSeq: number, tableName?: string) {
168
- const sql = tableName
169
- ? `SELECT * FROM _changes WHERE id > ? AND table_name = ? ORDER BY id ASC`
170
- : `SELECT * FROM _changes WHERE id > ? ORDER BY id ASC`;
171
- return this.db.query(sql).all(...(tableName ? [sinceSeq, tableName] : [sinceSeq])) as any[];
123
+ /** Get the current revision for a table. Used by QueryBuilder.subscribe() fingerprint. */
124
+ public _getRevision(entityName: string): number {
125
+ return this._revisions[entityName] ?? 0;
172
126
  }
173
127
 
174
128
  // ===========================================================================
@@ -189,8 +143,7 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
189
143
  const newEntity = this._getById(entityName, result.lastInsertRowid as number);
190
144
  if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
191
145
 
192
- this.emit('insert', entityName, newEntity);
193
- this.subscriptions.insert[entityName]?.forEach(cb => cb(newEntity));
146
+ this._bumpRevision(entityName);
194
147
  return newEntity;
195
148
  }
196
149
 
@@ -227,11 +180,8 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
227
180
  const setClause = Object.keys(transformed).map(key => `${key} = ?`).join(', ');
228
181
  this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
229
182
 
183
+ this._bumpRevision(entityName);
230
184
  const updatedEntity = this._getById(entityName, id);
231
- if (updatedEntity) {
232
- this.emit('update', entityName, updatedEntity);
233
- this.subscriptions.update[entityName]?.forEach(cb => cb(updatedEntity));
234
- }
235
185
  return updatedEntity;
236
186
  }
237
187
 
@@ -252,12 +202,7 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
252
202
  );
253
203
 
254
204
  const affected = (result as any).changes ?? 0;
255
- if (affected > 0 && (this.subscriptions.update[entityName]?.length || this.options.changeTracking)) {
256
- for (const entity of this._findMany(entityName, conditions)) {
257
- this.emit('update', entityName, entity);
258
- this.subscriptions.update[entityName]?.forEach(cb => cb(entity));
259
- }
260
- }
205
+ if (affected > 0) this._bumpRevision(entityName);
261
206
  return affected;
262
207
  }
263
208
 
@@ -292,8 +237,7 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
292
237
  const entity = this._getById(entityName, id);
293
238
  if (entity) {
294
239
  this.db.query(`DELETE FROM ${entityName} WHERE id = ?`).run(id);
295
- this.emit('delete', entityName, entity);
296
- this.subscriptions.delete[entityName]?.forEach(cb => cb(entity));
240
+ this._bumpRevision(entityName);
297
241
  }
298
242
  }
299
243
 
@@ -415,21 +359,6 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
415
359
  }
416
360
  }
417
361
 
418
- // ===========================================================================
419
- // Events
420
- // ===========================================================================
421
-
422
- private subscribe(event: 'insert' | 'update' | 'delete', entityName: string, callback: (data: any) => void): void {
423
- this.subscriptions[event][entityName] = this.subscriptions[event][entityName] || [];
424
- this.subscriptions[event][entityName].push(callback);
425
- }
426
-
427
- private unsubscribe(event: 'insert' | 'update' | 'delete', entityName: string, callback: (data: any) => void): void {
428
- if (this.subscriptions[event][entityName]) {
429
- this.subscriptions[event][entityName] = this.subscriptions[event][entityName].filter(cb => cb !== callback);
430
- }
431
- }
432
-
433
362
  // ===========================================================================
434
363
  // Query Builders
435
364
  // ===========================================================================
@@ -460,7 +389,10 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
460
389
  return null;
461
390
  };
462
391
 
463
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver);
392
+ // Pass revision getter — allows .subscribe() to detect ALL changes
393
+ const revisionGetter = () => this._getRevision(entityName);
394
+
395
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
464
396
  if (initialCols.length > 0) builder.select(...initialCols);
465
397
  return builder;
466
398
  }
@@ -167,6 +167,7 @@ export class QueryBuilder<T extends Record<string, any>> {
167
167
  private singleExecutor: (sql: string, params: any[], raw: boolean) => any | null;
168
168
  private joinResolver: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null;
169
169
  private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
170
+ private revisionGetter: (() => number) | null;
170
171
 
171
172
  constructor(
172
173
  tableName: string,
@@ -174,12 +175,14 @@ export class QueryBuilder<T extends Record<string, any>> {
174
175
  singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
175
176
  joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
176
177
  conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
178
+ revisionGetter?: (() => number) | null,
177
179
  ) {
178
180
  this.tableName = tableName;
179
181
  this.executor = executor;
180
182
  this.singleExecutor = singleExecutor;
181
183
  this.joinResolver = joinResolver ?? null;
182
184
  this.conditionResolver = conditionResolver ?? null;
185
+ this.revisionGetter = revisionGetter ?? null;
183
186
  this.iqo = {
184
187
  selects: [],
185
188
  wheres: [],
@@ -420,9 +423,9 @@ export class QueryBuilder<T extends Record<string, any>> {
420
423
  /**
421
424
  * Subscribe to query result changes using smart interval-based polling.
422
425
  *
423
- * Instead of re-fetching all rows every tick, it runs a lightweight
424
- * fingerprint query (`SELECT COUNT(*), MAX(id)`) with the same WHERE clause.
425
- * The full query is only re-executed when the fingerprint changes.
426
+ * Uses a lightweight fingerprint (`COUNT(*), MAX(id)`) combined with an
427
+ * in-memory revision counter to detect ALL changes (inserts, updates, deletes)
428
+ * with zero disk overhead.
426
429
  *
427
430
  * ```ts
428
431
  * const unsub = db.messages.select()
@@ -456,7 +459,10 @@ export class QueryBuilder<T extends Record<string, any>> {
456
459
  // Run lightweight fingerprint check
457
460
  const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
458
461
  const fpRow = fpRows[0] as any;
459
- const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}`;
462
+ // Include in-memory revision in fingerprint.
463
+ // This ensures ALL changes (insert/update/delete) are detected.
464
+ const rev = this.revisionGetter?.() ?? 0;
465
+ const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
460
466
 
461
467
  if (currentFingerprint !== lastFingerprint) {
462
468
  lastFingerprint = currentFingerprint;
package/src/types.ts CHANGED
@@ -21,7 +21,6 @@ export type IndexDef = string | string[];
21
21
 
22
22
  /** Options for the Database constructor */
23
23
  export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
24
- changeTracking?: boolean;
25
24
  indexes?: Record<string, IndexDef[]>;
26
25
  /**
27
26
  * Declare relationships between tables.
@@ -143,8 +142,6 @@ export type NavEntityAccessor<
143
142
  & ((data: Partial<Omit<z.input<S[Table & keyof S]>, 'id'>>) => UpdateBuilder<NavEntity<S, R, Table>>);
144
143
  upsert: (conditions?: Partial<z.infer<S[Table & keyof S]>>, data?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>;
145
144
  delete: (id: number) => void;
146
- subscribe: (event: 'insert' | 'update' | 'delete', callback: (data: NavEntity<S, R, Table>) => void) => void;
147
- unsubscribe: (event: 'insert' | 'update' | 'delete', callback: (data: NavEntity<S, R, Table>) => void) => void;
148
145
  select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
149
146
  _tableName: string;
150
147
  readonly _schema?: S[Table & keyof S];
@@ -170,8 +167,6 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
170
167
  update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
171
168
  upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
172
169
  delete: (id: number) => void;
173
- subscribe: (event: 'insert' | 'update' | 'delete', callback: (data: AugmentedEntity<S>) => void) => void;
174
- unsubscribe: (event: 'insert' | 'update' | 'delete', callback: (data: AugmentedEntity<S>) => void) => void;
175
170
  select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
176
171
  _tableName: string;
177
172
  readonly _schema?: S;