sqlite-zod-orm 3.2.1 → 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,66 +207,11 @@ const db = new Database(':memory:', schemas, {
207
207
 
208
208
  ---
209
209
 
210
- ## Reactivity — Three Ways to React to Changes
210
+ ## Reactivity — `select().subscribe()`
211
211
 
212
- sqlite-zod-orm provides three reactivity mechanisms for different use cases:
213
-
214
- | System | Detects | Scope | Overhead | Best for |
215
- |---|---|---|---|---|
216
- | **CRUD Events** | insert, update, delete | In-process, per table | Zero (synchronous) | Side effects, caching, logs |
217
- | **Smart Polling** | insert, delete, update* | Any query result | Lightweight fingerprint check | Live UI, dashboards |
218
- | **Change Tracking** | insert, update, delete | Per table or global | Trigger-based WAL | Cross-process sync, audit |
219
-
220
- \* Smart polling detects UPDATEs automatically when `changeTracking` is enabled. Without it, only inserts and deletes are detected.
221
-
222
- ---
223
-
224
- ### 1. CRUD Events — `db.table.subscribe(event, callback)`
225
-
226
- Synchronous callbacks fired immediately after each CRUD operation. Zero overhead; the callback runs inline.
212
+ One API to watch any query for changes. Detects **all** mutations (inserts, updates, deletes) with zero disk overhead.
227
213
 
228
214
  ```typescript
229
- // Listen for new users
230
- db.users.subscribe('insert', (user) => {
231
- console.log('New user:', user.name); // fires on every db.users.insert(...)
232
- });
233
-
234
- // Listen for updates
235
- db.users.subscribe('update', (user) => {
236
- console.log('Updated:', user.name, '→', user.role);
237
- });
238
-
239
- // Listen for deletes
240
- db.users.subscribe('delete', (user) => {
241
- console.log('Deleted:', user.name);
242
- });
243
-
244
- // Stop listening
245
- db.users.unsubscribe('update', myCallback);
246
- ```
247
-
248
- **Use cases:**
249
- - Invalidating a cache after writes
250
- - Logging / audit trail
251
- - Sending notifications
252
- - Keeping derived data in sync (e.g., a counter table)
253
-
254
- The database also extends Node's `EventEmitter`, so you can use `db.on()`:
255
-
256
- ```typescript
257
- db.on('insert', (tableName, entity) => {
258
- console.log(`New row in ${tableName}:`, entity.id);
259
- });
260
- ```
261
-
262
- ---
263
-
264
- ### 2. Smart Polling — `select().subscribe(callback, options)`
265
-
266
- Query-level polling that watches *any query result* for changes. Instead of re-fetching all rows every tick, it runs a **lightweight fingerprint query** (`SELECT COUNT(*), MAX(id)`) with the same WHERE clause. The full query only re-executes when the fingerprint changes.
267
-
268
- ```typescript
269
- // Watch for admin list changes, poll every second
270
215
  const unsub = db.users.select()
271
216
  .where({ role: 'admin' })
272
217
  .orderBy('name', 'asc')
@@ -283,116 +228,41 @@ unsub();
283
228
  | Option | Default | Description |
284
229
  |---|---|---|
285
230
  | `interval` | `500` | Polling interval in milliseconds |
286
- | `immediate` | `true` | Whether to fire the callback immediately with the current result |
231
+ | `immediate` | `true` | Fire callback immediately with current data |
287
232
 
288
- **How the fingerprint works:**
233
+ ### How it works
289
234
 
290
235
  ```
291
- ┌─────────────────────────────────────┐
292
- │ Every {interval}ms:
293
-
294
- │ 1. Run: SELECT COUNT(*), MAX(id)
295
- FROM users WHERE role = 'admin'
296
- │ ← fast, no data transfer
297
- 2. Compare fingerprint to last
298
-
299
- 3. If changed → re-run full query ← only when needed
300
- and call your callback
301
- └─────────────────────────────────────┘
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
+ └──────────────────────────────────────────────────┘
302
248
  ```
303
249
 
304
- **What it detects:**
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.
305
251
 
306
- | Operation | Without `changeTracking` | With `changeTracking` |
252
+ | Operation | Detected | How |
307
253
  |---|---|---|
308
- | INSERT | ✅ (MAX(id) increases) | |
309
- | DELETE | ✅ (COUNT changes) | |
310
- | UPDATE | (fingerprint unchanged) | (change sequence bumps) |
311
-
312
- > **Tip:** Enable `changeTracking: true` if you need `.subscribe()` to react to UPDATEs.
313
- > The overhead is minimal — one trigger per table that appends to a `_changes` log.
254
+ | INSERT | ✅ | MAX(id) increases + revision bumps |
255
+ | DELETE | ✅ | COUNT changes + revision bumps |
256
+ | UPDATE | | revision bumps |
314
257
 
315
258
  **Use cases:**
316
259
  - Live dashboards (poll every 1-5s)
317
- - Real-time chat message lists
260
+ - Real-time chat / message lists
318
261
  - Auto-refreshing data tables
319
262
  - Watching filtered subsets of data
320
263
 
321
264
  ---
322
265
 
323
- ### 3. Change Tracking — `changeTracking: true`
324
-
325
- A trigger-based WAL (write-ahead log) that records every INSERT, UPDATE, and DELETE to a `_changes` table. This is the foundation for cross-process sync and audit trails.
326
-
327
- ```typescript
328
- const db = new Database(':memory:', schemas, {
329
- changeTracking: true,
330
- });
331
- ```
332
-
333
- When enabled, the ORM creates:
334
- - A `_changes` table: `(id, table_name, row_id, action, changed_at)`
335
- - An index on `(table_name, id)` for fast lookups
336
- - Triggers on each table for INSERT, UPDATE, and DELETE
337
-
338
- **Reading changes:**
339
-
340
- ```typescript
341
- // Get the current sequence number (latest change ID)
342
- const seq = db.getChangeSeq(); // global
343
- const seq = db.getChangeSeq('users'); // per table
344
-
345
- // Get all changes since a sequence number
346
- const changes = db.getChangesSince(0); // all changes ever
347
- const changes = db.getChangesSince(seq); // new changes since seq
348
-
349
- // Each change looks like:
350
- // { id: 42, table_name: 'users', row_id: 7, action: 'UPDATE', changed_at: '2024-...' }
351
- ```
352
-
353
- **Polling for changes (external sync pattern):**
354
-
355
- ```typescript
356
- let lastSeq = 0;
357
-
358
- setInterval(() => {
359
- const changes = db.getChangesSince(lastSeq);
360
- if (changes.length > 0) {
361
- lastSeq = changes[changes.length - 1].id;
362
- for (const change of changes) {
363
- console.log(`${change.action} on ${change.table_name} row ${change.row_id}`);
364
- }
365
- }
366
- }, 1000);
367
- ```
368
-
369
- **Use cases:**
370
- - Syncing between processes (e.g., worker → main thread)
371
- - Building an event-sourced system
372
- - Replication to another database
373
- - Audit logging with timestamps
374
- - Powering smart polling UPDATE detection
375
-
376
- ---
377
-
378
- ### Choosing the Right System
379
-
380
- ```
381
- Do you need to react to your own writes?
382
- → CRUD Events (db.table.subscribe)
383
-
384
- Do you need to watch a query result set?
385
- → Smart Polling (select().subscribe)
386
- → Enable changeTracking if you need UPDATE detection
387
-
388
- Do you need cross-process sync or audit?
389
- → Change Tracking (changeTracking: true + getChangesSince)
390
- ```
391
-
392
- All three systems can be used together. `changeTracking` enhances smart polling automatically — no code changes needed.
393
-
394
- ---
395
-
396
266
  ## Examples & Tests
397
267
 
398
268
  ```bash
@@ -424,12 +294,7 @@ bun test # 91 tests
424
294
  | `entity.update(data)` | Update entity in-place |
425
295
  | `entity.delete()` | Delete entity |
426
296
  | **Reactivity** | |
427
- | `db.table.subscribe(event, cb)` | CRUD events: `'insert'`, `'update'`, `'delete'` |
428
- | `db.table.unsubscribe(event, cb)` | Remove CRUD event listener |
429
- | `db.on(event, cb)` | EventEmitter: listen across all tables |
430
- | `select().subscribe(cb, opts?)` | Smart polling (fingerprint-based) |
431
- | `db.getChangeSeq(table?)` | Current change sequence number |
432
- | `db.getChangesSince(seq, table?)` | Changes since sequence (change tracking) |
297
+ | `select().subscribe(cb, opts?)` | Watch any query for changes (all mutations) |
433
298
 
434
299
  ## License
435
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,14 +173,14 @@ class QueryBuilder {
174
173
  singleExecutor;
175
174
  joinResolver;
176
175
  conditionResolver;
177
- changeSeqGetter;
178
- constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, changeSeqGetter) {
176
+ revisionGetter;
177
+ constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter) {
179
178
  this.tableName = tableName;
180
179
  this.executor = executor;
181
180
  this.singleExecutor = singleExecutor;
182
181
  this.joinResolver = joinResolver ?? null;
183
182
  this.conditionResolver = conditionResolver ?? null;
184
- this.changeSeqGetter = changeSeqGetter ?? null;
183
+ this.revisionGetter = revisionGetter ?? null;
185
184
  this.iqo = {
186
185
  selects: [],
187
186
  wheres: [],
@@ -335,8 +334,8 @@ class QueryBuilder {
335
334
  try {
336
335
  const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
337
336
  const fpRow = fpRows[0];
338
- const changeSeq = this.changeSeqGetter?.() ?? 0;
339
- const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${changeSeq}`;
337
+ const rev = this.revisionGetter?.() ?? 0;
338
+ const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
340
339
  if (currentFingerprint !== lastFingerprint) {
341
340
  lastFingerprint = currentFingerprint;
342
341
  const rows = this.all();
@@ -4652,26 +4651,22 @@ function transformFromStorage(row, schema) {
4652
4651
  }
4653
4652
 
4654
4653
  // src/database.ts
4655
- class _Database extends EventEmitter {
4654
+ class _Database {
4656
4655
  db;
4657
4656
  schemas;
4658
4657
  relationships;
4659
- subscriptions;
4660
4658
  options;
4659
+ _revisions = {};
4661
4660
  constructor(dbFile, schemas, options = {}) {
4662
- super();
4663
4661
  this.db = new SqliteDatabase(dbFile);
4664
4662
  this.db.run("PRAGMA foreign_keys = ON");
4665
4663
  this.schemas = schemas;
4666
4664
  this.options = options;
4667
- this.subscriptions = { insert: {}, update: {}, delete: {} };
4668
4665
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
4669
4666
  this.initializeTables();
4670
4667
  this.runMigrations();
4671
4668
  if (options.indexes)
4672
4669
  this.createIndexes(options.indexes);
4673
- if (options.changeTracking)
4674
- this.setupChangeTracking();
4675
4670
  for (const entityName of Object.keys(schemas)) {
4676
4671
  const key = entityName;
4677
4672
  const accessor = {
@@ -4683,8 +4678,6 @@ class _Database extends EventEmitter {
4683
4678
  },
4684
4679
  upsert: (conditions, data) => this.upsert(entityName, data, conditions),
4685
4680
  delete: (id) => this.delete(entityName, id),
4686
- subscribe: (event, callback) => this.subscribe(event, entityName, callback),
4687
- unsubscribe: (event, callback) => this.unsubscribe(event, entityName, callback),
4688
4681
  select: (...cols) => this._createQueryBuilder(entityName, cols),
4689
4682
  _tableName: entityName
4690
4683
  };
@@ -4706,66 +4699,32 @@ class _Database extends EventEmitter {
4706
4699
  }
4707
4700
  }
4708
4701
  runMigrations() {
4709
- this.db.run(`CREATE TABLE IF NOT EXISTS _schema_meta (
4710
- id INTEGER PRIMARY KEY AUTOINCREMENT,
4711
- table_name TEXT NOT NULL,
4712
- column_name TEXT NOT NULL,
4713
- added_at TEXT DEFAULT (datetime('now')),
4714
- UNIQUE(table_name, column_name)
4715
- )`);
4716
4702
  for (const [entityName, schema] of Object.entries(this.schemas)) {
4717
- 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));
4718
4705
  const storableFields = getStorableFields(schema);
4719
4706
  for (const field of storableFields) {
4720
- if (!existingCols.has(field.name)) {
4721
- this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${zodTypeToSqlType(field.type)}`);
4722
- 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}`);
4723
4710
  }
4724
4711
  }
4725
4712
  }
4726
4713
  }
4727
- createIndexes(indexDefs) {
4728
- for (const [tableName, indexes] of Object.entries(indexDefs)) {
4729
- if (!this.schemas[tableName])
4730
- throw new Error(`Cannot create index on unknown table '${tableName}'`);
4731
- const indexList = Array.isArray(indexes) ? indexes : [indexes];
4732
- for (const indexDef of indexList) {
4733
- const columns = Array.isArray(indexDef) ? indexDef : [indexDef];
4734
- const indexName = `idx_${tableName}_${columns.join("_")}`;
4735
- 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(", ")})`);
4736
4720
  }
4737
4721
  }
4738
4722
  }
4739
- setupChangeTracking() {
4740
- this.db.run(`CREATE TABLE IF NOT EXISTS _changes (
4741
- id INTEGER PRIMARY KEY AUTOINCREMENT,
4742
- table_name TEXT NOT NULL,
4743
- row_id INTEGER NOT NULL,
4744
- action TEXT NOT NULL CHECK(action IN ('INSERT', 'UPDATE', 'DELETE')),
4745
- changed_at TEXT DEFAULT (datetime('now'))
4746
- )`);
4747
- this.db.run(`CREATE INDEX IF NOT EXISTS idx_changes_table ON _changes (table_name, id)`);
4748
- for (const entityName of Object.keys(this.schemas)) {
4749
- for (const action of ["insert", "update", "delete"]) {
4750
- const ref = action === "delete" ? "OLD" : "NEW";
4751
- this.db.run(`CREATE TRIGGER IF NOT EXISTS _trg_${entityName}_${action}
4752
- AFTER ${action.toUpperCase()} ON ${entityName}
4753
- BEGIN
4754
- INSERT INTO _changes (table_name, row_id, action) VALUES ('${entityName}', ${ref}.id, '${action.toUpperCase()}');
4755
- END`);
4756
- }
4757
- }
4723
+ _bumpRevision(entityName) {
4724
+ this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
4758
4725
  }
4759
- getChangeSeq(tableName) {
4760
- if (!this.options.changeTracking)
4761
- return -1;
4762
- const sql = tableName ? `SELECT MAX(id) as seq FROM _changes WHERE table_name = ?` : `SELECT MAX(id) as seq FROM _changes`;
4763
- const row = this.db.query(sql).get(...tableName ? [tableName] : []);
4764
- return row?.seq ?? 0;
4765
- }
4766
- getChangesSince(sinceSeq, tableName) {
4767
- const sql = tableName ? `SELECT * FROM _changes WHERE id > ? AND table_name = ? ORDER BY id ASC` : `SELECT * FROM _changes WHERE id > ? ORDER BY id ASC`;
4768
- return this.db.query(sql).all(...tableName ? [sinceSeq, tableName] : [sinceSeq]);
4726
+ _getRevision(entityName) {
4727
+ return this._revisions[entityName] ?? 0;
4769
4728
  }
4770
4729
  insert(entityName, data) {
4771
4730
  const schema = this.schemas[entityName];
@@ -4777,8 +4736,7 @@ class _Database extends EventEmitter {
4777
4736
  const newEntity = this._getById(entityName, result.lastInsertRowid);
4778
4737
  if (!newEntity)
4779
4738
  throw new Error("Failed to retrieve entity after insertion");
4780
- this.emit("insert", entityName, newEntity);
4781
- this.subscriptions.insert[entityName]?.forEach((cb) => cb(newEntity));
4739
+ this._bumpRevision(entityName);
4782
4740
  return newEntity;
4783
4741
  }
4784
4742
  _getById(entityName, id) {
@@ -4807,11 +4765,8 @@ class _Database extends EventEmitter {
4807
4765
  return this._getById(entityName, id);
4808
4766
  const setClause = Object.keys(transformed).map((key) => `${key} = ?`).join(", ");
4809
4767
  this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
4768
+ this._bumpRevision(entityName);
4810
4769
  const updatedEntity = this._getById(entityName, id);
4811
- if (updatedEntity) {
4812
- this.emit("update", entityName, updatedEntity);
4813
- this.subscriptions.update[entityName]?.forEach((cb) => cb(updatedEntity));
4814
- }
4815
4770
  return updatedEntity;
4816
4771
  }
4817
4772
  _updateWhere(entityName, data, conditions) {
@@ -4827,12 +4782,8 @@ class _Database extends EventEmitter {
4827
4782
  const setClause = setCols.map((key) => `${key} = ?`).join(", ");
4828
4783
  const result = this.db.query(`UPDATE ${entityName} SET ${setClause} ${clause}`).run(...setCols.map((key) => transformed[key]), ...whereValues);
4829
4784
  const affected = result.changes ?? 0;
4830
- if (affected > 0 && (this.subscriptions.update[entityName]?.length || this.options.changeTracking)) {
4831
- for (const entity of this._findMany(entityName, conditions)) {
4832
- this.emit("update", entityName, entity);
4833
- this.subscriptions.update[entityName]?.forEach((cb) => cb(entity));
4834
- }
4835
- }
4785
+ if (affected > 0)
4786
+ this._bumpRevision(entityName);
4836
4787
  return affected;
4837
4788
  }
4838
4789
  _createUpdateBuilder(entityName, data) {
@@ -4862,8 +4813,7 @@ class _Database extends EventEmitter {
4862
4813
  const entity = this._getById(entityName, id);
4863
4814
  if (entity) {
4864
4815
  this.db.query(`DELETE FROM ${entityName} WHERE id = ?`).run(id);
4865
- this.emit("delete", entityName, entity);
4866
- this.subscriptions.delete[entityName]?.forEach((cb) => cb(entity));
4816
+ this._bumpRevision(entityName);
4867
4817
  }
4868
4818
  }
4869
4819
  _attachMethods(entityName, entity) {
@@ -4960,15 +4910,6 @@ class _Database extends EventEmitter {
4960
4910
  throw new Error(`Transaction failed: ${error.message}`);
4961
4911
  }
4962
4912
  }
4963
- subscribe(event, entityName, callback) {
4964
- this.subscriptions[event][entityName] = this.subscriptions[event][entityName] || [];
4965
- this.subscriptions[event][entityName].push(callback);
4966
- }
4967
- unsubscribe(event, entityName, callback) {
4968
- if (this.subscriptions[event][entityName]) {
4969
- this.subscriptions[event][entityName] = this.subscriptions[event][entityName].filter((cb) => cb !== callback);
4970
- }
4971
- }
4972
4913
  _createQueryBuilder(entityName, initialCols) {
4973
4914
  const schema = this.schemas[entityName];
4974
4915
  const executor = (sql, params, raw) => {
@@ -4990,8 +4931,8 @@ class _Database extends EventEmitter {
4990
4931
  return { fk: "id", pk: reverse.foreignKey };
4991
4932
  return null;
4992
4933
  };
4993
- const changeSeqGetter = this.options.changeTracking ? () => this.getChangeSeq(entityName) : null;
4994
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, changeSeqGetter);
4934
+ const revisionGetter = () => this._getRevision(entityName);
4935
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
4995
4936
  if (initialCols.length > 0)
4996
4937
  builder.select(...initialCols);
4997
4938
  return builder;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.2.1",
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
  // ===========================================================================
@@ -459,13 +388,11 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
459
388
  if (reverse) return { fk: 'id', pk: reverse.foreignKey };
460
389
  return null;
461
390
  };
462
- // Provide change sequence getter when change tracking is enabled
463
- // This allows .subscribe() to detect row UPDATEs (not just inserts/deletes)
464
- const changeSeqGetter = this.options.changeTracking
465
- ? () => this.getChangeSeq(entityName)
466
- : null;
467
391
 
468
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, changeSeqGetter);
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);
469
396
  if (initialCols.length > 0) builder.select(...initialCols);
470
397
  return builder;
471
398
  }
@@ -167,7 +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 changeSeqGetter: (() => number) | null;
170
+ private revisionGetter: (() => number) | null;
171
171
 
172
172
  constructor(
173
173
  tableName: string,
@@ -175,14 +175,14 @@ export class QueryBuilder<T extends Record<string, any>> {
175
175
  singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
176
176
  joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
177
177
  conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
178
- changeSeqGetter?: (() => number) | null,
178
+ revisionGetter?: (() => number) | null,
179
179
  ) {
180
180
  this.tableName = tableName;
181
181
  this.executor = executor;
182
182
  this.singleExecutor = singleExecutor;
183
183
  this.joinResolver = joinResolver ?? null;
184
184
  this.conditionResolver = conditionResolver ?? null;
185
- this.changeSeqGetter = changeSeqGetter ?? null;
185
+ this.revisionGetter = revisionGetter ?? null;
186
186
  this.iqo = {
187
187
  selects: [],
188
188
  wheres: [],
@@ -423,9 +423,9 @@ export class QueryBuilder<T extends Record<string, any>> {
423
423
  /**
424
424
  * Subscribe to query result changes using smart interval-based polling.
425
425
  *
426
- * Instead of re-fetching all rows every tick, it runs a lightweight
427
- * fingerprint query (`SELECT COUNT(*), MAX(id)`) with the same WHERE clause.
428
- * 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.
429
429
  *
430
430
  * ```ts
431
431
  * const unsub = db.messages.select()
@@ -459,10 +459,10 @@ export class QueryBuilder<T extends Record<string, any>> {
459
459
  // Run lightweight fingerprint check
460
460
  const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
461
461
  const fpRow = fpRows[0] as any;
462
- // Include change sequence in fingerprint when change tracking is enabled.
463
- // This ensures UPDATEs are detected (COUNT + MAX alone don't change on UPDATE).
464
- const changeSeq = this.changeSeqGetter?.() ?? 0;
465
- const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${changeSeq}`;
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}`;
466
466
 
467
467
  if (currentFingerprint !== lastFingerprint) {
468
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;