sqlite-zod-orm 3.8.0 → 3.10.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
@@ -41,7 +41,6 @@ const db = new Database(':memory:', {
41
41
  relations: {
42
42
  books: { author_id: 'authors' },
43
43
  },
44
- pollInterval: 300, // global default for .on() and .subscribe() (default: 500ms)
45
44
  });
46
45
  ```
47
46
 
@@ -185,6 +184,60 @@ alice.score = 200; // → UPDATE users SET score = 200 WHERE id = 1
185
184
 
186
185
  ---
187
186
 
187
+ ## Change Listeners — `db.table.on()`
188
+
189
+ Register listeners for insert, update, and delete events. Uses SQLite triggers + a single global poller — no per-listener overhead.
190
+
191
+ ```typescript
192
+ // Listen for new users
193
+ const unsub = db.users.on('insert', (user) => {
194
+ console.log('New user:', user.name, user.email);
195
+ });
196
+
197
+ // Listen for updates
198
+ db.users.on('update', (user) => {
199
+ console.log('Updated:', user.name);
200
+ });
201
+
202
+ // Listen for deletes (row is gone, only id available)
203
+ db.users.on('delete', ({ id }) => {
204
+ console.log('Deleted user id:', id);
205
+ });
206
+
207
+ // Stop listening
208
+ unsub();
209
+ ```
210
+
211
+ ### How it works
212
+
213
+ ```
214
+ ┌──────────────────────────────────────────────────┐
215
+ │ SQLite triggers log every mutation: │
216
+ │ │
217
+ │ INSERT → _changes (tbl, op='insert', row_id) │
218
+ │ UPDATE → _changes (tbl, op='update', row_id) │
219
+ │ DELETE → _changes (tbl, op='delete', row_id) │
220
+ │ │
221
+ │ Single global poller (default 100ms): │
222
+ │ 1. SELECT * FROM _changes WHERE id > @watermark │
223
+ │ 2. Re-fetch affected rows │
224
+ │ 3. Dispatch to registered on() listeners │
225
+ │ 4. Advance watermark, clean up consumed entries │
226
+ └──────────────────────────────────────────────────┘
227
+ ```
228
+
229
+ | Feature | Detail |
230
+ |---|---|
231
+ | **Granularity** | Row-level (knows exactly which row changed) |
232
+ | **Operations** | INSERT, UPDATE, DELETE — all detected |
233
+ | **Cross-process** | ✅ Triggers fire regardless of which connection writes |
234
+ | **Overhead** | Single poller for all listeners, no per-listener timers |
235
+ | **Cleanup** | Consumed changes auto-deleted after dispatch |
236
+
237
+ Run `bun examples/messages-demo.ts` for a full working demo.
238
+
239
+ ---
240
+
188
241
  ## Schema Validation
189
242
 
190
243
  Zod validates every insert and update at runtime:
@@ -195,6 +248,35 @@ db.users.insert({ name: '', email: 'bad', age: -1 }); // throws ZodError
195
248
 
196
249
  ---
197
250
 
251
+ ## Automatic Migrations
252
+
253
+ When you add new fields to your Zod schema, the ORM automatically adds the corresponding columns to the SQLite table on startup. No migration files, no manual ALTER TABLE statements.
254
+
255
+ ```typescript
256
+ // v1: initial schema
257
+ const UserSchema = z.object({
258
+ name: z.string(),
259
+ email: z.string(),
260
+ });
261
+
262
+ // v2: added a new field — just update the Zod schema
263
+ const UserSchema = z.object({
264
+ name: z.string(),
265
+ email: z.string(),
266
+ bio: z.string().default(''), // ← new column added automatically
267
+ score: z.number().default(0), // ← new column added automatically
268
+ });
269
+ ```
270
+
271
+ **How it works:**
272
+ 1. On startup, the ORM reads `PRAGMA table_info(...)` to get existing columns
273
+ 2. Compares them against the Zod schema fields
274
+ 3. Any missing columns are added via `ALTER TABLE ... ADD COLUMN`
275
+
276
+ This handles the common case of additive schema evolution. For destructive changes (renaming or dropping columns), use the SQLite CLI directly.
277
+
278
+ ---
279
+
198
280
  ## Indexes
199
281
 
200
282
  ```typescript
@@ -208,122 +290,86 @@ const db = new Database(':memory:', schemas, {
208
290
 
209
291
  ---
210
292
 
211
- ## Reactivity
293
+ ## Transactions
212
294
 
213
- Two complementary APIs for watching data changes:
214
-
215
- | API | Receives | Fires on | Use case |
216
- |---|---|---|---|
217
- | **`db.table.on(cb)`** | One row at a time, in order | New inserts only | Message streams, event queues |
218
- | **`select().subscribe(cb)`** | Full result snapshot | Any change (insert/update/delete) | Live dashboards, filtered views |
295
+ ```typescript
296
+ const result = db.transaction(() => {
297
+ const author = db.authors.insert({ name: 'New Author', country: 'US' });
298
+ const book = db.books.insert({ title: 'New Book', year: 2024, author_id: author.id });
299
+ return { author, book };
300
+ });
301
+ // Automatically rolls back on error
302
+ ```
219
303
 
220
304
  ---
221
305
 
222
- ### Row Stream — `db.table.on(callback)`
223
-
224
- Streams new rows one at a time, in insertion order. Only emits rows created **after** subscription starts.
225
-
226
- ```typescript
227
- const unsub = db.messages.on((msg) => {
228
- console.log(`${msg.author}: ${msg.text}`);
229
- }, { interval: 200 });
306
+ ## Examples & Tests
230
307
 
231
- // Later: stop listening
232
- unsub();
308
+ ```bash
309
+ bun examples/messages-demo.ts # on() change listener demo
310
+ bun examples/example.ts # comprehensive demo
311
+ bun test # 117 tests
233
312
  ```
234
313
 
235
- If 5 messages arrive between polls, the callback fires 5 times — once per row, in order. Uses a watermark (`id > lastSeen`) internally.
236
-
237
314
  ---
238
315
 
239
- ### Snapshot — `select().subscribe(callback)`
316
+ ## Benchmarks
240
317
 
241
- Returns the **full query result** whenever data changes. Detects all mutations (inserts, updates, deletes).
318
+ All benchmarks run on in-memory SQLite via Bun. Reproduce with:
242
319
 
243
- ```typescript
244
- const unsub = db.users.select()
245
- .where({ role: 'admin' })
246
- .orderBy('name', 'asc')
247
- .subscribe((admins) => {
248
- console.log('Admin list:', admins.map(a => a.name));
249
- }, { interval: 1000 });
250
-
251
- // Stop watching
252
- unsub();
320
+ ```bash
321
+ bun bench/triggers-vs-naive.ts # change detection strategies
322
+ bun bench/poll-strategy.ts # MAX(id) vs SELECT WHERE
323
+ bun bench/indexes.ts # index impact on queries
253
324
  ```
254
325
 
255
- **Options:**
326
+ ### Why triggers? — Change detection strategies
256
327
 
257
- | Option | Default | Description |
258
- |---|---|---|
259
- | `interval` | `500` | Polling interval in milliseconds |
260
- | `immediate` | `true` | Fire callback immediately with current data |
328
+ We compared three approaches for detecting data changes:
261
329
 
262
- ### How it works
330
+ | Strategy | Idle poll cost | Write overhead | Granularity |
331
+ |---|---|---|---|
332
+ | **Triggers + `_changes` table** (ours) | 147ns/poll | ~1µs/mutation | row + table + operation |
333
+ | `PRAGMA data_version` | 136ns/poll | zero | boolean only (any write?) |
334
+ | `COUNT(*) + MAX(id)` fingerprint | 138,665ns/poll | zero | count only, misses updates |
263
335
 
264
- ```
265
- ┌──────────────────────────────────────────────────┐
266
- │ Every {interval}ms: │
267
- │ │
268
- │ 1. Check revision (in-memory + data_version) │
269
- │ 2. Run: SELECT COUNT(*), MAX(id) │
270
- │ FROM users WHERE role = 'admin' │
271
- │ │
272
- │ 3. Combine into fingerprint: "count:max:rev:dv" │
273
- │ │
274
- │ 4. If fingerprint changed → re-run full query │
275
- │ and call your callback │
276
- └──────────────────────────────────────────────────┘
277
- ```
336
+ **Idle poll cost** (the common hot path — nothing changed) is near-identical for triggers and `data_version` (~150ns). The `COUNT+MAX` approach is **~1000x slower** because it must scan the table every poll.
278
337
 
279
- Two signals combine to detect **all** changes from **any** source:
338
+ **Write overhead** for triggers is ~1µs per mutation (one extra INSERT into `_changes`):
280
339
 
281
- | Signal | Catches | How |
282
- |---|---|---|
283
- | **In-memory revision** | Same-process writes | Bumped by CRUD methods |
284
- | **PRAGMA data_version** | Cross-process writes | SQLite bumps it on external commits |
340
+ | Operation | With triggers | Without | Overhead |
341
+ |---|---|---|---|
342
+ | INSERT (10K rows) | 2.4µs/row | 1.4µs/row | +1.0µs |
343
+ | UPDATE (10K rows) | 1.8µs/row | 0.9µs/row | +0.9µs |
285
344
 
286
- | Operation | Detected | Source |
287
- |---|---|---|
288
- | INSERT | ✅ | Same or other process |
289
- | DELETE | ✅ | Same or other process |
290
- | UPDATE | ✅ | Same or other process |
345
+ In exchange, triggers give you **row-level, operation-level, table-level** granularity — you know exactly which row changed, how, and in which table. `PRAGMA data_version` just tells you "something changed somewhere." For apps that don't need listeners, `{ reactive: false }` eliminates all trigger overhead.
291
346
 
292
- No triggers. No `_changes` table. Zero disk overhead. WAL mode is enabled by default for concurrent read/write.
347
+ ### Why MAX(id) fast-path?
293
348
 
294
- ### Multi-process example
349
+ The poller checks `SELECT MAX(id) FROM _changes` before fetching rows. On idle (no changes), this avoids materializing any row objects:
295
350
 
296
- ```typescript
297
- // Process A — watches for new/edited messages
298
- const unsub = db.messages.select()
299
- .orderBy('id', 'asc')
300
- .subscribe((messages) => {
301
- console.log('Messages:', messages);
302
- }, { interval: 200 });
303
-
304
- // Process B — writes to the same DB file (different process)
305
- // sqlite3 chat.db "INSERT INTO messages (text, author) VALUES ('hello', 'Bob')"
306
- // → Process A's callback fires with updated message list!
307
- ```
351
+ | Strategy | Per poll (idle) |
352
+ |---|---|
353
+ | `MAX(id)` check only | **153ns** |
354
+ | `SELECT * WHERE id > ?` (returns 0 rows) | 192ns |
308
355
 
309
- Run `bun examples/messages-demo.ts` for a full working demo.
356
+ ~20% faster on the hot path with no penalty when changes exist.
310
357
 
311
- **Use cases:**
312
- - Live dashboards (poll every 1-5s)
313
- - Real-time chat / message lists
314
- - Auto-refreshing data tables
315
- - Watching filtered subsets of data
316
- - Cross-process data synchronization
358
+ ### Index impact
317
359
 
318
- ---
360
+ Benchmarked on 100K rows, 10K queries each:
319
361
 
320
- ## Examples & Tests
362
+ | Query pattern | No index | With index | Speedup |
363
+ |---|---|---|---|
364
+ | Point lookup (`WHERE email = ?`) | 2,447µs | **2.2µs** | **1,112x** |
365
+ | Top-N (`ORDER BY score DESC LIMIT 10`) | 2,777µs | **7.1µs** | **391x** |
366
+ | COUNT with filter | 2,526µs | **344µs** | **7x** |
367
+ | Range scan (`WHERE score > ? LIMIT 100`) | 54µs | 75µs | 0.7x* |
368
+ | Category filter (`WHERE role = ?`, ~20K rows) | 11,084µs | 14,809µs | 0.7x* |
321
369
 
322
- ```bash
323
- bun examples/messages-demo.ts # .on() vs .subscribe() demo
324
- bun examples/example.ts # comprehensive demo
325
- bun test # 100 tests
326
- ```
370
+ *\*Range scans and wide category filters can be slightly slower with indexes due to random I/O — SQLite's full scan is faster when returning a large fraction of the table.*
371
+
372
+ **Write overhead:** 4 indexes add ~2.8x to INSERT cost (1.9µs → 5.3µs per insert). This is typical and a good tradeoff for read-heavy workloads.
327
373
 
328
374
  ---
329
375
 
@@ -350,10 +396,17 @@ bun test # 100 tests
350
396
  | `entity.navMethod()` | Lazy navigation (FK name minus `_id`) |
351
397
  | `entity.update(data)` | Update entity in-place |
352
398
  | `entity.delete()` | Delete entity |
353
- | **Reactivity** | |
354
- | `db.table.on(cb, opts?)` | Stream new rows one at a time, in order |
355
- | `select().subscribe(cb, opts?)` | Watch query result snapshot (all mutations) |
399
+ | **Change Listeners** | |
400
+ | `db.table.on('insert', cb)` | Listen for new rows (receives full row) |
401
+ | `db.table.on('update', cb)` | Listen for updated rows (receives full row) |
402
+ | `db.table.on('delete', cb)` | Listen for deleted rows (receives `{ id }`) |
403
+ | **Options** | |
404
+ | `{ reactive: false }` | Disable triggers entirely (no .on() support) |
405
+ | `{ pollInterval: 100 }` | Global poller interval in ms (default: 100) |
406
+ | **Transactions** | |
407
+ | `db.transaction(fn)` | Atomic operation with auto-rollback |
356
408
 
357
409
  ## License
358
410
 
359
411
  MIT
412
+