sqlite-zod-orm 3.2.0 → 3.2.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,29 +207,190 @@ const db = new Database(':memory:', schemas, {
207
207
 
208
208
  ---
209
209
 
210
- ## Change Tracking & Events
210
+ ## Reactivity Three Ways to React to Changes
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.
211
227
 
212
228
  ```typescript
213
- const db = new Database(':memory:', schemas, { changeTracking: true });
214
- db.getChangesSince(0);
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)
215
253
 
216
- db.users.subscribe('insert', (user) => console.log('New:', user.name));
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
+ });
217
260
  ```
218
261
 
219
262
  ---
220
263
 
221
- ## Smart Polling
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.
222
267
 
223
268
  ```typescript
269
+ // Watch for admin list changes, poll every second
224
270
  const unsub = db.users.select()
225
271
  .where({ role: 'admin' })
272
+ .orderBy('name', 'asc')
226
273
  .subscribe((admins) => {
227
- console.log('Admin list changed:', admins);
274
+ console.log('Admin list:', admins.map(a => a.name));
228
275
  }, { interval: 1000 });
229
276
 
277
+ // Stop watching
230
278
  unsub();
231
279
  ```
232
280
 
281
+ **Options:**
282
+
283
+ | Option | Default | Description |
284
+ |---|---|---|
285
+ | `interval` | `500` | Polling interval in milliseconds |
286
+ | `immediate` | `true` | Whether to fire the callback immediately with the current result |
287
+
288
+ **How the fingerprint works:**
289
+
290
+ ```
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
+ └─────────────────────────────────────┘
302
+ ```
303
+
304
+ **What it detects:**
305
+
306
+ | Operation | Without `changeTracking` | With `changeTracking` |
307
+ |---|---|---|
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`
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
+
233
394
  ---
234
395
 
235
396
  ## Examples & Tests
@@ -262,10 +423,13 @@ bun test # 91 tests
262
423
  | `entity.navMethod()` | Lazy navigation (FK name minus `_id`) |
263
424
  | `entity.update(data)` | Update entity in-place |
264
425
  | `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 |
426
+ | **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) |
269
433
 
270
434
  ## License
271
435
 
package/dist/index.js CHANGED
@@ -174,12 +174,14 @@ class QueryBuilder {
174
174
  singleExecutor;
175
175
  joinResolver;
176
176
  conditionResolver;
177
- constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver) {
177
+ changeSeqGetter;
178
+ constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, changeSeqGetter) {
178
179
  this.tableName = tableName;
179
180
  this.executor = executor;
180
181
  this.singleExecutor = singleExecutor;
181
182
  this.joinResolver = joinResolver ?? null;
182
183
  this.conditionResolver = conditionResolver ?? null;
184
+ this.changeSeqGetter = changeSeqGetter ?? null;
183
185
  this.iqo = {
184
186
  selects: [],
185
187
  wheres: [],
@@ -333,7 +335,8 @@ class QueryBuilder {
333
335
  try {
334
336
  const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
335
337
  const fpRow = fpRows[0];
336
- const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}`;
338
+ const changeSeq = this.changeSeqGetter?.() ?? 0;
339
+ const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${changeSeq}`;
337
340
  if (currentFingerprint !== lastFingerprint) {
338
341
  lastFingerprint = currentFingerprint;
339
342
  const rows = this.all();
@@ -4987,7 +4990,8 @@ class _Database extends EventEmitter {
4987
4990
  return { fk: "id", pk: reverse.foreignKey };
4988
4991
  return null;
4989
4992
  };
4990
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver);
4993
+ const changeSeqGetter = this.options.changeTracking ? () => this.getChangeSeq(entityName) : null;
4994
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, changeSeqGetter);
4991
4995
  if (initialCols.length > 0)
4992
4996
  builder.select(...initialCols);
4993
4997
  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.2.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
@@ -459,8 +459,13 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
459
459
  if (reverse) return { fk: 'id', pk: reverse.foreignKey };
460
460
  return null;
461
461
  };
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;
462
467
 
463
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver);
468
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, changeSeqGetter);
464
469
  if (initialCols.length > 0) builder.select(...initialCols);
465
470
  return builder;
466
471
  }
@@ -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 changeSeqGetter: (() => 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
+ changeSeqGetter?: (() => 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.changeSeqGetter = changeSeqGetter ?? null;
183
186
  this.iqo = {
184
187
  selects: [],
185
188
  wheres: [],
@@ -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 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}`;
460
466
 
461
467
  if (currentFingerprint !== lastFingerprint) {
462
468
  lastFingerprint = currentFingerprint;