sqlite-zod-orm 3.11.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 +48 -381
- package/dist/index.js +42 -3
- package/package.json +1 -1
- package/src/builder.ts +13 -0
- package/src/crud.ts +14 -2
- package/src/database.ts +31 -2
- package/src/types.ts +9 -0
package/README.md
CHANGED
|
@@ -1,412 +1,79 @@
|
|
|
1
1
|
# sqlite-zod-orm
|
|
2
2
|
|
|
3
|
-
Type-safe SQLite ORM for Bun
|
|
3
|
+
**Type-safe SQLite ORM for Bun** — Zod schemas in, fully typed database out. Zero SQL required.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/sqlite-zod-orm)
|
|
6
|
+
[](./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('
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
140
|
-
const
|
|
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
|
-
//
|
|
167
|
-
|
|
34
|
+
// Query
|
|
35
|
+
const admins = db.users.select()
|
|
36
|
+
.where({ role: 'admin' })
|
|
37
|
+
.orderBy('name')
|
|
38
|
+
.all();
|
|
168
39
|
|
|
169
|
-
//
|
|
170
|
-
|
|
40
|
+
// Update
|
|
41
|
+
alice.name = 'Alice Smith'; // auto-persists
|
|
171
42
|
|
|
172
43
|
// Delete
|
|
173
|
-
db.users.delete(
|
|
44
|
+
db.users.delete(alice.id);
|
|
174
45
|
```
|
|
175
46
|
|
|
176
|
-
|
|
47
|
+
## Features
|
|
177
48
|
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
See [SKILL.md](./SKILL.md) for comprehensive documentation with examples for every feature.
|
|
190
70
|
|
|
191
|
-
|
|
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
|
|
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
|
-
|
package/dist/index.js
CHANGED
|
@@ -4465,6 +4465,11 @@ class QueryBuilder {
|
|
|
4465
4465
|
this.iqo.wheres = this.iqo.wheres.filter((w) => !(w.field === "deletedAt" && w.operator === "IS NULL"));
|
|
4466
4466
|
return this;
|
|
4467
4467
|
}
|
|
4468
|
+
onlyTrashed() {
|
|
4469
|
+
this.iqo.wheres = this.iqo.wheres.filter((w) => !(w.field === "deletedAt" && w.operator === "IS NULL"));
|
|
4470
|
+
this.iqo.wheres.push({ field: "deletedAt", operator: "IS NOT NULL", value: null });
|
|
4471
|
+
return this;
|
|
4472
|
+
}
|
|
4468
4473
|
having(conditions) {
|
|
4469
4474
|
for (const [field, value] of Object.entries(conditions)) {
|
|
4470
4475
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
@@ -4975,7 +4980,18 @@ function deleteWhere(ctx, entityName, conditions) {
|
|
|
4975
4980
|
const { clause, values } = ctx.buildWhereClause(conditions);
|
|
4976
4981
|
if (!clause)
|
|
4977
4982
|
throw new Error("delete().where() requires at least one condition");
|
|
4978
|
-
|
|
4983
|
+
if (ctx.softDeletes) {
|
|
4984
|
+
const now = new Date().toISOString();
|
|
4985
|
+
const sql2 = `UPDATE "${entityName}" SET "deletedAt" = ? ${clause}`;
|
|
4986
|
+
if (ctx.debug)
|
|
4987
|
+
console.log("[satidb]", sql2, [now, ...values]);
|
|
4988
|
+
const result2 = ctx.db.query(sql2).run(now, ...values);
|
|
4989
|
+
return result2.changes ?? 0;
|
|
4990
|
+
}
|
|
4991
|
+
const sql = `DELETE FROM "${entityName}" ${clause}`;
|
|
4992
|
+
if (ctx.debug)
|
|
4993
|
+
console.log("[satidb]", sql, values);
|
|
4994
|
+
const result = ctx.db.query(sql).run(...values);
|
|
4979
4995
|
return result.changes ?? 0;
|
|
4980
4996
|
}
|
|
4981
4997
|
function createDeleteBuilder(ctx, entityName) {
|
|
@@ -5093,6 +5109,8 @@ class _Database {
|
|
|
5093
5109
|
this.runMigrations();
|
|
5094
5110
|
if (options.indexes)
|
|
5095
5111
|
this.createIndexes(options.indexes);
|
|
5112
|
+
if (options.unique)
|
|
5113
|
+
this.createUniqueConstraints(options.unique);
|
|
5096
5114
|
for (const entityName of Object.keys(schemas)) {
|
|
5097
5115
|
const key = entityName;
|
|
5098
5116
|
const accessor = {
|
|
@@ -5108,13 +5126,20 @@ class _Database {
|
|
|
5108
5126
|
if (typeof id === "number") {
|
|
5109
5127
|
if (this._softDeletes) {
|
|
5110
5128
|
const now = new Date().toISOString();
|
|
5111
|
-
this.db.
|
|
5129
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
|
|
5112
5130
|
return;
|
|
5113
5131
|
}
|
|
5114
5132
|
return deleteEntity(this._ctx, entityName, id);
|
|
5115
5133
|
}
|
|
5116
5134
|
return createDeleteBuilder(this._ctx, entityName);
|
|
5117
5135
|
},
|
|
5136
|
+
restore: (id) => {
|
|
5137
|
+
if (!this._softDeletes)
|
|
5138
|
+
throw new Error("restore() requires softDeletes: true");
|
|
5139
|
+
if (this._debug)
|
|
5140
|
+
console.log("[satidb]", `UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`, [id]);
|
|
5141
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
|
|
5142
|
+
},
|
|
5118
5143
|
select: (...cols) => createQueryBuilder(this._ctx, entityName, cols),
|
|
5119
5144
|
on: (event, callback) => {
|
|
5120
5145
|
return this._registerListener(entityName, event, callback);
|
|
@@ -5194,6 +5219,14 @@ class _Database {
|
|
|
5194
5219
|
}
|
|
5195
5220
|
}
|
|
5196
5221
|
}
|
|
5222
|
+
createUniqueConstraints(unique) {
|
|
5223
|
+
for (const [tableName, groups] of Object.entries(unique)) {
|
|
5224
|
+
for (const cols of groups) {
|
|
5225
|
+
const idxName = `uq_${tableName}_${cols.join("_")}`;
|
|
5226
|
+
this.db.run(`CREATE UNIQUE INDEX IF NOT EXISTS "${idxName}" ON "${tableName}" (${cols.map((c) => `"${c}"`).join(", ")})`);
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
}
|
|
5197
5230
|
_registerListener(table, event, callback) {
|
|
5198
5231
|
if (!this._reactive) {
|
|
5199
5232
|
throw new Error("Change listeners are disabled. Set { reactive: true } (or omit it) in Database options to enable .on().");
|
|
@@ -5249,7 +5282,7 @@ class _Database {
|
|
|
5249
5282
|
}
|
|
5250
5283
|
this._changeWatermark = change.id;
|
|
5251
5284
|
}
|
|
5252
|
-
this.db.
|
|
5285
|
+
this.db.query('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
|
|
5253
5286
|
}
|
|
5254
5287
|
transaction(callback) {
|
|
5255
5288
|
return this.db.transaction(callback)();
|
|
@@ -5275,6 +5308,12 @@ class _Database {
|
|
|
5275
5308
|
console.log("[satidb]", sql, params);
|
|
5276
5309
|
this.db.run(sql, ...params);
|
|
5277
5310
|
}
|
|
5311
|
+
tables() {
|
|
5312
|
+
return Object.keys(this.schemas);
|
|
5313
|
+
}
|
|
5314
|
+
columns(tableName) {
|
|
5315
|
+
return this.db.query(`PRAGMA table_info("${tableName}")`).all();
|
|
5316
|
+
}
|
|
5278
5317
|
}
|
|
5279
5318
|
var Database = _Database;
|
|
5280
5319
|
export {
|
package/package.json
CHANGED
package/src/builder.ts
CHANGED
|
@@ -313,6 +313,19 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
313
313
|
return this;
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Return only soft-deleted rows.
|
|
318
|
+
* Only relevant when `softDeletes: true` is set in Database options.
|
|
319
|
+
*/
|
|
320
|
+
onlyTrashed(): this {
|
|
321
|
+
// Remove the auto-injected `deletedAt IS NULL` and add `deletedAt IS NOT NULL`
|
|
322
|
+
this.iqo.wheres = this.iqo.wheres.filter(
|
|
323
|
+
w => !(w.field === 'deletedAt' && w.operator === 'IS NULL')
|
|
324
|
+
);
|
|
325
|
+
this.iqo.wheres.push({ field: 'deletedAt', operator: 'IS NOT NULL', value: null });
|
|
326
|
+
return this;
|
|
327
|
+
}
|
|
328
|
+
|
|
316
329
|
/**
|
|
317
330
|
* Add HAVING conditions (used after groupBy for aggregate filtering).
|
|
318
331
|
*
|
package/src/crud.ts
CHANGED
|
@@ -134,11 +134,23 @@ export function deleteEntity(ctx: DatabaseContext, entityName: string, id: numbe
|
|
|
134
134
|
ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/** Delete all rows matching the given conditions. Returns the number of rows
|
|
137
|
+
/** Delete all rows matching the given conditions. Returns the number of rows affected. */
|
|
138
138
|
export function deleteWhere(ctx: DatabaseContext, entityName: string, conditions: Record<string, any>): number {
|
|
139
139
|
const { clause, values } = ctx.buildWhereClause(conditions);
|
|
140
140
|
if (!clause) throw new Error('delete().where() requires at least one condition');
|
|
141
|
-
|
|
141
|
+
|
|
142
|
+
if (ctx.softDeletes) {
|
|
143
|
+
// Soft delete: set deletedAt instead of removing rows
|
|
144
|
+
const now = new Date().toISOString();
|
|
145
|
+
const sql = `UPDATE "${entityName}" SET "deletedAt" = ? ${clause}`;
|
|
146
|
+
if (ctx.debug) console.log('[satidb]', sql, [now, ...values]);
|
|
147
|
+
const result = ctx.db.query(sql).run(now, ...values);
|
|
148
|
+
return (result as any).changes ?? 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const sql = `DELETE FROM "${entityName}" ${clause}`;
|
|
152
|
+
if (ctx.debug) console.log('[satidb]', sql, values);
|
|
153
|
+
const result = ctx.db.query(sql).run(...values);
|
|
142
154
|
return (result as any).changes ?? 0;
|
|
143
155
|
}
|
|
144
156
|
|
package/src/database.ts
CHANGED
|
@@ -92,6 +92,7 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
92
92
|
if (this._reactive) this.initializeChangeTracking();
|
|
93
93
|
this.runMigrations();
|
|
94
94
|
if (options.indexes) this.createIndexes(options.indexes);
|
|
95
|
+
if (options.unique) this.createUniqueConstraints(options.unique);
|
|
95
96
|
|
|
96
97
|
// Create typed entity accessors (db.users, db.posts, etc.)
|
|
97
98
|
for (const entityName of Object.keys(schemas)) {
|
|
@@ -109,13 +110,18 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
109
110
|
if (this._softDeletes) {
|
|
110
111
|
// Soft delete: set deletedAt instead of removing
|
|
111
112
|
const now = new Date().toISOString();
|
|
112
|
-
this.db.
|
|
113
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
|
|
113
114
|
return;
|
|
114
115
|
}
|
|
115
116
|
return deleteEntity(this._ctx, entityName, id);
|
|
116
117
|
}
|
|
117
118
|
return createDeleteBuilder(this._ctx, entityName);
|
|
118
119
|
}) as any,
|
|
120
|
+
restore: ((id: number) => {
|
|
121
|
+
if (!this._softDeletes) throw new Error('restore() requires softDeletes: true');
|
|
122
|
+
if (this._debug) console.log('[satidb]', `UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`, [id]);
|
|
123
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
|
|
124
|
+
}) as any,
|
|
119
125
|
select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
|
|
120
126
|
on: (event: ChangeEvent, callback: (row: any) => void | Promise<void>) => {
|
|
121
127
|
return this._registerListener(entityName, event, callback);
|
|
@@ -228,6 +234,15 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
228
234
|
}
|
|
229
235
|
}
|
|
230
236
|
|
|
237
|
+
private createUniqueConstraints(unique: Record<string, string[][]>): void {
|
|
238
|
+
for (const [tableName, groups] of Object.entries(unique)) {
|
|
239
|
+
for (const cols of groups) {
|
|
240
|
+
const idxName = `uq_${tableName}_${cols.join('_')}`;
|
|
241
|
+
this.db.run(`CREATE UNIQUE INDEX IF NOT EXISTS "${idxName}" ON "${tableName}" (${cols.map(c => `"${c}"`).join(', ')})`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
231
246
|
// =========================================================================
|
|
232
247
|
// Change Listeners — db.table.on('insert' | 'update' | 'delete', cb)
|
|
233
248
|
// =========================================================================
|
|
@@ -306,7 +321,7 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
306
321
|
}
|
|
307
322
|
|
|
308
323
|
// Clean up consumed changes
|
|
309
|
-
this.db.
|
|
324
|
+
this.db.query('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
|
|
310
325
|
}
|
|
311
326
|
|
|
312
327
|
// =========================================================================
|
|
@@ -356,6 +371,20 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
356
371
|
if (this._debug) console.log('[satidb]', sql, params);
|
|
357
372
|
this.db.run(sql, ...params);
|
|
358
373
|
}
|
|
374
|
+
|
|
375
|
+
// =========================================================================
|
|
376
|
+
// Schema Introspection
|
|
377
|
+
// =========================================================================
|
|
378
|
+
|
|
379
|
+
/** Return the list of user-defined table names. */
|
|
380
|
+
public tables(): string[] {
|
|
381
|
+
return Object.keys(this.schemas);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Return column info for a table via PRAGMA table_info. */
|
|
385
|
+
public columns(tableName: string): { name: string; type: string; notnull: number; pk: number }[] {
|
|
386
|
+
return this.db.query(`PRAGMA table_info("${tableName}")`).all() as any[];
|
|
387
|
+
}
|
|
359
388
|
}
|
|
360
389
|
|
|
361
390
|
// =============================================================================
|
package/src/types.ts
CHANGED
|
@@ -21,6 +21,12 @@ export type IndexDef = string | string[];
|
|
|
21
21
|
|
|
22
22
|
export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
23
23
|
indexes?: Record<string, IndexDef[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Unique constraints per table. Each entry is an array of column groups.
|
|
26
|
+
* Single column: `{ users: [['email']] }` → UNIQUE INDEX.
|
|
27
|
+
* Compound: `{ users: [['email'], ['name', 'org_id']] }` → two UNIQUE indexes.
|
|
28
|
+
*/
|
|
29
|
+
unique?: Record<string, string[][]>;
|
|
24
30
|
/**
|
|
25
31
|
* Declare relationships between tables.
|
|
26
32
|
*
|
|
@@ -179,6 +185,7 @@ export type NavEntityAccessor<
|
|
|
179
185
|
& ((data: Partial<Omit<z.input<S[Table & keyof S]>, 'id'>>) => UpdateBuilder<NavEntity<S, R, Table>>);
|
|
180
186
|
upsert: (conditions?: Partial<z.infer<S[Table & keyof S]>>, data?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>;
|
|
181
187
|
delete: ((id: number) => void) & (() => DeleteBuilder<NavEntity<S, R, Table>>);
|
|
188
|
+
restore: (id: number) => void;
|
|
182
189
|
select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
|
|
183
190
|
on: ((event: 'insert' | 'update', callback: (row: NavEntity<S, R, Table>) => void | Promise<void>) => () => void) &
|
|
184
191
|
((event: 'delete', callback: (row: { id: number }) => void | Promise<void>) => () => void);
|
|
@@ -209,6 +216,8 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
|
|
|
209
216
|
update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
|
|
210
217
|
upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
|
|
211
218
|
delete: ((id: number) => void) & (() => DeleteBuilder<AugmentedEntity<S>>);
|
|
219
|
+
/** Undo a soft delete by setting deletedAt = null. Requires softDeletes. */
|
|
220
|
+
restore: (id: number) => void;
|
|
212
221
|
select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
|
|
213
222
|
on: ((event: 'insert' | 'update', callback: (row: AugmentedEntity<S>) => void | Promise<void>) => () => void) &
|
|
214
223
|
((event: 'delete', callback: (row: { id: number }) => void | Promise<void>) => () => void);
|