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 +174 -10
- package/dist/index.js +7 -3
- package/package.json +1 -1
- package/src/database.ts +6 -1
- package/src/query-builder.ts +7 -1
package/README.md
CHANGED
|
@@ -207,29 +207,190 @@ const db = new Database(':memory:', schemas, {
|
|
|
207
207
|
|
|
208
208
|
---
|
|
209
209
|
|
|
210
|
-
##
|
|
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
|
-
|
|
214
|
-
db.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
| **
|
|
266
|
-
| `db.table.subscribe(event,
|
|
267
|
-
| `db.table.
|
|
268
|
-
| `db.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
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
|
}
|
package/src/query-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|