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 +146 -93
- package/dist/index.js +2437 -2386
- package/package.json +5 -3
- package/src/ast.ts +1 -1
- package/src/builder.ts +311 -0
- package/src/context.ts +25 -0
- package/src/crud.ts +163 -0
- package/src/database.ts +173 -396
- package/src/entity.ts +62 -0
- package/src/helpers.ts +87 -0
- package/src/index.ts +2 -3
- package/src/iqo.ts +172 -0
- package/src/{proxy-query.ts → proxy.ts} +22 -58
- package/src/query.ts +136 -0
- package/src/types.ts +27 -6
- package/dist/satidb.js +0 -26
- package/src/build.ts +0 -21
- package/src/query-builder.ts +0 -669
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
|
-
##
|
|
293
|
+
## Transactions
|
|
212
294
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
316
|
+
## Benchmarks
|
|
240
317
|
|
|
241
|
-
|
|
318
|
+
All benchmarks run on in-memory SQLite via Bun. Reproduce with:
|
|
242
319
|
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
326
|
+
### Why triggers? — Change detection strategies
|
|
256
327
|
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
+
**Write overhead** for triggers is ~1µs per mutation (one extra INSERT into `_changes`):
|
|
280
339
|
|
|
281
|
-
|
|
|
282
|
-
|
|
283
|
-
|
|
|
284
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
347
|
+
### Why MAX(id) fast-path?
|
|
293
348
|
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
356
|
+
~20% faster on the hot path with no penalty when changes exist.
|
|
310
357
|
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
| **
|
|
354
|
-
| `db.table.on(
|
|
355
|
-
| `
|
|
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
|
+
|