sqlite-zod-orm 3.0.0 → 3.2.1
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 +329 -207
- package/dist/index.js +5014 -0
- package/package.json +13 -13
- package/src/build.ts +8 -10
- package/src/database.ts +496 -0
- package/src/index.ts +24 -0
- package/src/proxy-query.ts +55 -51
- package/src/query-builder.ts +152 -7
- package/src/schema.ts +122 -0
- package/src/types.ts +195 -0
- package/src/satidb.ts +0 -1153
package/README.md
CHANGED
|
@@ -1,314 +1,436 @@
|
|
|
1
|
-
#
|
|
1
|
+
# sqlite-zod-orm
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Type-safe SQLite ORM for Bun. Define schemas with Zod, get a fully-typed database with automatic relationships, lazy navigation, and zero SQL.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
- **📊 Type-Safe Schemas**: Powered by Zod for runtime validation
|
|
9
|
-
- **🔗 Automatic Relationships**: Belongs-to, one-to-many, many-to-many support
|
|
10
|
-
- **📺 Event Subscriptions**: Listen to insert/update/delete events
|
|
11
|
-
- **🏗️ Explicit Junction Tables**: Full control over many-to-many relationships
|
|
12
|
-
- **🚀 Fluent API**: Intuitive, chainable operations
|
|
13
|
-
- **⚡ Zero-Config**: Works with Bun SQLite out of the box
|
|
5
|
+
```bash
|
|
6
|
+
bun add sqlite-zod-orm
|
|
7
|
+
```
|
|
14
8
|
|
|
15
|
-
##
|
|
9
|
+
## Quick Start
|
|
16
10
|
|
|
17
11
|
```typescript
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
import { Database, z } from 'sqlite-zod-orm';
|
|
13
|
+
|
|
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
|
+
}),
|
|
24
20
|
});
|
|
25
21
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
const
|
|
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:', {
|
|
34
38
|
authors: AuthorSchema,
|
|
35
|
-
|
|
39
|
+
books: BookSchema,
|
|
40
|
+
}, {
|
|
41
|
+
relations: {
|
|
42
|
+
books: { author_id: 'authors' },
|
|
43
|
+
},
|
|
36
44
|
});
|
|
45
|
+
```
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
const author = db.authors.insert({ name: 'Jane Doe' });
|
|
40
|
-
const post = author.posts.push({ title: 'Hello World', content: '...' });
|
|
47
|
+
`books: { author_id: 'authors' }` tells the ORM that `books.author_id` is a foreign key referencing `authors.id`. The ORM automatically:
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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()`.
|
|
45
55
|
|
|
46
|
-
|
|
56
|
+
---
|
|
47
57
|
|
|
48
|
-
|
|
58
|
+
## Querying — `select()` is the only path
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
All queries go through `select()`:
|
|
51
61
|
|
|
52
62
|
```typescript
|
|
53
|
-
//
|
|
54
|
-
const
|
|
55
|
-
name: z.string(),
|
|
56
|
-
posts: z.lazy(() => z.array(PostSchema)).optional(), // one-to-many
|
|
57
|
-
});
|
|
63
|
+
// Single row
|
|
64
|
+
const user = db.users.select().where({ id: 1 }).get();
|
|
58
65
|
|
|
59
|
-
//
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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();
|
|
64
74
|
```
|
|
65
75
|
|
|
66
|
-
###
|
|
76
|
+
### Operators
|
|
67
77
|
|
|
68
|
-
|
|
78
|
+
`$gt` `$gte` `$lt` `$lte` `$ne` `$in`
|
|
69
79
|
|
|
70
80
|
```typescript
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
priority: z.number().default(0),
|
|
78
|
-
});
|
|
81
|
+
const topScorers = db.users.select()
|
|
82
|
+
.where({ score: { $gt: 50 } })
|
|
83
|
+
.orderBy('score', 'desc')
|
|
84
|
+
.limit(10)
|
|
85
|
+
.all();
|
|
86
|
+
```
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
};
|
|
85
122
|
});
|
|
86
123
|
```
|
|
87
124
|
|
|
88
|
-
|
|
89
|
-
- Full control over additional fields (timestamps, metadata, etc.)
|
|
90
|
-
- Junction tables are queryable entities themselves
|
|
91
|
-
- More flexible than auto-generated tables
|
|
92
|
-
- Clearer data modeling
|
|
125
|
+
---
|
|
93
126
|
|
|
94
|
-
##
|
|
127
|
+
## Lazy Navigation
|
|
95
128
|
|
|
96
|
-
|
|
129
|
+
Relationship fields become callable methods on entities. The method name is the FK column with `_id` stripped:
|
|
97
130
|
|
|
98
131
|
```typescript
|
|
99
|
-
//
|
|
100
|
-
const
|
|
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', ... }
|
|
101
135
|
|
|
102
|
-
//
|
|
103
|
-
const
|
|
136
|
+
// one-to-many: author → books
|
|
137
|
+
const books = tolstoy.books(); // → [{ title: 'War and Peace' }, ...]
|
|
104
138
|
|
|
105
|
-
//
|
|
106
|
-
const
|
|
107
|
-
|
|
139
|
+
// Chain
|
|
140
|
+
const allByAuthor = book.author().books();
|
|
141
|
+
```
|
|
108
142
|
|
|
109
|
-
|
|
110
|
-
|
|
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' });
|
|
165
|
+
|
|
166
|
+
// Fluent update with WHERE
|
|
167
|
+
db.users.update({ role: 'member' }).where({ role: 'guest' }).exec();
|
|
111
168
|
|
|
112
169
|
// Upsert
|
|
113
|
-
|
|
170
|
+
db.users.upsert({ name: 'Alice' }, { name: 'Alice', role: 'admin' });
|
|
114
171
|
|
|
115
172
|
// Delete
|
|
116
|
-
db.users.delete(
|
|
173
|
+
db.users.delete(1);
|
|
117
174
|
```
|
|
118
175
|
|
|
119
|
-
###
|
|
176
|
+
### Auto-Persist Proxy
|
|
177
|
+
|
|
178
|
+
Setting a property on an entity auto-updates the DB:
|
|
120
179
|
|
|
121
180
|
```typescript
|
|
122
|
-
|
|
123
|
-
|
|
181
|
+
const alice = db.users.select().where({ id: 1 }).get()!;
|
|
182
|
+
alice.score = 200; // → UPDATE users SET score = 200 WHERE id = 1
|
|
183
|
+
```
|
|
124
184
|
|
|
125
|
-
|
|
126
|
-
const authorPosts = author.posts(); // All posts
|
|
127
|
-
const recentPosts = author.posts({ $limit: 5, $sortBy: 'createdAt:desc' });
|
|
185
|
+
---
|
|
128
186
|
|
|
129
|
-
|
|
130
|
-
const postAuthor = post.author();
|
|
187
|
+
## Schema Validation
|
|
131
188
|
|
|
132
|
-
|
|
133
|
-
|
|
189
|
+
Zod validates every insert and update at runtime:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
db.users.insert({ name: '', email: 'bad', age: -1 }); // throws ZodError
|
|
134
193
|
```
|
|
135
194
|
|
|
136
|
-
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Indexes
|
|
137
198
|
|
|
138
199
|
```typescript
|
|
139
|
-
const
|
|
200
|
+
const db = new Database(':memory:', schemas, {
|
|
201
|
+
indexes: {
|
|
202
|
+
users: ['email', ['name', 'role']],
|
|
203
|
+
books: ['author_id', 'year'],
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
```
|
|
140
207
|
|
|
141
|
-
|
|
142
|
-
user.name = 'New Name';
|
|
143
|
-
user.email = 'new@email.com';
|
|
144
|
-
user.active = false;
|
|
208
|
+
---
|
|
145
209
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
210
|
+
## Reactivity — Three Ways to React to Changes
|
|
211
|
+
|
|
212
|
+
sqlite-zod-orm provides three reactivity mechanisms for different use cases:
|
|
149
213
|
|
|
150
|
-
|
|
214
|
+
| System | Detects | Scope | Overhead | Best for |
|
|
215
|
+
|---|---|---|---|---|
|
|
216
|
+
| **CRUD Events** | insert, update, delete | In-process, per table | Zero (synchronous) | Side effects, caching, logs |
|
|
217
|
+
| **Smart Polling** | insert, delete, update* | Any query result | Lightweight fingerprint check | Live UI, dashboards |
|
|
218
|
+
| **Change Tracking** | insert, update, delete | Per table or global | Trigger-based WAL | Cross-process sync, audit |
|
|
219
|
+
|
|
220
|
+
\* Smart polling detects UPDATEs automatically when `changeTracking` is enabled. Without it, only inserts and deletes are detected.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
### 1. CRUD Events — `db.table.subscribe(event, callback)`
|
|
225
|
+
|
|
226
|
+
Synchronous callbacks fired immediately after each CRUD operation. Zero overhead; the callback runs inline.
|
|
151
227
|
|
|
152
228
|
```typescript
|
|
153
|
-
// Listen
|
|
229
|
+
// Listen for new users
|
|
154
230
|
db.users.subscribe('insert', (user) => {
|
|
155
|
-
console.log(
|
|
231
|
+
console.log('New user:', user.name); // fires on every db.users.insert(...)
|
|
156
232
|
});
|
|
157
233
|
|
|
158
|
-
|
|
159
|
-
|
|
234
|
+
// Listen for updates
|
|
235
|
+
db.users.subscribe('update', (user) => {
|
|
236
|
+
console.log('Updated:', user.name, '→', user.role);
|
|
160
237
|
});
|
|
161
238
|
|
|
239
|
+
// Listen for deletes
|
|
162
240
|
db.users.subscribe('delete', (user) => {
|
|
163
|
-
console.log(
|
|
241
|
+
console.log('Deleted:', user.name);
|
|
164
242
|
});
|
|
243
|
+
|
|
244
|
+
// Stop listening
|
|
245
|
+
db.users.unsubscribe('update', myCallback);
|
|
165
246
|
```
|
|
166
247
|
|
|
167
|
-
|
|
248
|
+
**Use cases:**
|
|
249
|
+
- Invalidating a cache after writes
|
|
250
|
+
- Logging / audit trail
|
|
251
|
+
- Sending notifications
|
|
252
|
+
- Keeping derived data in sync (e.g., a counter table)
|
|
253
|
+
|
|
254
|
+
The database also extends Node's `EventEmitter`, so you can use `db.on()`:
|
|
168
255
|
|
|
169
256
|
```typescript
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const post1 = author.posts.push({ title: 'Post 1' });
|
|
173
|
-
const post2 = author.posts.push({ title: 'Post 2' });
|
|
174
|
-
return { author, posts: [post1, post2] };
|
|
257
|
+
db.on('insert', (tableName, entity) => {
|
|
258
|
+
console.log(`New row in ${tableName}:`, entity.id);
|
|
175
259
|
});
|
|
176
260
|
```
|
|
177
261
|
|
|
178
|
-
|
|
262
|
+
---
|
|
179
263
|
|
|
180
|
-
###
|
|
264
|
+
### 2. Smart Polling — `select().subscribe(callback, options)`
|
|
181
265
|
|
|
182
|
-
|
|
183
|
-
const PersonalitySchema = z.object({
|
|
184
|
-
name: z.string(),
|
|
185
|
-
chats: z.lazy(() => z.array(ChatSchema)).optional(),
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
const ChatSchema = z.object({
|
|
189
|
-
title: z.string(),
|
|
190
|
-
personality: z.lazy(() => PersonalitySchema).optional(),
|
|
191
|
-
});
|
|
266
|
+
Query-level polling that watches *any query result* for changes. Instead of re-fetching all rows every tick, it runs a **lightweight fingerprint query** (`SELECT COUNT(*), MAX(id)`) with the same WHERE clause. The full query only re-executes when the fingerprint changes.
|
|
192
267
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
268
|
+
```typescript
|
|
269
|
+
// Watch for admin list changes, poll every second
|
|
270
|
+
const unsub = db.users.select()
|
|
271
|
+
.where({ role: 'admin' })
|
|
272
|
+
.orderBy('name', 'asc')
|
|
273
|
+
.subscribe((admins) => {
|
|
274
|
+
console.log('Admin list:', admins.map(a => a.name));
|
|
275
|
+
}, { interval: 1000 });
|
|
276
|
+
|
|
277
|
+
// Stop watching
|
|
278
|
+
unsub();
|
|
197
279
|
```
|
|
198
280
|
|
|
199
|
-
|
|
281
|
+
**Options:**
|
|
200
282
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
likes: z.lazy(() => z.array(LikeSchema)).optional(),
|
|
206
|
-
});
|
|
283
|
+
| Option | Default | Description |
|
|
284
|
+
|---|---|---|
|
|
285
|
+
| `interval` | `500` | Polling interval in milliseconds |
|
|
286
|
+
| `immediate` | `true` | Whether to fire the callback immediately with the current result |
|
|
207
287
|
|
|
208
|
-
|
|
209
|
-
name: z.string(),
|
|
210
|
-
likes: z.lazy(() => z.array(LikeSchema)).optional(),
|
|
211
|
-
});
|
|
288
|
+
**How the fingerprint works:**
|
|
212
289
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
290
|
+
```
|
|
291
|
+
┌─────────────────────────────────────┐
|
|
292
|
+
│ Every {interval}ms: │
|
|
293
|
+
│ │
|
|
294
|
+
│ 1. Run: SELECT COUNT(*), MAX(id) │
|
|
295
|
+
│ FROM users WHERE role = 'admin' │
|
|
296
|
+
│ │ ← fast, no data transfer
|
|
297
|
+
│ 2. Compare fingerprint to last │
|
|
298
|
+
│ │
|
|
299
|
+
│ 3. If changed → re-run full query │ ← only when needed
|
|
300
|
+
│ and call your callback │
|
|
301
|
+
└─────────────────────────────────────┘
|
|
302
|
+
```
|
|
220
303
|
|
|
221
|
-
|
|
222
|
-
const user = db.users.insert({ name: 'Alice' });
|
|
223
|
-
const product = db.products.insert({ name: 'Laptop' });
|
|
304
|
+
**What it detects:**
|
|
224
305
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
306
|
+
| Operation | Without `changeTracking` | With `changeTracking` |
|
|
307
|
+
|---|---|---|
|
|
308
|
+
| INSERT | ✅ (MAX(id) increases) | ✅ |
|
|
309
|
+
| DELETE | ✅ (COUNT changes) | ✅ |
|
|
310
|
+
| UPDATE | ❌ (fingerprint unchanged) | ✅ (change sequence bumps) |
|
|
230
311
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const productLikers = product.likes().map(l => l.user()); // Users who like laptop
|
|
312
|
+
> **Tip:** Enable `changeTracking: true` if you need `.subscribe()` to react to UPDATEs.
|
|
313
|
+
> The overhead is minimal — one trigger per table that appends to a `_changes` log.
|
|
234
314
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
315
|
+
**Use cases:**
|
|
316
|
+
- Live dashboards (poll every 1-5s)
|
|
317
|
+
- Real-time chat message lists
|
|
318
|
+
- Auto-refreshing data tables
|
|
319
|
+
- Watching filtered subsets of data
|
|
238
320
|
|
|
239
|
-
|
|
321
|
+
---
|
|
240
322
|
|
|
241
|
-
###
|
|
323
|
+
### 3. Change Tracking — `changeTracking: true`
|
|
242
324
|
|
|
243
|
-
|
|
325
|
+
A trigger-based WAL (write-ahead log) that records every INSERT, UPDATE, and DELETE to a `_changes` table. This is the foundation for cross-process sync and audit trails.
|
|
244
326
|
|
|
245
327
|
```typescript
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
name: 'Custom Entity'
|
|
328
|
+
const db = new Database(':memory:', schemas, {
|
|
329
|
+
changeTracking: true,
|
|
249
330
|
});
|
|
250
331
|
```
|
|
251
332
|
|
|
252
|
-
|
|
333
|
+
When enabled, the ORM creates:
|
|
334
|
+
- A `_changes` table: `(id, table_name, row_id, action, changed_at)`
|
|
335
|
+
- An index on `(table_name, id)` for fast lookups
|
|
336
|
+
- Triggers on each table for INSERT, UPDATE, and DELETE
|
|
337
|
+
|
|
338
|
+
**Reading changes:**
|
|
253
339
|
|
|
254
340
|
```typescript
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
$offset: 20,
|
|
259
|
-
$sortBy: 'createdAt:desc'
|
|
260
|
-
});
|
|
261
|
-
```
|
|
341
|
+
// Get the current sequence number (latest change ID)
|
|
342
|
+
const seq = db.getChangeSeq(); // global
|
|
343
|
+
const seq = db.getChangeSeq('users'); // per table
|
|
262
344
|
|
|
263
|
-
|
|
345
|
+
// Get all changes since a sequence number
|
|
346
|
+
const changes = db.getChangesSince(0); // all changes ever
|
|
347
|
+
const changes = db.getChangesSince(seq); // new changes since seq
|
|
264
348
|
|
|
265
|
-
|
|
349
|
+
// Each change looks like:
|
|
350
|
+
// { id: 42, table_name: 'users', row_id: 7, action: 'UPDATE', changed_at: '2024-...' }
|
|
351
|
+
```
|
|
266
352
|
|
|
267
|
-
|
|
268
|
-
const UserSchema = z.object({
|
|
269
|
-
name: z.string().min(2),
|
|
270
|
-
email: z.string().email(),
|
|
271
|
-
age: z.number().optional(),
|
|
272
|
-
});
|
|
353
|
+
**Polling for changes (external sync pattern):**
|
|
273
354
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
355
|
+
```typescript
|
|
356
|
+
let lastSeq = 0;
|
|
357
|
+
|
|
358
|
+
setInterval(() => {
|
|
359
|
+
const changes = db.getChangesSince(lastSeq);
|
|
360
|
+
if (changes.length > 0) {
|
|
361
|
+
lastSeq = changes[changes.length - 1].id;
|
|
362
|
+
for (const change of changes) {
|
|
363
|
+
console.log(`${change.action} on ${change.table_name} row ${change.row_id}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}, 1000);
|
|
279
367
|
```
|
|
280
368
|
|
|
281
|
-
|
|
369
|
+
**Use cases:**
|
|
370
|
+
- Syncing between processes (e.g., worker → main thread)
|
|
371
|
+
- Building an event-sourced system
|
|
372
|
+
- Replication to another database
|
|
373
|
+
- Audit logging with timestamps
|
|
374
|
+
- Powering smart polling UPDATE detection
|
|
282
375
|
|
|
283
|
-
|
|
284
|
-
2. **Leverage Reactive Editing**: Change properties directly instead of calling update methods
|
|
285
|
-
3. **Subscribe to Events**: Use subscriptions for real-time updates and side effects
|
|
286
|
-
4. **Validate with Zod**: Define comprehensive schemas with proper validation rules
|
|
287
|
-
5. **Use Transactions**: Wrap related operations in transactions for data consistency
|
|
376
|
+
---
|
|
288
377
|
|
|
289
|
-
|
|
378
|
+
### Choosing the Right System
|
|
290
379
|
|
|
291
|
-
```typescript
|
|
292
|
-
// Traditional ORM
|
|
293
|
-
const user = await User.findById(id);
|
|
294
|
-
user.name = 'New Name';
|
|
295
|
-
await user.save(); // Explicit save
|
|
296
|
-
|
|
297
|
-
// SatiDB
|
|
298
|
-
const user = db.users.get(id);
|
|
299
|
-
user.name = 'New Name'; // Automatically saved!
|
|
300
380
|
```
|
|
381
|
+
Do you need to react to your own writes?
|
|
382
|
+
→ CRUD Events (db.table.subscribe)
|
|
301
383
|
|
|
302
|
-
|
|
384
|
+
Do you need to watch a query result set?
|
|
385
|
+
→ Smart Polling (select().subscribe)
|
|
386
|
+
→ Enable changeTracking if you need UPDATE detection
|
|
303
387
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
388
|
+
Do you need cross-process sync or audit?
|
|
389
|
+
→ Change Tracking (changeTracking: true + getChangesSince)
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
All three systems can be used together. `changeTracking` enhances smart polling automatically — no code changes needed.
|
|
307
393
|
|
|
308
|
-
|
|
394
|
+
---
|
|
309
395
|
|
|
310
|
-
|
|
396
|
+
## Examples & Tests
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
bun examples/example.ts # comprehensive demo
|
|
400
|
+
bun test # 91 tests
|
|
401
|
+
```
|
|
311
402
|
|
|
312
403
|
---
|
|
313
404
|
|
|
314
|
-
|
|
405
|
+
## API Reference
|
|
406
|
+
|
|
407
|
+
| Method | Description |
|
|
408
|
+
|---|---|
|
|
409
|
+
| `new Database(path, schemas, options?)` | Create database with Zod schemas |
|
|
410
|
+
| **Querying** | |
|
|
411
|
+
| `db.table.select(...cols?).where(filter).get()` | Single row |
|
|
412
|
+
| `db.table.select(...cols?).where(filter).all()` | Array of rows |
|
|
413
|
+
| `db.table.select().count()` | Count rows |
|
|
414
|
+
| `db.table.select().join(db.other, cols?).all()` | Fluent join (auto FK) |
|
|
415
|
+
| `db.query(c => { ... })` | Proxy callback (SQL-like JOINs) |
|
|
416
|
+
| **Writing** | |
|
|
417
|
+
| `db.table.insert(data)` | Insert with validation |
|
|
418
|
+
| `db.table.update(id, data)` | Update by ID |
|
|
419
|
+
| `db.table.update(data).where(filter).exec()` | Fluent update |
|
|
420
|
+
| `db.table.upsert(match, data)` | Insert or update |
|
|
421
|
+
| `db.table.delete(id)` | Delete by ID |
|
|
422
|
+
| **Navigation** | |
|
|
423
|
+
| `entity.navMethod()` | Lazy navigation (FK name minus `_id`) |
|
|
424
|
+
| `entity.update(data)` | Update entity in-place |
|
|
425
|
+
| `entity.delete()` | Delete entity |
|
|
426
|
+
| **Reactivity** | |
|
|
427
|
+
| `db.table.subscribe(event, cb)` | CRUD events: `'insert'`, `'update'`, `'delete'` |
|
|
428
|
+
| `db.table.unsubscribe(event, cb)` | Remove CRUD event listener |
|
|
429
|
+
| `db.on(event, cb)` | EventEmitter: listen across all tables |
|
|
430
|
+
| `select().subscribe(cb, opts?)` | Smart polling (fingerprint-based) |
|
|
431
|
+
| `db.getChangeSeq(table?)` | Current change sequence number |
|
|
432
|
+
| `db.getChangesSince(seq, table?)` | Changes since sequence (change tracking) |
|
|
433
|
+
|
|
434
|
+
## License
|
|
435
|
+
|
|
436
|
+
MIT
|