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 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
-
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
- const validatedData = asZodObject(schema).passthrough().parse(data);
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
- const validatedData = asZodObject(schema).partial().parse(data);
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
- return getById(ctx, entityName, id);
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
- const result = ctx.db.query(`DELETE FROM "${entityName}" ${clause}`).run(...values);
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 (const data of rows) {
5000
- const validatedData = zodSchema.parse(data);
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
- return ids.map((id) => getById(ctx, entityName, id)).filter(Boolean);
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.run(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`, now, id);
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.run('DELETE FROM "_changes" WHERE id <= ?', this._changeWatermark);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.11.0",
3
+ "version": "3.13.0",
4
4
  "description": "Type-safe SQLite ORM for Bun — Zod schemas, fluent queries, auto relationships, zero SQL",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
- const validatedData = asZodObject(schema).passthrough().parse(data);
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
- const validatedData = asZodObject(schema).partial().parse(data);
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
- return getById(ctx, entityName, id);
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 deleted. */
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
- const result = ctx.db.query(`DELETE FROM "${entityName}" ${clause}`).run(...values);
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 (const data of rows) {
164
- const validatedData = zodSchema.parse(data);
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
- return ids.map((id: number) => getById(ctx, entityName, id)!).filter(Boolean);
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.run(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`, now, id);
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.run('DELETE FROM "_changes" WHERE id <= ?', this._changeWatermark);
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);