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 CHANGED
@@ -1,314 +1,436 @@
1
- # SatiDB 🗄️
1
+ # sqlite-zod-orm
2
2
 
3
- A modern, type-safe SQLite wrapper with **reactive editing**, **automatic relationships**, and **fluent APIs**. Built for TypeScript/JavaScript with Zod schema validation.
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
- ## ✨ Key Features
6
-
7
- - **🔄 Reactive Editing**: Change properties directly, auto-persists to database
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
- ## 🚀 Quick Start
9
+ ## Quick Start
16
10
 
17
11
  ```typescript
18
- import { MyDatabase, z } from './satidb';
19
-
20
- // Define schemas with relationships
21
- const AuthorSchema = z.object({
22
- name: z.string(),
23
- posts: z.lazy(() => z.array(PostSchema)).optional(),
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 PostSchema = z.object({
27
- title: z.string(),
28
- content: z.string(),
29
- author: z.lazy(() => AuthorSchema).optional(),
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
- // Create database
33
- const db = new MyDatabase(':memory:', {
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
- posts: PostSchema,
39
+ books: BookSchema,
40
+ }, {
41
+ relations: {
42
+ books: { author_id: 'authors' },
43
+ },
36
44
  });
45
+ ```
37
46
 
38
- // Use it!
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
- // Reactive editing - just change properties!
43
- post.title = 'Hello TypeScript'; // Automatically saved to DB
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
- ## 📖 Core Concepts
56
+ ---
47
57
 
48
- ### Schemas & Relationships
58
+ ## Querying `select()` is the only path
49
59
 
50
- Define entities using Zod schemas with lazy relationships:
60
+ All queries go through `select()`:
51
61
 
52
62
  ```typescript
53
- // One-to-many: Author has many Posts
54
- const AuthorSchema = z.object({
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
- // Belongs-to: Post belongs to Author
60
- const PostSchema = z.object({
61
- title: z.string(),
62
- author: z.lazy(() => AuthorSchema).optional(), // belongs-to
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
- ### Explicit Junction Tables
76
+ ### Operators
67
77
 
68
- For many-to-many relationships, define explicit junction entities with additional fields:
78
+ `$gt` `$gte` `$lt` `$lte` `$ne` `$in`
69
79
 
70
80
  ```typescript
71
- // Many-to-many with rich junction table
72
- const PostTagSchema = z.object({
73
- post: z.lazy(() => PostSchema).optional(),
74
- tag: z.lazy(() => TagSchema).optional(),
75
- appliedAt: z.date().default(() => new Date()),
76
- appliedBy: z.string(),
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
- // Usage
81
- const postTag = post.postTags.push({
82
- tagId: tag.id,
83
- appliedBy: 'editor',
84
- priority: 5
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
- **Why explicit junction tables?**
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
- ## 🎯 API Reference
127
+ ## Lazy Navigation
95
128
 
96
- ### Creating & Querying
129
+ Relationship fields become callable methods on entities. The method name is the FK column with `_id` stripped:
97
130
 
98
131
  ```typescript
99
- // Insert new entities
100
- const user = db.users.insert({ name: 'Alice', email: 'alice@example.com' });
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
- // Find multiple with conditions
103
- const activeUsers = db.users.find({ active: true, $limit: 10 });
136
+ // one-to-many: author books
137
+ const books = tolstoy.books(); // → [{ title: 'War and Peace' }, ...]
104
138
 
105
- // Get single entity
106
- const user = db.users.get({ email: 'alice@example.com' });
107
- const userById = db.users.get('user-id-123');
139
+ // Chain
140
+ const allByAuthor = book.author().books();
141
+ ```
108
142
 
109
- // Update
110
- const updated = db.users.update('user-id', { name: 'Alice Smith' });
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
- const user = db.users.upsert({ email: 'alice@example.com', name: 'Alice' });
170
+ db.users.upsert({ name: 'Alice' }, { name: 'Alice', role: 'admin' });
114
171
 
115
172
  // Delete
116
- db.users.delete('user-id');
173
+ db.users.delete(1);
117
174
  ```
118
175
 
119
- ### Relationships
176
+ ### Auto-Persist Proxy
177
+
178
+ Setting a property on an entity auto-updates the DB:
120
179
 
121
180
  ```typescript
122
- // One-to-many: Add related entities
123
- const post = author.posts.push({ title: 'New Post', content: '...' });
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
- // Query related entities
126
- const authorPosts = author.posts(); // All posts
127
- const recentPosts = author.posts({ $limit: 5, $sortBy: 'createdAt:desc' });
185
+ ---
128
186
 
129
- // Belongs-to: Navigate relationships
130
- const postAuthor = post.author();
187
+ ## Schema Validation
131
188
 
132
- // Get specific related entity
133
- const specificPost = author.post('post-id-123');
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
- ### Reactive Editing
195
+ ---
196
+
197
+ ## Indexes
137
198
 
138
199
  ```typescript
139
- const user = db.users.get('user-id');
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
- // Just change properties - automatically persists!
142
- user.name = 'New Name';
143
- user.email = 'new@email.com';
144
- user.active = false;
208
+ ---
145
209
 
146
- // No need to call .save() or .update()
147
- // Changes are immediately persisted to database
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
- ### Event Subscriptions
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 to database events
229
+ // Listen for new users
154
230
  db.users.subscribe('insert', (user) => {
155
- console.log(`New user: ${user.name}`);
231
+ console.log('New user:', user.name); // fires on every db.users.insert(...)
156
232
  });
157
233
 
158
- db.posts.subscribe('update', (post) => {
159
- console.log(`Post updated: ${post.title}`);
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(`User deleted: ${user.name}`);
241
+ console.log('Deleted:', user.name);
164
242
  });
243
+
244
+ // Stop listening
245
+ db.users.unsubscribe('update', myCallback);
165
246
  ```
166
247
 
167
- ### Transactions
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
- const result = db.transaction(() => {
171
- const author = db.authors.insert({ name: 'Jane' });
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
- ## 🔗 Relationship Types
262
+ ---
179
263
 
180
- ### One-to-Many
264
+ ### 2. Smart Polling — `select().subscribe(callback, options)`
181
265
 
182
- ```typescript
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
- // Usage
194
- const personality = db.personalities.insert({ name: 'Assistant' });
195
- const chat = personality.chats.push({ title: 'Help Session' });
196
- const chatPersonality = chat.personality(); // Navigate back
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
- ### Many-to-Many (Explicit Junction)
281
+ **Options:**
200
282
 
201
- ```typescript
202
- // Define all three entities
203
- const UserSchema = z.object({
204
- name: z.string(),
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
- const ProductSchema = z.object({
209
- name: z.string(),
210
- likes: z.lazy(() => z.array(LikeSchema)).optional(),
211
- });
288
+ **How the fingerprint works:**
212
289
 
213
- // Junction table with additional fields
214
- const LikeSchema = z.object({
215
- user: z.lazy(() => UserSchema).optional(),
216
- product: z.lazy(() => ProductSchema).optional(),
217
- rating: z.number().min(1).max(5),
218
- likedAt: z.date().default(() => new Date()),
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
- // Usage
222
- const user = db.users.insert({ name: 'Alice' });
223
- const product = db.products.insert({ name: 'Laptop' });
304
+ **What it detects:**
224
305
 
225
- // Create relationship with metadata
226
- const like = user.likes.push({
227
- productId: product.id,
228
- rating: 5
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
- // Query from both sides
232
- const userLikes = user.likes().map(l => l.product()); // Products Alice likes
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
- // Junction table is a full entity
236
- like.rating = 4; // Reactive update on junction table!
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
- ## 📝 Advanced Usage
321
+ ---
240
322
 
241
- ### Custom ID Generation
323
+ ### 3. Change Tracking — `changeTracking: true`
242
324
 
243
- IDs are automatically generated based on entity data hash. For custom IDs:
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 entity = db.entities.insert({
247
- id: 'custom-id-123', // Explicit ID
248
- name: 'Custom Entity'
328
+ const db = new Database(':memory:', schemas, {
329
+ changeTracking: true,
249
330
  });
250
331
  ```
251
332
 
252
- ### Query Options
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
- const results = db.posts.find({
256
- published: true,
257
- $limit: 10,
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
- ### Schema Validation
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
- All data is validated against Zod schemas:
349
+ // Each change looks like:
350
+ // { id: 42, table_name: 'users', row_id: 7, action: 'UPDATE', changed_at: '2024-...' }
351
+ ```
266
352
 
267
- ```typescript
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
- // Throws validation error if invalid
275
- const user = db.users.insert({
276
- name: 'A', // Too short!
277
- email: 'invalid-email' // Invalid format!
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
- ## 🎯 Best Practices
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
- 1. **Use Explicit Junction Tables**: Define junction entities with additional fields rather than relying on auto-generated tables
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
- ## 🔄 Migration from Traditional ORMs
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
- ## 🛠️ Requirements
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
- - Bun runtime with SQLite support
305
- - TypeScript (recommended) or JavaScript
306
- - Zod for schema validation
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
- ## 📄 License
394
+ ---
309
395
 
310
- MIT License - feel free to use in your projects!
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
- **SatiDB** - Reactive, type-safe database operations made simple. 🚀
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