sqlite-zod-orm 3.2.1 → 3.3.1

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,113 +228,63 @@ 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 revision (in-memory + data_version)
240
+ 2. Run: SELECT COUNT(*), MAX(id)
241
+ FROM users WHERE role = 'admin' │
242
+
243
+ 3. Combine into fingerprint: "count:max:rev:dv"
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
+ Two signals combine to detect **all** changes from **any** source:
305
251
 
306
- | Operation | Without `changeTracking` | With `changeTracking` |
252
+ | Signal | Catches | 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.
314
-
315
- **Use cases:**
316
- - Live dashboards (poll every 1-5s)
317
- - Real-time chat message lists
318
- - Auto-refreshing data tables
319
- - Watching filtered subsets of data
320
-
321
- ---
322
-
323
- ### 3. Change Tracking — `changeTracking: true`
254
+ | **In-memory revision** | Same-process writes | Bumped by CRUD methods |
255
+ | **PRAGMA data_version** | Cross-process writes | SQLite bumps it on external commits |
324
256
 
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
- ```
257
+ | Operation | Detected | Source |
258
+ |---|---|---|
259
+ | INSERT | ✅ | Same or other process |
260
+ | DELETE | | Same or other process |
261
+ | UPDATE | ✅ | Same or other process |
332
262
 
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
263
+ No triggers. No `_changes` table. Zero disk overhead. WAL mode is enabled by default for concurrent read/write.
337
264
 
338
- **Reading changes:**
265
+ ### Multi-process example
339
266
 
340
267
  ```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-...' }
268
+ // Process A watches for new/edited messages
269
+ const unsub = db.messages.select()
270
+ .orderBy('id', 'asc')
271
+ .subscribe((messages) => {
272
+ console.log('Messages:', messages);
273
+ }, { interval: 200 });
274
+
275
+ // Process B — writes to the same DB file (different process)
276
+ // sqlite3 chat.db "INSERT INTO messages (text, author) VALUES ('hello', 'Bob')"
277
+ // Process A's callback fires with updated message list!
351
278
  ```
352
279
 
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
- ```
280
+ Run `bun examples/messages-demo.ts` for a full working demo.
368
281
 
369
282
  **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.
283
+ - Live dashboards (poll every 1-5s)
284
+ - Real-time chat / message lists
285
+ - Auto-refreshing data tables
286
+ - Watching filtered subsets of data
287
+ - Cross-process data synchronization
393
288
 
394
289
  ---
395
290
 
@@ -424,12 +319,7 @@ bun test # 91 tests
424
319
  | `entity.update(data)` | Update entity in-place |
425
320
  | `entity.delete()` | Delete entity |
426
321
  | **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) |
322
+ | `select().subscribe(cb, opts?)` | Watch any query for changes (all mutations) |
433
323
 
434
324
  ## License
435
325
 
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,23 @@ 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);
4662
+ this.db.run("PRAGMA journal_mode = WAL");
4664
4663
  this.db.run("PRAGMA foreign_keys = ON");
4665
4664
  this.schemas = schemas;
4666
4665
  this.options = options;
4667
- this.subscriptions = { insert: {}, update: {}, delete: {} };
4668
4666
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
4669
4667
  this.initializeTables();
4670
4668
  this.runMigrations();
4671
4669
  if (options.indexes)
4672
4670
  this.createIndexes(options.indexes);
4673
- if (options.changeTracking)
4674
- this.setupChangeTracking();
4675
4671
  for (const entityName of Object.keys(schemas)) {
4676
4672
  const key = entityName;
4677
4673
  const accessor = {
@@ -4683,8 +4679,6 @@ class _Database extends EventEmitter {
4683
4679
  },
4684
4680
  upsert: (conditions, data) => this.upsert(entityName, data, conditions),
4685
4681
  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
4682
  select: (...cols) => this._createQueryBuilder(entityName, cols),
4689
4683
  _tableName: entityName
4690
4684
  };
@@ -4706,66 +4700,34 @@ class _Database extends EventEmitter {
4706
4700
  }
4707
4701
  }
4708
4702
  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
4703
  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));
4704
+ const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all();
4705
+ const existingNames = new Set(existingColumns.map((c) => c.name));
4718
4706
  const storableFields = getStorableFields(schema);
4719
4707
  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);
4708
+ if (!existingNames.has(field.name)) {
4709
+ const sqlType = zodTypeToSqlType(field.type);
4710
+ this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${sqlType}`);
4723
4711
  }
4724
4712
  }
4725
4713
  }
4726
4714
  }
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(", ")})`);
4715
+ createIndexes(indexes) {
4716
+ for (const [tableName, indexDefs] of Object.entries(indexes)) {
4717
+ for (const def of indexDefs) {
4718
+ const cols = Array.isArray(def) ? def : [def];
4719
+ const idxName = `idx_${tableName}_${cols.join("_")}`;
4720
+ this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${tableName} (${cols.join(", ")})`);
4736
4721
  }
4737
4722
  }
4738
4723
  }
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
- }
4724
+ _bumpRevision(entityName) {
4725
+ this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
4758
4726
  }
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]);
4727
+ _getRevision(entityName) {
4728
+ const rev = this._revisions[entityName] ?? 0;
4729
+ const dataVersion = this.db.query("PRAGMA data_version").get()?.data_version ?? 0;
4730
+ return `${rev}:${dataVersion}`;
4769
4731
  }
4770
4732
  insert(entityName, data) {
4771
4733
  const schema = this.schemas[entityName];
@@ -4777,8 +4739,7 @@ class _Database extends EventEmitter {
4777
4739
  const newEntity = this._getById(entityName, result.lastInsertRowid);
4778
4740
  if (!newEntity)
4779
4741
  throw new Error("Failed to retrieve entity after insertion");
4780
- this.emit("insert", entityName, newEntity);
4781
- this.subscriptions.insert[entityName]?.forEach((cb) => cb(newEntity));
4742
+ this._bumpRevision(entityName);
4782
4743
  return newEntity;
4783
4744
  }
4784
4745
  _getById(entityName, id) {
@@ -4807,11 +4768,8 @@ class _Database extends EventEmitter {
4807
4768
  return this._getById(entityName, id);
4808
4769
  const setClause = Object.keys(transformed).map((key) => `${key} = ?`).join(", ");
4809
4770
  this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
4771
+ this._bumpRevision(entityName);
4810
4772
  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
4773
  return updatedEntity;
4816
4774
  }
4817
4775
  _updateWhere(entityName, data, conditions) {
@@ -4827,12 +4785,8 @@ class _Database extends EventEmitter {
4827
4785
  const setClause = setCols.map((key) => `${key} = ?`).join(", ");
4828
4786
  const result = this.db.query(`UPDATE ${entityName} SET ${setClause} ${clause}`).run(...setCols.map((key) => transformed[key]), ...whereValues);
4829
4787
  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
- }
4788
+ if (affected > 0)
4789
+ this._bumpRevision(entityName);
4836
4790
  return affected;
4837
4791
  }
4838
4792
  _createUpdateBuilder(entityName, data) {
@@ -4862,8 +4816,7 @@ class _Database extends EventEmitter {
4862
4816
  const entity = this._getById(entityName, id);
4863
4817
  if (entity) {
4864
4818
  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));
4819
+ this._bumpRevision(entityName);
4867
4820
  }
4868
4821
  }
4869
4822
  _attachMethods(entityName, entity) {
@@ -4960,15 +4913,6 @@ class _Database extends EventEmitter {
4960
4913
  throw new Error(`Transaction failed: ${error.message}`);
4961
4914
  }
4962
4915
  }
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
4916
  _createQueryBuilder(entityName, initialCols) {
4973
4917
  const schema = this.schemas[entityName];
4974
4918
  const executor = (sql, params, raw) => {
@@ -4990,8 +4934,8 @@ class _Database extends EventEmitter {
4990
4934
  return { fk: "id", pk: reverse.foreignKey };
4991
4935
  return null;
4992
4936
  };
4993
- const changeSeqGetter = this.options.changeTracking ? () => this.getChangeSeq(entityName) : null;
4994
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, changeSeqGetter);
4937
+ const revisionGetter = () => this._getRevision(entityName);
4938
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
4995
4939
  if (initialCols.length > 0)
4996
4940
  builder.select(...initialCols);
4997
4941
  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.1",
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,26 @@ 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);
39
+ this.db.run('PRAGMA journal_mode = WAL'); // WAL enables concurrent read + write
38
40
  this.db.run('PRAGMA foreign_keys = ON');
39
41
  this.schemas = schemas;
40
42
  this.options = options;
41
- this.subscriptions = { insert: {}, update: {}, delete: {} };
42
43
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
43
44
  this.initializeTables();
44
45
  this.runMigrations();
45
46
  if (options.indexes) this.createIndexes(options.indexes);
46
- if (options.changeTracking) this.setupChangeTracking();
47
47
 
48
48
  // Create typed entity accessors (db.users, db.posts, etc.)
49
49
  for (const entityName of Object.keys(schemas)) {
@@ -56,8 +56,6 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
56
56
  },
57
57
  upsert: (conditions, data) => this.upsert(entityName, data, conditions),
58
58
  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
59
  select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
62
60
  _tableName: entityName,
63
61
  };
@@ -90,85 +88,53 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
90
88
  }
91
89
 
92
90
  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
91
  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);
92
+ const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all() as any[];
93
+ const existingNames = new Set(existingColumns.map(c => c.name));
106
94
 
95
+ const storableFields = getStorableFields(schema);
107
96
  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);
97
+ if (!existingNames.has(field.name)) {
98
+ const sqlType = zodTypeToSqlType(field.type);
99
+ this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${sqlType}`);
111
100
  }
112
101
  }
113
102
  }
114
103
  }
115
104
 
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(', ')})`);
105
+ private createIndexes(indexes: Record<string, (string | string[])[]>): void {
106
+ for (const [tableName, indexDefs] of Object.entries(indexes)) {
107
+ for (const def of indexDefs) {
108
+ const cols = Array.isArray(def) ? def : [def];
109
+ const idxName = `idx_${tableName}_${cols.join('_')}`;
110
+ this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${tableName} (${cols.join(', ')})`);
128
111
  }
129
112
  }
130
113
  }
131
114
 
132
115
  // ===========================================================================
133
- // Change Tracking
116
+ // Revision Tracking (in-memory + cross-process)
134
117
  // ===========================================================================
135
118
 
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;
119
+ /** Bump the revision counter for a table. Called on every write. */
120
+ private _bumpRevision(entityName: string): void {
121
+ this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
165
122
  }
166
123
 
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[];
124
+ /**
125
+ * Get a composite revision string for a table.
126
+ *
127
+ * Combines two signals:
128
+ * - In-memory counter: catches writes from THIS process (our CRUD methods bump it)
129
+ * - PRAGMA data_version: catches writes from OTHER processes (SQLite bumps it
130
+ * whenever another connection commits, but NOT for the current connection)
131
+ *
132
+ * Together they detect ALL changes regardless of source, with zero disk overhead.
133
+ */
134
+ public _getRevision(entityName: string): string {
135
+ const rev = this._revisions[entityName] ?? 0;
136
+ const dataVersion = (this.db.query('PRAGMA data_version').get() as any)?.data_version ?? 0;
137
+ return `${rev}:${dataVersion}`;
172
138
  }
173
139
 
174
140
  // ===========================================================================
@@ -189,8 +155,7 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
189
155
  const newEntity = this._getById(entityName, result.lastInsertRowid as number);
190
156
  if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
191
157
 
192
- this.emit('insert', entityName, newEntity);
193
- this.subscriptions.insert[entityName]?.forEach(cb => cb(newEntity));
158
+ this._bumpRevision(entityName);
194
159
  return newEntity;
195
160
  }
196
161
 
@@ -227,11 +192,8 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
227
192
  const setClause = Object.keys(transformed).map(key => `${key} = ?`).join(', ');
228
193
  this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
229
194
 
195
+ this._bumpRevision(entityName);
230
196
  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
197
  return updatedEntity;
236
198
  }
237
199
 
@@ -252,12 +214,7 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
252
214
  );
253
215
 
254
216
  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
- }
217
+ if (affected > 0) this._bumpRevision(entityName);
261
218
  return affected;
262
219
  }
263
220
 
@@ -292,8 +249,7 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
292
249
  const entity = this._getById(entityName, id);
293
250
  if (entity) {
294
251
  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));
252
+ this._bumpRevision(entityName);
297
253
  }
298
254
  }
299
255
 
@@ -415,21 +371,6 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
415
371
  }
416
372
  }
417
373
 
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
374
  // ===========================================================================
434
375
  // Query Builders
435
376
  // ===========================================================================
@@ -459,13 +400,11 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
459
400
  if (reverse) return { fk: 'id', pk: reverse.foreignKey };
460
401
  return null;
461
402
  };
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
403
 
468
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, changeSeqGetter);
404
+ // Pass revision getter — allows .subscribe() to detect ALL changes
405
+ const revisionGetter = () => this._getRevision(entityName);
406
+
407
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
469
408
  if (initialCols.length > 0) builder.select(...initialCols);
470
409
  return builder;
471
410
  }
@@ -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: (() => string) | 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?: (() => string) | 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 revision in fingerprint (combines in-memory counter + PRAGMA data_version).
463
+ // This detects ALL changes: same-process and cross-process.
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;