sqlite-zod-orm 3.10.0 → 3.12.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
@@ -1,412 +1,79 @@
1
1
  # sqlite-zod-orm
2
2
 
3
- Type-safe SQLite ORM for Bun. Define schemas with Zod, get a fully-typed database with automatic relationships, lazy navigation, and zero SQL.
3
+ **Type-safe SQLite ORM for Bun** Zod schemas in, fully typed database out. Zero SQL required.
4
+
5
+ [![npm](https://img.shields.io/npm/v/sqlite-zod-orm)](https://www.npmjs.com/package/sqlite-zod-orm)
6
+ [![license](https://img.shields.io/npm/l/sqlite-zod-orm)](./LICENSE)
7
+
8
+ ## Install
4
9
 
5
10
  ```bash
6
11
  bun add sqlite-zod-orm
7
12
  ```
8
13
 
14
+ > **Requires Bun runtime** — uses `bun:sqlite` under the hood.
15
+
9
16
  ## Quick Start
10
17
 
11
18
  ```typescript
12
19
  import { Database, z } from 'sqlite-zod-orm';
13
20
 
14
- const db = new Database(':memory:', {
15
- users: z.object({
16
- name: z.string(),
17
- email: z.string().email(),
18
- role: z.string().default('member'),
19
- }),
20
- });
21
-
22
- const alice = db.users.insert({ name: 'Alice', email: 'alice@example.com', role: 'admin' });
23
- const admin = db.users.select().where({ role: 'admin' }).get(); // single row
24
- const all = db.users.select().all(); // all rows
25
- ```
26
-
27
- ---
28
-
29
- ## Defining Relationships
30
-
31
- FK columns go in your schema. The `relations` config declares which FK points to which table:
32
-
33
- ```typescript
34
- const AuthorSchema = z.object({ name: z.string(), country: z.string() });
35
- const BookSchema = z.object({ title: z.string(), year: z.number(), author_id: z.number().optional() });
36
-
37
- const db = new Database(':memory:', {
38
- authors: AuthorSchema,
39
- books: BookSchema,
40
- }, {
41
- relations: {
42
- books: { author_id: 'authors' },
43
- },
44
- });
45
- ```
46
-
47
- `books: { author_id: 'authors' }` tells the ORM that `books.author_id` is a foreign key referencing `authors.id`. The ORM automatically:
48
-
49
- - Adds `FOREIGN KEY (author_id) REFERENCES authors(id)` constraint
50
- - Infers the inverse one-to-many `authors → books`
51
- - Enables lazy navigation: `book.author()` and `author.books()`
52
- - Enables fluent joins: `db.books.select().join(db.authors).all()`
53
-
54
- The nav method name is derived by stripping `_id` from the FK column: `author_id` → `author()`.
55
-
56
- ---
57
-
58
- ## Querying — `select()` is the only path
59
-
60
- All queries go through `select()`:
61
-
62
- ```typescript
63
- // Single row
64
- const user = db.users.select().where({ id: 1 }).get();
65
-
66
- // All matching rows
67
- const admins = db.users.select().where({ role: 'admin' }).all();
68
-
69
- // All rows
70
- const everyone = db.users.select().all();
71
-
72
- // Count
73
- const count = db.users.select().count();
74
- ```
75
-
76
- ### Operators
77
-
78
- `$gt` `$gte` `$lt` `$lte` `$ne` `$in`
79
-
80
- ```typescript
81
- const topScorers = db.users.select()
82
- .where({ score: { $gt: 50 } })
83
- .orderBy('score', 'desc')
84
- .limit(10)
85
- .all();
86
- ```
87
-
88
- ### `$or`
89
-
90
- ```typescript
91
- const results = db.users.select()
92
- .where({ $or: [{ role: 'admin' }, { score: { $gt: 50 } }] })
93
- .all();
94
- ```
95
-
96
- ### Fluent Join
97
-
98
- Auto-infers foreign keys from relationships:
99
-
100
- ```typescript
101
- const rows = db.books.select('title', 'year')
102
- .join(db.authors, ['name', 'country'])
103
- .where({ year: { $gt: 1800 } })
104
- .orderBy('year', 'asc')
105
- .all();
106
- // → [{ title: 'War and Peace', year: 1869, authors_name: 'Leo Tolstoy', ... }]
107
- ```
108
-
109
- ### `db.query()` — Proxy Query (SQL-like)
110
-
111
- Full SQL-like control with destructured table aliases:
112
-
113
- ```typescript
114
- const rows = db.query(c => {
115
- const { authors: a, books: b } = c;
116
- return {
117
- select: { author: a.name, book: b.title, year: b.year },
118
- join: [[b.author_id, a.id]],
119
- where: { [a.country]: 'Russia' },
120
- orderBy: { [b.year]: 'asc' },
121
- };
21
+ const db = new Database('app.db', {
22
+ users: z.object({
23
+ name: z.string(),
24
+ email: z.string(),
25
+ role: z.string().default('member'),
26
+ }),
122
27
  });
123
- ```
124
-
125
- ---
126
-
127
- ## Lazy Navigation
128
-
129
- Relationship fields become callable methods on entities. The method name is the FK column with `_id` stripped:
130
-
131
- ```typescript
132
- // belongs-to: book.author_id → book.author()
133
- const book = db.books.select().where({ title: 'War and Peace' }).get()!;
134
- const author = book.author(); // → { name: 'Leo Tolstoy', ... }
135
-
136
- // one-to-many: author → books
137
- const books = tolstoy.books(); // → [{ title: 'War and Peace' }, ...]
138
28
 
139
- // Chain
140
- const allByAuthor = book.author().books();
141
- ```
142
-
143
- ---
144
-
145
- ## CRUD
146
-
147
- ```typescript
148
- // Insert (defaults fill in automatically)
149
- const user = db.users.insert({ name: 'Alice', role: 'admin' });
150
-
151
- // Insert with FK
152
- const book = db.books.insert({ title: 'War and Peace', year: 1869, author_id: tolstoy.id });
153
-
154
- // Read
155
- const one = db.users.select().where({ id: 1 }).get();
156
- const some = db.users.select().where({ role: 'admin' }).all();
157
- const all = db.users.select().all();
158
- const count = db.users.select().count();
159
-
160
- // Entity-level update
161
- user.update({ role: 'superadmin' });
162
-
163
- // Update by ID
164
- db.users.update(1, { role: 'superadmin' });
29
+ // Insert
30
+ const alice = db.users.insert({ name: 'Alice', email: 'alice@co.com' });
31
+ alice.id; // auto-increment ID
32
+ alice.role; // 'member' (from Zod default)
165
33
 
166
- // Fluent update with WHERE
167
- db.users.update({ role: 'member' }).where({ role: 'guest' }).exec();
34
+ // Query
35
+ const admins = db.users.select()
36
+ .where({ role: 'admin' })
37
+ .orderBy('name')
38
+ .all();
168
39
 
169
- // Upsert
170
- db.users.upsert({ name: 'Alice' }, { name: 'Alice', role: 'admin' });
40
+ // Update
41
+ alice.name = 'Alice Smith'; // auto-persists
171
42
 
172
43
  // Delete
173
- db.users.delete(1);
44
+ db.users.delete(alice.id);
174
45
  ```
175
46
 
176
- ### Auto-Persist Proxy
47
+ ## Features
177
48
 
178
- Setting a property on an entity auto-updates the DB:
49
+ - **Zod schemas typed database** define once, types flow everywhere
50
+ - **Auto-migration** — new schema fields auto-add columns on startup
51
+ - **Fluent query builder** — `.where()`, `.orderBy()`, `.limit()`, `.join()`, `.groupBy()`, `.having()`
52
+ - **Rich operators** — `$gt`, `$lt`, `$in`, `$like`, `$isNull`, `$isNotNull`, and more
53
+ - **Aggregates** — `.sum()`, `.avg()`, `.min()`, `.max()`, `.count()`
54
+ - **Pagination** — `.paginate(page, perPage)` with metadata
55
+ - **Relationships** — foreign keys, lazy navigation, fluent joins
56
+ - **Reactivity** — `.on('insert' | 'update' | 'delete', callback)` with trigger-based change tracking
57
+ - **Transactions** — `db.transaction(() => { ... })`
58
+ - **Timestamps** — auto `createdAt`/`updatedAt` with `{ timestamps: true }`
59
+ - **Soft deletes** — `{ softDeletes: true }` with `.withTrashed()`, `.onlyTrashed()`, `.restore()`
60
+ - **Unique constraints** — `{ unique: { users: [['email']] } }`
61
+ - **Schema introspection** — `db.tables()`, `db.columns('users')`
62
+ - **Raw SQL** — `db.raw()` / `db.exec()` escape hatch
63
+ - **Debug mode** — `{ debug: true }` logs all SQL to console
64
+ - **Distinct** — `.distinct()` on queries
65
+ - **Proxy queries** — SQL-like DSL with type-safe column references
179
66
 
180
- ```typescript
181
- const alice = db.users.select().where({ id: 1 }).get()!;
182
- alice.score = 200; // → UPDATE users SET score = 200 WHERE id = 1
183
- ```
184
-
185
- ---
186
-
187
- ## Change Listeners — `db.table.on()`
67
+ ## Documentation
188
68
 
189
- Register listeners for insert, update, and delete events. Uses SQLite triggers + a single global poller — no per-listener overhead.
69
+ See [SKILL.md](./SKILL.md) for comprehensive documentation with examples for every feature.
190
70
 
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
-
241
- ## Schema Validation
242
-
243
- Zod validates every insert and update at runtime:
244
-
245
- ```typescript
246
- db.users.insert({ name: '', email: 'bad', age: -1 }); // throws ZodError
247
- ```
248
-
249
- ---
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
-
280
- ## Indexes
281
-
282
- ```typescript
283
- const db = new Database(':memory:', schemas, {
284
- indexes: {
285
- users: ['email', ['name', 'role']],
286
- books: ['author_id', 'year'],
287
- },
288
- });
289
- ```
290
-
291
- ---
292
-
293
- ## Transactions
294
-
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
- ```
303
-
304
- ---
305
-
306
- ## Examples & Tests
71
+ ## Tests
307
72
 
308
73
  ```bash
309
- bun examples/messages-demo.ts # on() change listener demo
310
- bun examples/example.ts # comprehensive demo
311
- bun test # 117 tests
74
+ bun test # 160 tests, ~1.5s
312
75
  ```
313
76
 
314
- ---
315
-
316
- ## Benchmarks
317
-
318
- All benchmarks run on in-memory SQLite via Bun. Reproduce with:
319
-
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
324
- ```
325
-
326
- ### Why triggers? — Change detection strategies
327
-
328
- We compared three approaches for detecting data changes:
329
-
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 |
335
-
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.
337
-
338
- **Write overhead** for triggers is ~1µs per mutation (one extra INSERT into `_changes`):
339
-
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 |
344
-
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.
346
-
347
- ### Why MAX(id) fast-path?
348
-
349
- The poller checks `SELECT MAX(id) FROM _changes` before fetching rows. On idle (no changes), this avoids materializing any row objects:
350
-
351
- | Strategy | Per poll (idle) |
352
- |---|---|
353
- | `MAX(id)` check only | **153ns** |
354
- | `SELECT * WHERE id > ?` (returns 0 rows) | 192ns |
355
-
356
- ~20% faster on the hot path with no penalty when changes exist.
357
-
358
- ### Index impact
359
-
360
- Benchmarked on 100K rows, 10K queries each:
361
-
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* |
369
-
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.
373
-
374
- ---
375
-
376
- ## API Reference
377
-
378
- | Method | Description |
379
- |---|---|
380
- | `new Database(path, schemas, options?)` | Create database with Zod schemas |
381
- | **Querying** | |
382
- | `db.table.select(...cols?).where(filter).get()` | Single row |
383
- | `db.table.select(...cols?).where(filter).all()` | Array of rows |
384
- | `db.table.select().count()` | Count rows |
385
- | `db.table.select().join(db.other, cols?).all()` | Fluent join (auto FK) |
386
- | `db.table.select().with('children').all()` | Eager load related entities (no N+1) |
387
- | `.where({ relation: entity })` | Filter by entity reference |
388
- | `db.query(c => { ... })` | Proxy callback (SQL-like JOINs) |
389
- | **Writing** | |
390
- | `db.table.insert(data)` | Insert with validation |
391
- | `db.table.update(id, data)` | Update by ID |
392
- | `db.table.update(data).where(filter).exec()` | Fluent update |
393
- | `db.table.upsert(match, data)` | Insert or update |
394
- | `db.table.delete(id)` | Delete by ID |
395
- | **Navigation** | |
396
- | `entity.navMethod()` | Lazy navigation (FK name minus `_id`) |
397
- | `entity.update(data)` | Update entity in-place |
398
- | `entity.delete()` | Delete entity |
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 |
408
-
409
77
  ## License
410
78
 
411
79
  MIT
412
-