sqlite-zod-orm 3.11.0 → 3.13.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 +119 -10
- package/package.json +1 -1
- package/src/builder.ts +35 -0
- package/src/context.ts +4 -1
- package/src/crud.ts +87 -8
- package/src/database.ts +41 -3
- package/src/types.ts +32 -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)) {
|
|
@@ -4512,6 +4517,15 @@ class QueryBuilder {
|
|
|
4512
4517
|
const data = this.all();
|
|
4513
4518
|
return { data, total, page, perPage, pages };
|
|
4514
4519
|
}
|
|
4520
|
+
countGrouped() {
|
|
4521
|
+
if (this.iqo.groupBy.length === 0) {
|
|
4522
|
+
throw new Error("countGrouped() requires at least one groupBy() call");
|
|
4523
|
+
}
|
|
4524
|
+
const groupCols = this.iqo.groupBy.map((c) => `"${c}"`).join(", ");
|
|
4525
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
4526
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT ${groupCols}, COUNT(*) as count FROM`);
|
|
4527
|
+
return this.executor(aggSql, params, true);
|
|
4528
|
+
}
|
|
4515
4529
|
then(onfulfilled, onrejected) {
|
|
4516
4530
|
try {
|
|
4517
4531
|
const result = this.all();
|
|
@@ -4897,7 +4911,14 @@ function findMany(ctx, entityName, conditions = {}) {
|
|
|
4897
4911
|
}
|
|
4898
4912
|
function insert(ctx, entityName, data) {
|
|
4899
4913
|
const schema = ctx.schemas[entityName];
|
|
4900
|
-
|
|
4914
|
+
let inputData = { ...data };
|
|
4915
|
+
const hooks = ctx.hooks[entityName];
|
|
4916
|
+
if (hooks?.beforeInsert) {
|
|
4917
|
+
const result2 = hooks.beforeInsert(inputData);
|
|
4918
|
+
if (result2)
|
|
4919
|
+
inputData = result2;
|
|
4920
|
+
}
|
|
4921
|
+
const validatedData = asZodObject(schema).passthrough().parse(inputData);
|
|
4901
4922
|
const transformed = transformForStorage(validatedData);
|
|
4902
4923
|
if (ctx.timestamps) {
|
|
4903
4924
|
const now = new Date().toISOString();
|
|
@@ -4913,11 +4934,20 @@ function insert(ctx, entityName, data) {
|
|
|
4913
4934
|
const newEntity = getById(ctx, entityName, result.lastInsertRowid);
|
|
4914
4935
|
if (!newEntity)
|
|
4915
4936
|
throw new Error("Failed to retrieve entity after insertion");
|
|
4937
|
+
if (hooks?.afterInsert)
|
|
4938
|
+
hooks.afterInsert(newEntity);
|
|
4916
4939
|
return newEntity;
|
|
4917
4940
|
}
|
|
4918
4941
|
function update(ctx, entityName, id, data) {
|
|
4919
4942
|
const schema = ctx.schemas[entityName];
|
|
4920
|
-
|
|
4943
|
+
let inputData = { ...data };
|
|
4944
|
+
const hooks = ctx.hooks[entityName];
|
|
4945
|
+
if (hooks?.beforeUpdate) {
|
|
4946
|
+
const result = hooks.beforeUpdate(inputData, id);
|
|
4947
|
+
if (result)
|
|
4948
|
+
inputData = result;
|
|
4949
|
+
}
|
|
4950
|
+
const validatedData = asZodObject(schema).partial().parse(inputData);
|
|
4921
4951
|
const transformed = transformForStorage(validatedData);
|
|
4922
4952
|
if (Object.keys(transformed).length === 0 && !ctx.timestamps)
|
|
4923
4953
|
return getById(ctx, entityName, id);
|
|
@@ -4929,7 +4959,10 @@ function update(ctx, entityName, id, data) {
|
|
|
4929
4959
|
if (ctx.debug)
|
|
4930
4960
|
console.log("[satidb]", sql, [...Object.values(transformed), id]);
|
|
4931
4961
|
ctx.db.query(sql).run(...Object.values(transformed), id);
|
|
4932
|
-
|
|
4962
|
+
const updated = getById(ctx, entityName, id);
|
|
4963
|
+
if (hooks?.afterUpdate && updated)
|
|
4964
|
+
hooks.afterUpdate(updated);
|
|
4965
|
+
return updated;
|
|
4933
4966
|
}
|
|
4934
4967
|
function updateWhere(ctx, entityName, data, conditions) {
|
|
4935
4968
|
const schema = ctx.schemas[entityName];
|
|
@@ -4969,13 +5002,32 @@ function upsert(ctx, entityName, data, conditions = {}) {
|
|
|
4969
5002
|
return insert(ctx, entityName, insertData);
|
|
4970
5003
|
}
|
|
4971
5004
|
function deleteEntity(ctx, entityName, id) {
|
|
5005
|
+
const hooks = ctx.hooks[entityName];
|
|
5006
|
+
if (hooks?.beforeDelete) {
|
|
5007
|
+
const result = hooks.beforeDelete(id);
|
|
5008
|
+
if (result === false)
|
|
5009
|
+
return;
|
|
5010
|
+
}
|
|
4972
5011
|
ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
|
|
5012
|
+
if (hooks?.afterDelete)
|
|
5013
|
+
hooks.afterDelete(id);
|
|
4973
5014
|
}
|
|
4974
5015
|
function deleteWhere(ctx, entityName, conditions) {
|
|
4975
5016
|
const { clause, values } = ctx.buildWhereClause(conditions);
|
|
4976
5017
|
if (!clause)
|
|
4977
5018
|
throw new Error("delete().where() requires at least one condition");
|
|
4978
|
-
|
|
5019
|
+
if (ctx.softDeletes) {
|
|
5020
|
+
const now = new Date().toISOString();
|
|
5021
|
+
const sql2 = `UPDATE "${entityName}" SET "deletedAt" = ? ${clause}`;
|
|
5022
|
+
if (ctx.debug)
|
|
5023
|
+
console.log("[satidb]", sql2, [now, ...values]);
|
|
5024
|
+
const result2 = ctx.db.query(sql2).run(now, ...values);
|
|
5025
|
+
return result2.changes ?? 0;
|
|
5026
|
+
}
|
|
5027
|
+
const sql = `DELETE FROM "${entityName}" ${clause}`;
|
|
5028
|
+
if (ctx.debug)
|
|
5029
|
+
console.log("[satidb]", sql, values);
|
|
5030
|
+
const result = ctx.db.query(sql).run(...values);
|
|
4979
5031
|
return result.changes ?? 0;
|
|
4980
5032
|
}
|
|
4981
5033
|
function createDeleteBuilder(ctx, entityName) {
|
|
@@ -4994,10 +5046,17 @@ function insertMany(ctx, entityName, rows) {
|
|
|
4994
5046
|
return [];
|
|
4995
5047
|
const schema = ctx.schemas[entityName];
|
|
4996
5048
|
const zodSchema = asZodObject(schema).passthrough();
|
|
5049
|
+
const hooks = ctx.hooks[entityName];
|
|
4997
5050
|
const txn = ctx.db.transaction(() => {
|
|
4998
5051
|
const ids2 = [];
|
|
4999
|
-
for (
|
|
5000
|
-
|
|
5052
|
+
for (let data of rows) {
|
|
5053
|
+
let inputData = { ...data };
|
|
5054
|
+
if (hooks?.beforeInsert) {
|
|
5055
|
+
const result2 = hooks.beforeInsert(inputData);
|
|
5056
|
+
if (result2)
|
|
5057
|
+
inputData = result2;
|
|
5058
|
+
}
|
|
5059
|
+
const validatedData = zodSchema.parse(inputData);
|
|
5001
5060
|
const transformed = transformForStorage(validatedData);
|
|
5002
5061
|
if (ctx.timestamps) {
|
|
5003
5062
|
const now = new Date().toISOString();
|
|
@@ -5013,7 +5072,24 @@ function insertMany(ctx, entityName, rows) {
|
|
|
5013
5072
|
return ids2;
|
|
5014
5073
|
});
|
|
5015
5074
|
const ids = txn();
|
|
5016
|
-
|
|
5075
|
+
const entities = ids.map((id) => getById(ctx, entityName, id)).filter(Boolean);
|
|
5076
|
+
if (hooks?.afterInsert) {
|
|
5077
|
+
for (const entity of entities)
|
|
5078
|
+
hooks.afterInsert(entity);
|
|
5079
|
+
}
|
|
5080
|
+
return entities;
|
|
5081
|
+
}
|
|
5082
|
+
function upsertMany(ctx, entityName, rows, conditions = {}) {
|
|
5083
|
+
if (rows.length === 0)
|
|
5084
|
+
return [];
|
|
5085
|
+
const txn = ctx.db.transaction(() => {
|
|
5086
|
+
const results = [];
|
|
5087
|
+
for (const data of rows) {
|
|
5088
|
+
results.push(upsert(ctx, entityName, data, conditions));
|
|
5089
|
+
}
|
|
5090
|
+
return results;
|
|
5091
|
+
});
|
|
5092
|
+
return txn();
|
|
5017
5093
|
}
|
|
5018
5094
|
|
|
5019
5095
|
// src/entity.ts
|
|
@@ -5085,7 +5161,8 @@ class _Database {
|
|
|
5085
5161
|
buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
|
|
5086
5162
|
debug: this._debug,
|
|
5087
5163
|
timestamps: this._timestamps,
|
|
5088
|
-
softDeletes: this._softDeletes
|
|
5164
|
+
softDeletes: this._softDeletes,
|
|
5165
|
+
hooks: options.hooks ?? {}
|
|
5089
5166
|
};
|
|
5090
5167
|
this.initializeTables();
|
|
5091
5168
|
if (this._reactive)
|
|
@@ -5093,6 +5170,8 @@ class _Database {
|
|
|
5093
5170
|
this.runMigrations();
|
|
5094
5171
|
if (options.indexes)
|
|
5095
5172
|
this.createIndexes(options.indexes);
|
|
5173
|
+
if (options.unique)
|
|
5174
|
+
this.createUniqueConstraints(options.unique);
|
|
5096
5175
|
for (const entityName of Object.keys(schemas)) {
|
|
5097
5176
|
const key = entityName;
|
|
5098
5177
|
const accessor = {
|
|
@@ -5104,17 +5183,33 @@ class _Database {
|
|
|
5104
5183
|
return createUpdateBuilder(this._ctx, entityName, idOrData);
|
|
5105
5184
|
},
|
|
5106
5185
|
upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
|
|
5186
|
+
upsertMany: (rows, conditions) => upsertMany(this._ctx, entityName, rows, conditions),
|
|
5107
5187
|
delete: (id) => {
|
|
5108
5188
|
if (typeof id === "number") {
|
|
5189
|
+
const hooks = this._ctx.hooks[entityName];
|
|
5190
|
+
if (hooks?.beforeDelete) {
|
|
5191
|
+
const result = hooks.beforeDelete(id);
|
|
5192
|
+
if (result === false)
|
|
5193
|
+
return;
|
|
5194
|
+
}
|
|
5109
5195
|
if (this._softDeletes) {
|
|
5110
5196
|
const now = new Date().toISOString();
|
|
5111
|
-
this.db.
|
|
5197
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
|
|
5198
|
+
if (hooks?.afterDelete)
|
|
5199
|
+
hooks.afterDelete(id);
|
|
5112
5200
|
return;
|
|
5113
5201
|
}
|
|
5114
5202
|
return deleteEntity(this._ctx, entityName, id);
|
|
5115
5203
|
}
|
|
5116
5204
|
return createDeleteBuilder(this._ctx, entityName);
|
|
5117
5205
|
},
|
|
5206
|
+
restore: (id) => {
|
|
5207
|
+
if (!this._softDeletes)
|
|
5208
|
+
throw new Error("restore() requires softDeletes: true");
|
|
5209
|
+
if (this._debug)
|
|
5210
|
+
console.log("[satidb]", `UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`, [id]);
|
|
5211
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
|
|
5212
|
+
},
|
|
5118
5213
|
select: (...cols) => createQueryBuilder(this._ctx, entityName, cols),
|
|
5119
5214
|
on: (event, callback) => {
|
|
5120
5215
|
return this._registerListener(entityName, event, callback);
|
|
@@ -5194,6 +5289,14 @@ class _Database {
|
|
|
5194
5289
|
}
|
|
5195
5290
|
}
|
|
5196
5291
|
}
|
|
5292
|
+
createUniqueConstraints(unique) {
|
|
5293
|
+
for (const [tableName, groups] of Object.entries(unique)) {
|
|
5294
|
+
for (const cols of groups) {
|
|
5295
|
+
const idxName = `uq_${tableName}_${cols.join("_")}`;
|
|
5296
|
+
this.db.run(`CREATE UNIQUE INDEX IF NOT EXISTS "${idxName}" ON "${tableName}" (${cols.map((c) => `"${c}"`).join(", ")})`);
|
|
5297
|
+
}
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5197
5300
|
_registerListener(table, event, callback) {
|
|
5198
5301
|
if (!this._reactive) {
|
|
5199
5302
|
throw new Error("Change listeners are disabled. Set { reactive: true } (or omit it) in Database options to enable .on().");
|
|
@@ -5249,7 +5352,7 @@ class _Database {
|
|
|
5249
5352
|
}
|
|
5250
5353
|
this._changeWatermark = change.id;
|
|
5251
5354
|
}
|
|
5252
|
-
this.db.
|
|
5355
|
+
this.db.query('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
|
|
5253
5356
|
}
|
|
5254
5357
|
transaction(callback) {
|
|
5255
5358
|
return this.db.transaction(callback)();
|
|
@@ -5275,6 +5378,12 @@ class _Database {
|
|
|
5275
5378
|
console.log("[satidb]", sql, params);
|
|
5276
5379
|
this.db.run(sql, ...params);
|
|
5277
5380
|
}
|
|
5381
|
+
tables() {
|
|
5382
|
+
return Object.keys(this.schemas);
|
|
5383
|
+
}
|
|
5384
|
+
columns(tableName) {
|
|
5385
|
+
return this.db.query(`PRAGMA table_info("${tableName}")`).all();
|
|
5386
|
+
}
|
|
5278
5387
|
}
|
|
5279
5388
|
var Database = _Database;
|
|
5280
5389
|
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
|
*
|
|
@@ -381,6 +394,28 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
381
394
|
return { data, total, page, perPage, pages };
|
|
382
395
|
}
|
|
383
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Count rows per group. Must call `.groupBy()` first.
|
|
399
|
+
* Returns an array of objects with the grouped column(s) and a `count` field.
|
|
400
|
+
*
|
|
401
|
+
* ```ts
|
|
402
|
+
* db.users.select('role').groupBy('role').countGrouped()
|
|
403
|
+
* // → [{ role: 'admin', count: 5 }, { role: 'member', count: 12 }]
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
countGrouped(): (Record<string, any> & { count: number })[] {
|
|
407
|
+
if (this.iqo.groupBy.length === 0) {
|
|
408
|
+
throw new Error('countGrouped() requires at least one groupBy() call');
|
|
409
|
+
}
|
|
410
|
+
const groupCols = this.iqo.groupBy.map(c => `"${c}"`).join(', ');
|
|
411
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
412
|
+
const aggSql = selectSql.replace(
|
|
413
|
+
/^SELECT .+? FROM/,
|
|
414
|
+
`SELECT ${groupCols}, COUNT(*) as count FROM`
|
|
415
|
+
);
|
|
416
|
+
return this.executor(aggSql, params, true) as any;
|
|
417
|
+
}
|
|
418
|
+
|
|
384
419
|
|
|
385
420
|
|
|
386
421
|
// ---------- Thenable (async/await support) ----------
|
package/src/context.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* can access the Database's internals without importing the full class.
|
|
6
6
|
*/
|
|
7
7
|
import type { Database as SqliteDatabase } from 'bun:sqlite';
|
|
8
|
-
import type { SchemaMap, Relationship, AugmentedEntity } from './types';
|
|
8
|
+
import type { SchemaMap, Relationship, AugmentedEntity, TableHooks } from './types';
|
|
9
9
|
|
|
10
10
|
export interface DatabaseContext {
|
|
11
11
|
/** The raw bun:sqlite Database handle. */
|
|
@@ -31,4 +31,7 @@ export interface DatabaseContext {
|
|
|
31
31
|
|
|
32
32
|
/** Whether soft deletes are enabled (deletedAt column). */
|
|
33
33
|
softDeletes: boolean;
|
|
34
|
+
|
|
35
|
+
/** Lifecycle hooks keyed by table name. */
|
|
36
|
+
hooks: Record<string, TableHooks>;
|
|
34
37
|
}
|
package/src/crud.ts
CHANGED
|
@@ -40,7 +40,16 @@ export function findMany(ctx: DatabaseContext, entityName: string, conditions: R
|
|
|
40
40
|
|
|
41
41
|
export function insert<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, data: Omit<T, 'id'>): AugmentedEntity<any> {
|
|
42
42
|
const schema = ctx.schemas[entityName]!;
|
|
43
|
-
|
|
43
|
+
let inputData = { ...data } as Record<string, any>;
|
|
44
|
+
|
|
45
|
+
// beforeInsert hook — can transform data
|
|
46
|
+
const hooks = ctx.hooks[entityName];
|
|
47
|
+
if (hooks?.beforeInsert) {
|
|
48
|
+
const result = hooks.beforeInsert(inputData);
|
|
49
|
+
if (result) inputData = result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const validatedData = asZodObject(schema).passthrough().parse(inputData);
|
|
44
53
|
const transformed = transformForStorage(validatedData);
|
|
45
54
|
|
|
46
55
|
// Auto-inject timestamps
|
|
@@ -62,12 +71,24 @@ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
62
71
|
const newEntity = getById(ctx, entityName, result.lastInsertRowid as number);
|
|
63
72
|
if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
|
|
64
73
|
|
|
74
|
+
// afterInsert hook
|
|
75
|
+
if (hooks?.afterInsert) hooks.afterInsert(newEntity);
|
|
76
|
+
|
|
65
77
|
return newEntity;
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
export function update<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, id: number, data: Partial<Omit<T, 'id'>>): AugmentedEntity<any> | null {
|
|
69
81
|
const schema = ctx.schemas[entityName]!;
|
|
70
|
-
|
|
82
|
+
let inputData = { ...data } as Record<string, any>;
|
|
83
|
+
|
|
84
|
+
// beforeUpdate hook — can transform data
|
|
85
|
+
const hooks = ctx.hooks[entityName];
|
|
86
|
+
if (hooks?.beforeUpdate) {
|
|
87
|
+
const result = hooks.beforeUpdate(inputData, id);
|
|
88
|
+
if (result) inputData = result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const validatedData = asZodObject(schema).partial().parse(inputData);
|
|
71
92
|
const transformed = transformForStorage(validatedData);
|
|
72
93
|
if (Object.keys(transformed).length === 0 && !ctx.timestamps) return getById(ctx, entityName, id);
|
|
73
94
|
|
|
@@ -81,7 +102,12 @@ export function update<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
81
102
|
if (ctx.debug) console.log('[satidb]', sql, [...Object.values(transformed), id]);
|
|
82
103
|
ctx.db.query(sql).run(...Object.values(transformed), id);
|
|
83
104
|
|
|
84
|
-
|
|
105
|
+
const updated = getById(ctx, entityName, id);
|
|
106
|
+
|
|
107
|
+
// afterUpdate hook
|
|
108
|
+
if (hooks?.afterUpdate && updated) hooks.afterUpdate(updated);
|
|
109
|
+
|
|
110
|
+
return updated;
|
|
85
111
|
}
|
|
86
112
|
|
|
87
113
|
export function updateWhere(ctx: DatabaseContext, entityName: string, data: Record<string, any>, conditions: Record<string, any>): number {
|
|
@@ -131,14 +157,36 @@ export function upsert<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
export function deleteEntity(ctx: DatabaseContext, entityName: string, id: number): void {
|
|
160
|
+
// beforeDelete hook — return false to cancel
|
|
161
|
+
const hooks = ctx.hooks[entityName];
|
|
162
|
+
if (hooks?.beforeDelete) {
|
|
163
|
+
const result = hooks.beforeDelete(id);
|
|
164
|
+
if (result === false) return;
|
|
165
|
+
}
|
|
166
|
+
|
|
134
167
|
ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
|
|
168
|
+
|
|
169
|
+
// afterDelete hook
|
|
170
|
+
if (hooks?.afterDelete) hooks.afterDelete(id);
|
|
135
171
|
}
|
|
136
172
|
|
|
137
|
-
/** Delete all rows matching the given conditions. Returns the number of rows
|
|
173
|
+
/** Delete all rows matching the given conditions. Returns the number of rows affected. */
|
|
138
174
|
export function deleteWhere(ctx: DatabaseContext, entityName: string, conditions: Record<string, any>): number {
|
|
139
175
|
const { clause, values } = ctx.buildWhereClause(conditions);
|
|
140
176
|
if (!clause) throw new Error('delete().where() requires at least one condition');
|
|
141
|
-
|
|
177
|
+
|
|
178
|
+
if (ctx.softDeletes) {
|
|
179
|
+
// Soft delete: set deletedAt instead of removing rows
|
|
180
|
+
const now = new Date().toISOString();
|
|
181
|
+
const sql = `UPDATE "${entityName}" SET "deletedAt" = ? ${clause}`;
|
|
182
|
+
if (ctx.debug) console.log('[satidb]', sql, [now, ...values]);
|
|
183
|
+
const result = ctx.db.query(sql).run(now, ...values);
|
|
184
|
+
return (result as any).changes ?? 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const sql = `DELETE FROM "${entityName}" ${clause}`;
|
|
188
|
+
if (ctx.debug) console.log('[satidb]', sql, values);
|
|
189
|
+
const result = ctx.db.query(sql).run(...values);
|
|
142
190
|
return (result as any).changes ?? 0;
|
|
143
191
|
}
|
|
144
192
|
|
|
@@ -157,11 +205,20 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
|
|
|
157
205
|
if (rows.length === 0) return [];
|
|
158
206
|
const schema = ctx.schemas[entityName]!;
|
|
159
207
|
const zodSchema = asZodObject(schema).passthrough();
|
|
208
|
+
const hooks = ctx.hooks[entityName];
|
|
160
209
|
|
|
161
210
|
const txn = ctx.db.transaction(() => {
|
|
162
211
|
const ids: number[] = [];
|
|
163
|
-
for (
|
|
164
|
-
|
|
212
|
+
for (let data of rows) {
|
|
213
|
+
let inputData = { ...data } as Record<string, any>;
|
|
214
|
+
|
|
215
|
+
// beforeInsert hook
|
|
216
|
+
if (hooks?.beforeInsert) {
|
|
217
|
+
const result = hooks.beforeInsert(inputData);
|
|
218
|
+
if (result) inputData = result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const validatedData = zodSchema.parse(inputData);
|
|
165
222
|
const transformed = transformForStorage(validatedData);
|
|
166
223
|
|
|
167
224
|
if (ctx.timestamps) {
|
|
@@ -182,5 +239,27 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
|
|
|
182
239
|
});
|
|
183
240
|
|
|
184
241
|
const ids = txn();
|
|
185
|
-
|
|
242
|
+
const entities = ids.map((id: number) => getById(ctx, entityName, id)!).filter(Boolean);
|
|
243
|
+
|
|
244
|
+
// afterInsert hooks
|
|
245
|
+
if (hooks?.afterInsert) {
|
|
246
|
+
for (const entity of entities) hooks.afterInsert(entity);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return entities;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Upsert multiple rows in a single transaction. */
|
|
253
|
+
export function upsertMany<T extends Record<string, any>>(ctx: DatabaseContext, entityName: string, rows: any[], conditions: Record<string, any> = {}): AugmentedEntity<any>[] {
|
|
254
|
+
if (rows.length === 0) return [];
|
|
255
|
+
|
|
256
|
+
const txn = ctx.db.transaction(() => {
|
|
257
|
+
const results: AugmentedEntity<any>[] = [];
|
|
258
|
+
for (const data of rows) {
|
|
259
|
+
results.push(upsert(ctx, entityName, data, conditions));
|
|
260
|
+
}
|
|
261
|
+
return results;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return txn();
|
|
186
265
|
}
|
package/src/database.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { DatabaseContext } from './context';
|
|
|
24
24
|
import { buildWhereClause } from './helpers';
|
|
25
25
|
import { attachMethods } from './entity';
|
|
26
26
|
import {
|
|
27
|
-
insert, insertMany, update, upsert, deleteEntity, createDeleteBuilder,
|
|
27
|
+
insert, insertMany, update, upsert, upsertMany, deleteEntity, createDeleteBuilder,
|
|
28
28
|
getById, getOne, findMany, updateWhere, createUpdateBuilder,
|
|
29
29
|
} from './crud';
|
|
30
30
|
|
|
@@ -86,12 +86,14 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
86
86
|
debug: this._debug,
|
|
87
87
|
timestamps: this._timestamps,
|
|
88
88
|
softDeletes: this._softDeletes,
|
|
89
|
+
hooks: options.hooks ?? {},
|
|
89
90
|
};
|
|
90
91
|
|
|
91
92
|
this.initializeTables();
|
|
92
93
|
if (this._reactive) this.initializeChangeTracking();
|
|
93
94
|
this.runMigrations();
|
|
94
95
|
if (options.indexes) this.createIndexes(options.indexes);
|
|
96
|
+
if (options.unique) this.createUniqueConstraints(options.unique);
|
|
95
97
|
|
|
96
98
|
// Create typed entity accessors (db.users, db.posts, etc.)
|
|
97
99
|
for (const entityName of Object.keys(schemas)) {
|
|
@@ -104,18 +106,31 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
104
106
|
return createUpdateBuilder(this._ctx, entityName, idOrData);
|
|
105
107
|
},
|
|
106
108
|
upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
|
|
109
|
+
upsertMany: (rows: any[], conditions?: any) => upsertMany(this._ctx, entityName, rows, conditions),
|
|
107
110
|
delete: ((id?: any) => {
|
|
108
111
|
if (typeof id === 'number') {
|
|
112
|
+
// beforeDelete hook — return false to cancel
|
|
113
|
+
const hooks = this._ctx.hooks[entityName];
|
|
114
|
+
if (hooks?.beforeDelete) {
|
|
115
|
+
const result = hooks.beforeDelete(id);
|
|
116
|
+
if (result === false) return;
|
|
117
|
+
}
|
|
109
118
|
if (this._softDeletes) {
|
|
110
119
|
// Soft delete: set deletedAt instead of removing
|
|
111
120
|
const now = new Date().toISOString();
|
|
112
|
-
this.db.
|
|
121
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
|
|
122
|
+
if (hooks?.afterDelete) hooks.afterDelete(id);
|
|
113
123
|
return;
|
|
114
124
|
}
|
|
115
125
|
return deleteEntity(this._ctx, entityName, id);
|
|
116
126
|
}
|
|
117
127
|
return createDeleteBuilder(this._ctx, entityName);
|
|
118
128
|
}) as any,
|
|
129
|
+
restore: ((id: number) => {
|
|
130
|
+
if (!this._softDeletes) throw new Error('restore() requires softDeletes: true');
|
|
131
|
+
if (this._debug) console.log('[satidb]', `UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`, [id]);
|
|
132
|
+
this.db.query(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
|
|
133
|
+
}) as any,
|
|
119
134
|
select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
|
|
120
135
|
on: (event: ChangeEvent, callback: (row: any) => void | Promise<void>) => {
|
|
121
136
|
return this._registerListener(entityName, event, callback);
|
|
@@ -228,6 +243,15 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
228
243
|
}
|
|
229
244
|
}
|
|
230
245
|
|
|
246
|
+
private createUniqueConstraints(unique: Record<string, string[][]>): void {
|
|
247
|
+
for (const [tableName, groups] of Object.entries(unique)) {
|
|
248
|
+
for (const cols of groups) {
|
|
249
|
+
const idxName = `uq_${tableName}_${cols.join('_')}`;
|
|
250
|
+
this.db.run(`CREATE UNIQUE INDEX IF NOT EXISTS "${idxName}" ON "${tableName}" (${cols.map(c => `"${c}"`).join(', ')})`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
231
255
|
// =========================================================================
|
|
232
256
|
// Change Listeners — db.table.on('insert' | 'update' | 'delete', cb)
|
|
233
257
|
// =========================================================================
|
|
@@ -306,7 +330,7 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
306
330
|
}
|
|
307
331
|
|
|
308
332
|
// Clean up consumed changes
|
|
309
|
-
this.db.
|
|
333
|
+
this.db.query('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
|
|
310
334
|
}
|
|
311
335
|
|
|
312
336
|
// =========================================================================
|
|
@@ -356,6 +380,20 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
356
380
|
if (this._debug) console.log('[satidb]', sql, params);
|
|
357
381
|
this.db.run(sql, ...params);
|
|
358
382
|
}
|
|
383
|
+
|
|
384
|
+
// =========================================================================
|
|
385
|
+
// Schema Introspection
|
|
386
|
+
// =========================================================================
|
|
387
|
+
|
|
388
|
+
/** Return the list of user-defined table names. */
|
|
389
|
+
public tables(): string[] {
|
|
390
|
+
return Object.keys(this.schemas);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Return column info for a table via PRAGMA table_info. */
|
|
394
|
+
public columns(tableName: string): { name: string; type: string; notnull: number; pk: number }[] {
|
|
395
|
+
return this.db.query(`PRAGMA table_info("${tableName}")`).all() as any[];
|
|
396
|
+
}
|
|
359
397
|
}
|
|
360
398
|
|
|
361
399
|
// =============================================================================
|
package/src/types.ts
CHANGED
|
@@ -19,8 +19,24 @@ export const asZodObject = (s: z.ZodType<any>) => s as unknown as z.ZodObject<an
|
|
|
19
19
|
/** Index definition: single column or composite columns */
|
|
20
20
|
export type IndexDef = string | string[];
|
|
21
21
|
|
|
22
|
+
/** Lifecycle hooks for a single table. */
|
|
23
|
+
export type TableHooks = {
|
|
24
|
+
beforeInsert?: (data: Record<string, any>) => Record<string, any> | void;
|
|
25
|
+
afterInsert?: (entity: Record<string, any>) => void;
|
|
26
|
+
beforeUpdate?: (data: Record<string, any>, id: number) => Record<string, any> | void;
|
|
27
|
+
afterUpdate?: (entity: Record<string, any>) => void;
|
|
28
|
+
beforeDelete?: (id: number) => false | void;
|
|
29
|
+
afterDelete?: (id: number) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
22
32
|
export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
23
33
|
indexes?: Record<string, IndexDef[]>;
|
|
34
|
+
/**
|
|
35
|
+
* Unique constraints per table. Each entry is an array of column groups.
|
|
36
|
+
* Single column: `{ users: [['email']] }` → UNIQUE INDEX.
|
|
37
|
+
* Compound: `{ users: [['email'], ['name', 'org_id']] }` → two UNIQUE indexes.
|
|
38
|
+
*/
|
|
39
|
+
unique?: Record<string, string[][]>;
|
|
24
40
|
/**
|
|
25
41
|
* Declare relationships between tables.
|
|
26
42
|
*
|
|
@@ -59,6 +75,17 @@ export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
|
59
75
|
* Default: `false`.
|
|
60
76
|
*/
|
|
61
77
|
debug?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Lifecycle hooks per table. Each hook receives data and can transform it.
|
|
80
|
+
*
|
|
81
|
+
* - `beforeInsert(data)` — called before insert, return modified data or void
|
|
82
|
+
* - `afterInsert(entity)` — called after insert with the persisted entity
|
|
83
|
+
* - `beforeUpdate(data, id)` — called before update, return modified data or void
|
|
84
|
+
* - `afterUpdate(entity)` — called after update with the updated entity
|
|
85
|
+
* - `beforeDelete(id)` — called before delete, return false to cancel
|
|
86
|
+
* - `afterDelete(id)` — called after delete
|
|
87
|
+
*/
|
|
88
|
+
hooks?: Record<string, TableHooks>;
|
|
62
89
|
};
|
|
63
90
|
|
|
64
91
|
export type Relationship = {
|
|
@@ -178,7 +205,9 @@ export type NavEntityAccessor<
|
|
|
178
205
|
update: ((id: number, data: Partial<Omit<z.input<S[Table & keyof S]>, 'id'>>) => NavEntity<S, R, Table> | null)
|
|
179
206
|
& ((data: Partial<Omit<z.input<S[Table & keyof S]>, 'id'>>) => UpdateBuilder<NavEntity<S, R, Table>>);
|
|
180
207
|
upsert: (conditions?: Partial<z.infer<S[Table & keyof S]>>, data?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>;
|
|
208
|
+
upsertMany: (rows: Partial<z.infer<S[Table & keyof S]>>[], conditions?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>[];
|
|
181
209
|
delete: ((id: number) => void) & (() => DeleteBuilder<NavEntity<S, R, Table>>);
|
|
210
|
+
restore: (id: number) => void;
|
|
182
211
|
select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
|
|
183
212
|
on: ((event: 'insert' | 'update', callback: (row: NavEntity<S, R, Table>) => void | Promise<void>) => () => void) &
|
|
184
213
|
((event: 'delete', callback: (row: { id: number }) => void | Promise<void>) => () => void);
|
|
@@ -208,7 +237,10 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
|
|
|
208
237
|
insertMany: (rows: EntityData<S>[]) => AugmentedEntity<S>[];
|
|
209
238
|
update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
|
|
210
239
|
upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
|
|
240
|
+
upsertMany: (rows: Partial<InferSchema<S>>[], conditions?: Partial<InferSchema<S>>) => AugmentedEntity<S>[];
|
|
211
241
|
delete: ((id: number) => void) & (() => DeleteBuilder<AugmentedEntity<S>>);
|
|
242
|
+
/** Undo a soft delete by setting deletedAt = null. Requires softDeletes. */
|
|
243
|
+
restore: (id: number) => void;
|
|
212
244
|
select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
|
|
213
245
|
on: ((event: 'insert' | 'update', callback: (row: AugmentedEntity<S>) => void | Promise<void>) => () => void) &
|
|
214
246
|
((event: 'delete', callback: (row: { id: number }) => void | Promise<void>) => () => void);
|