sqlite-zod-orm 3.2.1 → 3.3.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 +23 -158
- package/dist/index.js +29 -88
- package/package.json +1 -1
- package/src/database.ts +32 -105
- package/src/query-builder.ts +10 -10
- package/src/types.ts +0 -5
package/README.md
CHANGED
|
@@ -207,66 +207,11 @@ const db = new Database(':memory:', schemas, {
|
|
|
207
207
|
|
|
208
208
|
---
|
|
209
209
|
|
|
210
|
-
## Reactivity —
|
|
210
|
+
## Reactivity — `select().subscribe()`
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
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.
|
|
212
|
+
One API to watch any query for changes. Detects **all** mutations (inserts, updates, deletes) with zero disk overhead.
|
|
227
213
|
|
|
228
214
|
```typescript
|
|
229
|
-
// Listen for new users
|
|
230
|
-
db.users.subscribe('insert', (user) => {
|
|
231
|
-
console.log('New user:', user.name); // fires on every db.users.insert(...)
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
// Listen for updates
|
|
235
|
-
db.users.subscribe('update', (user) => {
|
|
236
|
-
console.log('Updated:', user.name, '→', user.role);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
// Listen for deletes
|
|
240
|
-
db.users.subscribe('delete', (user) => {
|
|
241
|
-
console.log('Deleted:', user.name);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
// Stop listening
|
|
245
|
-
db.users.unsubscribe('update', myCallback);
|
|
246
|
-
```
|
|
247
|
-
|
|
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()`:
|
|
255
|
-
|
|
256
|
-
```typescript
|
|
257
|
-
db.on('insert', (tableName, entity) => {
|
|
258
|
-
console.log(`New row in ${tableName}:`, entity.id);
|
|
259
|
-
});
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
---
|
|
263
|
-
|
|
264
|
-
### 2. Smart Polling — `select().subscribe(callback, options)`
|
|
265
|
-
|
|
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.
|
|
267
|
-
|
|
268
|
-
```typescript
|
|
269
|
-
// Watch for admin list changes, poll every second
|
|
270
215
|
const unsub = db.users.select()
|
|
271
216
|
.where({ role: 'admin' })
|
|
272
217
|
.orderBy('name', 'asc')
|
|
@@ -283,116 +228,41 @@ unsub();
|
|
|
283
228
|
| Option | Default | Description |
|
|
284
229
|
|---|---|---|
|
|
285
230
|
| `interval` | `500` | Polling interval in milliseconds |
|
|
286
|
-
| `immediate` | `true` |
|
|
231
|
+
| `immediate` | `true` | Fire callback immediately with current data |
|
|
287
232
|
|
|
288
|
-
|
|
233
|
+
### How it works
|
|
289
234
|
|
|
290
235
|
```
|
|
291
|
-
|
|
292
|
-
│ Every {interval}ms:
|
|
293
|
-
│
|
|
294
|
-
│ 1.
|
|
295
|
-
│
|
|
296
|
-
│
|
|
297
|
-
│
|
|
298
|
-
│
|
|
299
|
-
│
|
|
300
|
-
│
|
|
301
|
-
|
|
236
|
+
┌──────────────────────────────────────────────────┐
|
|
237
|
+
│ Every {interval}ms: │
|
|
238
|
+
│ │
|
|
239
|
+
│ 1. Check in-memory revision counter (free) │
|
|
240
|
+
│ 2. Run: SELECT COUNT(*), MAX(id) │
|
|
241
|
+
│ FROM users WHERE role = 'admin' │
|
|
242
|
+
│ │
|
|
243
|
+
│ 3. Combine into fingerprint: "count:max:rev" │
|
|
244
|
+
│ │
|
|
245
|
+
│ 4. If fingerprint changed → re-run full query │
|
|
246
|
+
│ and call your callback │
|
|
247
|
+
└──────────────────────────────────────────────────┘
|
|
302
248
|
```
|
|
303
249
|
|
|
304
|
-
|
|
250
|
+
Since `_Database` controls all writes, it bumps an in-memory revision counter on every insert, update, and delete. The fingerprint includes this counter, so **all changes are always detected** — no triggers, no WAL, no `_changes` table.
|
|
305
251
|
|
|
306
|
-
| Operation |
|
|
252
|
+
| Operation | Detected | How |
|
|
307
253
|
|---|---|---|
|
|
308
|
-
| INSERT | ✅
|
|
309
|
-
| DELETE | ✅
|
|
310
|
-
| UPDATE |
|
|
311
|
-
|
|
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.
|
|
254
|
+
| INSERT | ✅ | MAX(id) increases + revision bumps |
|
|
255
|
+
| DELETE | ✅ | COUNT changes + revision bumps |
|
|
256
|
+
| UPDATE | ✅ | revision bumps |
|
|
314
257
|
|
|
315
258
|
**Use cases:**
|
|
316
259
|
- Live dashboards (poll every 1-5s)
|
|
317
|
-
- Real-time chat message lists
|
|
260
|
+
- Real-time chat / message lists
|
|
318
261
|
- Auto-refreshing data tables
|
|
319
262
|
- Watching filtered subsets of data
|
|
320
263
|
|
|
321
264
|
---
|
|
322
265
|
|
|
323
|
-
### 3. Change Tracking — `changeTracking: true`
|
|
324
|
-
|
|
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.
|
|
326
|
-
|
|
327
|
-
```typescript
|
|
328
|
-
const db = new Database(':memory:', schemas, {
|
|
329
|
-
changeTracking: true,
|
|
330
|
-
});
|
|
331
|
-
```
|
|
332
|
-
|
|
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:**
|
|
339
|
-
|
|
340
|
-
```typescript
|
|
341
|
-
// Get the current sequence number (latest change ID)
|
|
342
|
-
const seq = db.getChangeSeq(); // global
|
|
343
|
-
const seq = db.getChangeSeq('users'); // per table
|
|
344
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
// Each change looks like:
|
|
350
|
-
// { id: 42, table_name: 'users', row_id: 7, action: 'UPDATE', changed_at: '2024-...' }
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
**Polling for changes (external sync pattern):**
|
|
354
|
-
|
|
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);
|
|
367
|
-
```
|
|
368
|
-
|
|
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
|
|
375
|
-
|
|
376
|
-
---
|
|
377
|
-
|
|
378
|
-
### Choosing the Right System
|
|
379
|
-
|
|
380
|
-
```
|
|
381
|
-
Do you need to react to your own writes?
|
|
382
|
-
→ CRUD Events (db.table.subscribe)
|
|
383
|
-
|
|
384
|
-
Do you need to watch a query result set?
|
|
385
|
-
→ Smart Polling (select().subscribe)
|
|
386
|
-
→ Enable changeTracking if you need UPDATE detection
|
|
387
|
-
|
|
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.
|
|
393
|
-
|
|
394
|
-
---
|
|
395
|
-
|
|
396
266
|
## Examples & Tests
|
|
397
267
|
|
|
398
268
|
```bash
|
|
@@ -424,12 +294,7 @@ bun test # 91 tests
|
|
|
424
294
|
| `entity.update(data)` | Update entity in-place |
|
|
425
295
|
| `entity.delete()` | Delete entity |
|
|
426
296
|
| **Reactivity** | |
|
|
427
|
-
| `
|
|
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) |
|
|
297
|
+
| `select().subscribe(cb, opts?)` | Watch any query for changes (all mutations) |
|
|
433
298
|
|
|
434
299
|
## License
|
|
435
300
|
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,6 @@ var __export = (target, all) => {
|
|
|
12
12
|
|
|
13
13
|
// src/database.ts
|
|
14
14
|
import { Database as SqliteDatabase } from "bun:sqlite";
|
|
15
|
-
import { EventEmitter } from "events";
|
|
16
15
|
|
|
17
16
|
// src/ast.ts
|
|
18
17
|
var wrapNode = (val) => val !== null && typeof val === "object" && ("type" in val) ? val : { type: "literal", value: val };
|
|
@@ -174,14 +173,14 @@ class QueryBuilder {
|
|
|
174
173
|
singleExecutor;
|
|
175
174
|
joinResolver;
|
|
176
175
|
conditionResolver;
|
|
177
|
-
|
|
178
|
-
constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver,
|
|
176
|
+
revisionGetter;
|
|
177
|
+
constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter) {
|
|
179
178
|
this.tableName = tableName;
|
|
180
179
|
this.executor = executor;
|
|
181
180
|
this.singleExecutor = singleExecutor;
|
|
182
181
|
this.joinResolver = joinResolver ?? null;
|
|
183
182
|
this.conditionResolver = conditionResolver ?? null;
|
|
184
|
-
this.
|
|
183
|
+
this.revisionGetter = revisionGetter ?? null;
|
|
185
184
|
this.iqo = {
|
|
186
185
|
selects: [],
|
|
187
186
|
wheres: [],
|
|
@@ -335,8 +334,8 @@ class QueryBuilder {
|
|
|
335
334
|
try {
|
|
336
335
|
const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
|
|
337
336
|
const fpRow = fpRows[0];
|
|
338
|
-
const
|
|
339
|
-
const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${
|
|
337
|
+
const rev = this.revisionGetter?.() ?? 0;
|
|
338
|
+
const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
|
|
340
339
|
if (currentFingerprint !== lastFingerprint) {
|
|
341
340
|
lastFingerprint = currentFingerprint;
|
|
342
341
|
const rows = this.all();
|
|
@@ -4652,26 +4651,22 @@ function transformFromStorage(row, schema) {
|
|
|
4652
4651
|
}
|
|
4653
4652
|
|
|
4654
4653
|
// src/database.ts
|
|
4655
|
-
class _Database
|
|
4654
|
+
class _Database {
|
|
4656
4655
|
db;
|
|
4657
4656
|
schemas;
|
|
4658
4657
|
relationships;
|
|
4659
|
-
subscriptions;
|
|
4660
4658
|
options;
|
|
4659
|
+
_revisions = {};
|
|
4661
4660
|
constructor(dbFile, schemas, options = {}) {
|
|
4662
|
-
super();
|
|
4663
4661
|
this.db = new SqliteDatabase(dbFile);
|
|
4664
4662
|
this.db.run("PRAGMA foreign_keys = ON");
|
|
4665
4663
|
this.schemas = schemas;
|
|
4666
4664
|
this.options = options;
|
|
4667
|
-
this.subscriptions = { insert: {}, update: {}, delete: {} };
|
|
4668
4665
|
this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
|
|
4669
4666
|
this.initializeTables();
|
|
4670
4667
|
this.runMigrations();
|
|
4671
4668
|
if (options.indexes)
|
|
4672
4669
|
this.createIndexes(options.indexes);
|
|
4673
|
-
if (options.changeTracking)
|
|
4674
|
-
this.setupChangeTracking();
|
|
4675
4670
|
for (const entityName of Object.keys(schemas)) {
|
|
4676
4671
|
const key = entityName;
|
|
4677
4672
|
const accessor = {
|
|
@@ -4683,8 +4678,6 @@ class _Database extends EventEmitter {
|
|
|
4683
4678
|
},
|
|
4684
4679
|
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
4685
4680
|
delete: (id) => this.delete(entityName, id),
|
|
4686
|
-
subscribe: (event, callback) => this.subscribe(event, entityName, callback),
|
|
4687
|
-
unsubscribe: (event, callback) => this.unsubscribe(event, entityName, callback),
|
|
4688
4681
|
select: (...cols) => this._createQueryBuilder(entityName, cols),
|
|
4689
4682
|
_tableName: entityName
|
|
4690
4683
|
};
|
|
@@ -4706,66 +4699,32 @@ class _Database extends EventEmitter {
|
|
|
4706
4699
|
}
|
|
4707
4700
|
}
|
|
4708
4701
|
runMigrations() {
|
|
4709
|
-
this.db.run(`CREATE TABLE IF NOT EXISTS _schema_meta (
|
|
4710
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4711
|
-
table_name TEXT NOT NULL,
|
|
4712
|
-
column_name TEXT NOT NULL,
|
|
4713
|
-
added_at TEXT DEFAULT (datetime('now')),
|
|
4714
|
-
UNIQUE(table_name, column_name)
|
|
4715
|
-
)`);
|
|
4716
4702
|
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
4717
|
-
const
|
|
4703
|
+
const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all();
|
|
4704
|
+
const existingNames = new Set(existingColumns.map((c) => c.name));
|
|
4718
4705
|
const storableFields = getStorableFields(schema);
|
|
4719
4706
|
for (const field of storableFields) {
|
|
4720
|
-
if (!
|
|
4721
|
-
|
|
4722
|
-
this.db.
|
|
4707
|
+
if (!existingNames.has(field.name)) {
|
|
4708
|
+
const sqlType = zodTypeToSqlType(field.type);
|
|
4709
|
+
this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${sqlType}`);
|
|
4723
4710
|
}
|
|
4724
4711
|
}
|
|
4725
4712
|
}
|
|
4726
4713
|
}
|
|
4727
|
-
createIndexes(
|
|
4728
|
-
for (const [tableName,
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
const columns = Array.isArray(indexDef) ? indexDef : [indexDef];
|
|
4734
|
-
const indexName = `idx_${tableName}_${columns.join("_")}`;
|
|
4735
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columns.join(", ")})`);
|
|
4714
|
+
createIndexes(indexes) {
|
|
4715
|
+
for (const [tableName, indexDefs] of Object.entries(indexes)) {
|
|
4716
|
+
for (const def of indexDefs) {
|
|
4717
|
+
const cols = Array.isArray(def) ? def : [def];
|
|
4718
|
+
const idxName = `idx_${tableName}_${cols.join("_")}`;
|
|
4719
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${tableName} (${cols.join(", ")})`);
|
|
4736
4720
|
}
|
|
4737
4721
|
}
|
|
4738
4722
|
}
|
|
4739
|
-
|
|
4740
|
-
this.
|
|
4741
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4742
|
-
table_name TEXT NOT NULL,
|
|
4743
|
-
row_id INTEGER NOT NULL,
|
|
4744
|
-
action TEXT NOT NULL CHECK(action IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
4745
|
-
changed_at TEXT DEFAULT (datetime('now'))
|
|
4746
|
-
)`);
|
|
4747
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS idx_changes_table ON _changes (table_name, id)`);
|
|
4748
|
-
for (const entityName of Object.keys(this.schemas)) {
|
|
4749
|
-
for (const action of ["insert", "update", "delete"]) {
|
|
4750
|
-
const ref = action === "delete" ? "OLD" : "NEW";
|
|
4751
|
-
this.db.run(`CREATE TRIGGER IF NOT EXISTS _trg_${entityName}_${action}
|
|
4752
|
-
AFTER ${action.toUpperCase()} ON ${entityName}
|
|
4753
|
-
BEGIN
|
|
4754
|
-
INSERT INTO _changes (table_name, row_id, action) VALUES ('${entityName}', ${ref}.id, '${action.toUpperCase()}');
|
|
4755
|
-
END`);
|
|
4756
|
-
}
|
|
4757
|
-
}
|
|
4723
|
+
_bumpRevision(entityName) {
|
|
4724
|
+
this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
|
|
4758
4725
|
}
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
return -1;
|
|
4762
|
-
const sql = tableName ? `SELECT MAX(id) as seq FROM _changes WHERE table_name = ?` : `SELECT MAX(id) as seq FROM _changes`;
|
|
4763
|
-
const row = this.db.query(sql).get(...tableName ? [tableName] : []);
|
|
4764
|
-
return row?.seq ?? 0;
|
|
4765
|
-
}
|
|
4766
|
-
getChangesSince(sinceSeq, tableName) {
|
|
4767
|
-
const sql = tableName ? `SELECT * FROM _changes WHERE id > ? AND table_name = ? ORDER BY id ASC` : `SELECT * FROM _changes WHERE id > ? ORDER BY id ASC`;
|
|
4768
|
-
return this.db.query(sql).all(...tableName ? [sinceSeq, tableName] : [sinceSeq]);
|
|
4726
|
+
_getRevision(entityName) {
|
|
4727
|
+
return this._revisions[entityName] ?? 0;
|
|
4769
4728
|
}
|
|
4770
4729
|
insert(entityName, data) {
|
|
4771
4730
|
const schema = this.schemas[entityName];
|
|
@@ -4777,8 +4736,7 @@ class _Database extends EventEmitter {
|
|
|
4777
4736
|
const newEntity = this._getById(entityName, result.lastInsertRowid);
|
|
4778
4737
|
if (!newEntity)
|
|
4779
4738
|
throw new Error("Failed to retrieve entity after insertion");
|
|
4780
|
-
this.
|
|
4781
|
-
this.subscriptions.insert[entityName]?.forEach((cb) => cb(newEntity));
|
|
4739
|
+
this._bumpRevision(entityName);
|
|
4782
4740
|
return newEntity;
|
|
4783
4741
|
}
|
|
4784
4742
|
_getById(entityName, id) {
|
|
@@ -4807,11 +4765,8 @@ class _Database extends EventEmitter {
|
|
|
4807
4765
|
return this._getById(entityName, id);
|
|
4808
4766
|
const setClause = Object.keys(transformed).map((key) => `${key} = ?`).join(", ");
|
|
4809
4767
|
this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
|
|
4768
|
+
this._bumpRevision(entityName);
|
|
4810
4769
|
const updatedEntity = this._getById(entityName, id);
|
|
4811
|
-
if (updatedEntity) {
|
|
4812
|
-
this.emit("update", entityName, updatedEntity);
|
|
4813
|
-
this.subscriptions.update[entityName]?.forEach((cb) => cb(updatedEntity));
|
|
4814
|
-
}
|
|
4815
4770
|
return updatedEntity;
|
|
4816
4771
|
}
|
|
4817
4772
|
_updateWhere(entityName, data, conditions) {
|
|
@@ -4827,12 +4782,8 @@ class _Database extends EventEmitter {
|
|
|
4827
4782
|
const setClause = setCols.map((key) => `${key} = ?`).join(", ");
|
|
4828
4783
|
const result = this.db.query(`UPDATE ${entityName} SET ${setClause} ${clause}`).run(...setCols.map((key) => transformed[key]), ...whereValues);
|
|
4829
4784
|
const affected = result.changes ?? 0;
|
|
4830
|
-
if (affected > 0
|
|
4831
|
-
|
|
4832
|
-
this.emit("update", entityName, entity);
|
|
4833
|
-
this.subscriptions.update[entityName]?.forEach((cb) => cb(entity));
|
|
4834
|
-
}
|
|
4835
|
-
}
|
|
4785
|
+
if (affected > 0)
|
|
4786
|
+
this._bumpRevision(entityName);
|
|
4836
4787
|
return affected;
|
|
4837
4788
|
}
|
|
4838
4789
|
_createUpdateBuilder(entityName, data) {
|
|
@@ -4862,8 +4813,7 @@ class _Database extends EventEmitter {
|
|
|
4862
4813
|
const entity = this._getById(entityName, id);
|
|
4863
4814
|
if (entity) {
|
|
4864
4815
|
this.db.query(`DELETE FROM ${entityName} WHERE id = ?`).run(id);
|
|
4865
|
-
this.
|
|
4866
|
-
this.subscriptions.delete[entityName]?.forEach((cb) => cb(entity));
|
|
4816
|
+
this._bumpRevision(entityName);
|
|
4867
4817
|
}
|
|
4868
4818
|
}
|
|
4869
4819
|
_attachMethods(entityName, entity) {
|
|
@@ -4960,15 +4910,6 @@ class _Database extends EventEmitter {
|
|
|
4960
4910
|
throw new Error(`Transaction failed: ${error.message}`);
|
|
4961
4911
|
}
|
|
4962
4912
|
}
|
|
4963
|
-
subscribe(event, entityName, callback) {
|
|
4964
|
-
this.subscriptions[event][entityName] = this.subscriptions[event][entityName] || [];
|
|
4965
|
-
this.subscriptions[event][entityName].push(callback);
|
|
4966
|
-
}
|
|
4967
|
-
unsubscribe(event, entityName, callback) {
|
|
4968
|
-
if (this.subscriptions[event][entityName]) {
|
|
4969
|
-
this.subscriptions[event][entityName] = this.subscriptions[event][entityName].filter((cb) => cb !== callback);
|
|
4970
|
-
}
|
|
4971
|
-
}
|
|
4972
4913
|
_createQueryBuilder(entityName, initialCols) {
|
|
4973
4914
|
const schema = this.schemas[entityName];
|
|
4974
4915
|
const executor = (sql, params, raw) => {
|
|
@@ -4990,8 +4931,8 @@ class _Database extends EventEmitter {
|
|
|
4990
4931
|
return { fk: "id", pk: reverse.foreignKey };
|
|
4991
4932
|
return null;
|
|
4992
4933
|
};
|
|
4993
|
-
const
|
|
4994
|
-
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null,
|
|
4934
|
+
const revisionGetter = () => this._getRevision(entityName);
|
|
4935
|
+
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
|
|
4995
4936
|
if (initialCols.length > 0)
|
|
4996
4937
|
builder.select(...initialCols);
|
|
4997
4938
|
return builder;
|
package/package.json
CHANGED
package/src/database.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* query builders, and event handling.
|
|
6
6
|
*/
|
|
7
7
|
import { Database as SqliteDatabase } from 'bun:sqlite';
|
|
8
|
-
import { EventEmitter } from 'events';
|
|
9
8
|
import { z } from 'zod';
|
|
10
9
|
import { QueryBuilder } from './query-builder';
|
|
11
10
|
import { executeProxyQuery, type ProxyQueryResult } from './proxy-query';
|
|
@@ -25,25 +24,25 @@ import {
|
|
|
25
24
|
// Database Class
|
|
26
25
|
// =============================================================================
|
|
27
26
|
|
|
28
|
-
class _Database<Schemas extends SchemaMap>
|
|
27
|
+
class _Database<Schemas extends SchemaMap> {
|
|
29
28
|
private db: SqliteDatabase;
|
|
30
29
|
private schemas: Schemas;
|
|
31
30
|
private relationships: Relationship[];
|
|
32
|
-
private subscriptions: Record<'insert' | 'update' | 'delete', Record<string, ((data: any) => void)[]>>;
|
|
33
31
|
private options: DatabaseOptions;
|
|
34
32
|
|
|
33
|
+
/** In-memory revision counter per table — bumps on every write (insert/update/delete).
|
|
34
|
+
* Used by QueryBuilder.subscribe() fingerprint to detect ALL changes with zero overhead. */
|
|
35
|
+
private _revisions: Record<string, number> = {};
|
|
36
|
+
|
|
35
37
|
constructor(dbFile: string, schemas: Schemas, options: DatabaseOptions = {}) {
|
|
36
|
-
super();
|
|
37
38
|
this.db = new SqliteDatabase(dbFile);
|
|
38
39
|
this.db.run('PRAGMA foreign_keys = ON');
|
|
39
40
|
this.schemas = schemas;
|
|
40
41
|
this.options = options;
|
|
41
|
-
this.subscriptions = { insert: {}, update: {}, delete: {} };
|
|
42
42
|
this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
|
|
43
43
|
this.initializeTables();
|
|
44
44
|
this.runMigrations();
|
|
45
45
|
if (options.indexes) this.createIndexes(options.indexes);
|
|
46
|
-
if (options.changeTracking) this.setupChangeTracking();
|
|
47
46
|
|
|
48
47
|
// Create typed entity accessors (db.users, db.posts, etc.)
|
|
49
48
|
for (const entityName of Object.keys(schemas)) {
|
|
@@ -56,8 +55,6 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
|
56
55
|
},
|
|
57
56
|
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
58
57
|
delete: (id) => this.delete(entityName, id),
|
|
59
|
-
subscribe: (event, callback) => this.subscribe(event, entityName, callback),
|
|
60
|
-
unsubscribe: (event, callback) => this.unsubscribe(event, entityName, callback),
|
|
61
58
|
select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
|
|
62
59
|
_tableName: entityName,
|
|
63
60
|
};
|
|
@@ -90,85 +87,42 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
|
90
87
|
}
|
|
91
88
|
|
|
92
89
|
private runMigrations(): void {
|
|
93
|
-
this.db.run(`CREATE TABLE IF NOT EXISTS _schema_meta (
|
|
94
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
95
|
-
table_name TEXT NOT NULL,
|
|
96
|
-
column_name TEXT NOT NULL,
|
|
97
|
-
added_at TEXT DEFAULT (datetime('now')),
|
|
98
|
-
UNIQUE(table_name, column_name)
|
|
99
|
-
)`);
|
|
100
|
-
|
|
101
90
|
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
const storableFields = getStorableFields(schema);
|
|
91
|
+
const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all() as any[];
|
|
92
|
+
const existingNames = new Set(existingColumns.map(c => c.name));
|
|
106
93
|
|
|
94
|
+
const storableFields = getStorableFields(schema);
|
|
107
95
|
for (const field of storableFields) {
|
|
108
|
-
if (!
|
|
109
|
-
|
|
110
|
-
this.db.
|
|
96
|
+
if (!existingNames.has(field.name)) {
|
|
97
|
+
const sqlType = zodTypeToSqlType(field.type);
|
|
98
|
+
this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${sqlType}`);
|
|
111
99
|
}
|
|
112
100
|
}
|
|
113
101
|
}
|
|
114
102
|
}
|
|
115
103
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (!this.schemas[tableName]) throw new Error(`Cannot create index on unknown table '${tableName}'`);
|
|
123
|
-
const indexList = Array.isArray(indexes) ? indexes : [indexes];
|
|
124
|
-
for (const indexDef of indexList) {
|
|
125
|
-
const columns = Array.isArray(indexDef) ? indexDef : [indexDef];
|
|
126
|
-
const indexName = `idx_${tableName}_${columns.join('_')}`;
|
|
127
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columns.join(', ')})`);
|
|
104
|
+
private createIndexes(indexes: Record<string, (string | string[])[]>): void {
|
|
105
|
+
for (const [tableName, indexDefs] of Object.entries(indexes)) {
|
|
106
|
+
for (const def of indexDefs) {
|
|
107
|
+
const cols = Array.isArray(def) ? def : [def];
|
|
108
|
+
const idxName = `idx_${tableName}_${cols.join('_')}`;
|
|
109
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${tableName} (${cols.join(', ')})`);
|
|
128
110
|
}
|
|
129
111
|
}
|
|
130
112
|
}
|
|
131
113
|
|
|
132
114
|
// ===========================================================================
|
|
133
|
-
//
|
|
115
|
+
// Revision Tracking (in-memory, zero overhead)
|
|
134
116
|
// ===========================================================================
|
|
135
117
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
table_name TEXT NOT NULL,
|
|
140
|
-
row_id INTEGER NOT NULL,
|
|
141
|
-
action TEXT NOT NULL CHECK(action IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
142
|
-
changed_at TEXT DEFAULT (datetime('now'))
|
|
143
|
-
)`);
|
|
144
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS idx_changes_table ON _changes (table_name, id)`);
|
|
145
|
-
|
|
146
|
-
for (const entityName of Object.keys(this.schemas)) {
|
|
147
|
-
for (const action of ['insert', 'update', 'delete'] as const) {
|
|
148
|
-
const ref = action === 'delete' ? 'OLD' : 'NEW';
|
|
149
|
-
this.db.run(`CREATE TRIGGER IF NOT EXISTS _trg_${entityName}_${action}
|
|
150
|
-
AFTER ${action.toUpperCase()} ON ${entityName}
|
|
151
|
-
BEGIN
|
|
152
|
-
INSERT INTO _changes (table_name, row_id, action) VALUES ('${entityName}', ${ref}.id, '${action.toUpperCase()}');
|
|
153
|
-
END`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
public getChangeSeq(tableName?: string): number {
|
|
159
|
-
if (!this.options.changeTracking) return -1;
|
|
160
|
-
const sql = tableName
|
|
161
|
-
? `SELECT MAX(id) as seq FROM _changes WHERE table_name = ?`
|
|
162
|
-
: `SELECT MAX(id) as seq FROM _changes`;
|
|
163
|
-
const row = this.db.query(sql).get(...(tableName ? [tableName] : [])) as any;
|
|
164
|
-
return row?.seq ?? 0;
|
|
118
|
+
/** Bump the revision counter for a table. Called on every write. */
|
|
119
|
+
private _bumpRevision(entityName: string): void {
|
|
120
|
+
this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
|
|
165
121
|
}
|
|
166
122
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
: `SELECT * FROM _changes WHERE id > ? ORDER BY id ASC`;
|
|
171
|
-
return this.db.query(sql).all(...(tableName ? [sinceSeq, tableName] : [sinceSeq])) as any[];
|
|
123
|
+
/** Get the current revision for a table. Used by QueryBuilder.subscribe() fingerprint. */
|
|
124
|
+
public _getRevision(entityName: string): number {
|
|
125
|
+
return this._revisions[entityName] ?? 0;
|
|
172
126
|
}
|
|
173
127
|
|
|
174
128
|
// ===========================================================================
|
|
@@ -189,8 +143,7 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
|
189
143
|
const newEntity = this._getById(entityName, result.lastInsertRowid as number);
|
|
190
144
|
if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
|
|
191
145
|
|
|
192
|
-
this.
|
|
193
|
-
this.subscriptions.insert[entityName]?.forEach(cb => cb(newEntity));
|
|
146
|
+
this._bumpRevision(entityName);
|
|
194
147
|
return newEntity;
|
|
195
148
|
}
|
|
196
149
|
|
|
@@ -227,11 +180,8 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
|
227
180
|
const setClause = Object.keys(transformed).map(key => `${key} = ?`).join(', ');
|
|
228
181
|
this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
|
|
229
182
|
|
|
183
|
+
this._bumpRevision(entityName);
|
|
230
184
|
const updatedEntity = this._getById(entityName, id);
|
|
231
|
-
if (updatedEntity) {
|
|
232
|
-
this.emit('update', entityName, updatedEntity);
|
|
233
|
-
this.subscriptions.update[entityName]?.forEach(cb => cb(updatedEntity));
|
|
234
|
-
}
|
|
235
185
|
return updatedEntity;
|
|
236
186
|
}
|
|
237
187
|
|
|
@@ -252,12 +202,7 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
|
252
202
|
);
|
|
253
203
|
|
|
254
204
|
const affected = (result as any).changes ?? 0;
|
|
255
|
-
if (affected > 0
|
|
256
|
-
for (const entity of this._findMany(entityName, conditions)) {
|
|
257
|
-
this.emit('update', entityName, entity);
|
|
258
|
-
this.subscriptions.update[entityName]?.forEach(cb => cb(entity));
|
|
259
|
-
}
|
|
260
|
-
}
|
|
205
|
+
if (affected > 0) this._bumpRevision(entityName);
|
|
261
206
|
return affected;
|
|
262
207
|
}
|
|
263
208
|
|
|
@@ -292,8 +237,7 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
|
292
237
|
const entity = this._getById(entityName, id);
|
|
293
238
|
if (entity) {
|
|
294
239
|
this.db.query(`DELETE FROM ${entityName} WHERE id = ?`).run(id);
|
|
295
|
-
this.
|
|
296
|
-
this.subscriptions.delete[entityName]?.forEach(cb => cb(entity));
|
|
240
|
+
this._bumpRevision(entityName);
|
|
297
241
|
}
|
|
298
242
|
}
|
|
299
243
|
|
|
@@ -415,21 +359,6 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
|
415
359
|
}
|
|
416
360
|
}
|
|
417
361
|
|
|
418
|
-
// ===========================================================================
|
|
419
|
-
// Events
|
|
420
|
-
// ===========================================================================
|
|
421
|
-
|
|
422
|
-
private subscribe(event: 'insert' | 'update' | 'delete', entityName: string, callback: (data: any) => void): void {
|
|
423
|
-
this.subscriptions[event][entityName] = this.subscriptions[event][entityName] || [];
|
|
424
|
-
this.subscriptions[event][entityName].push(callback);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
private unsubscribe(event: 'insert' | 'update' | 'delete', entityName: string, callback: (data: any) => void): void {
|
|
428
|
-
if (this.subscriptions[event][entityName]) {
|
|
429
|
-
this.subscriptions[event][entityName] = this.subscriptions[event][entityName].filter(cb => cb !== callback);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
362
|
// ===========================================================================
|
|
434
363
|
// Query Builders
|
|
435
364
|
// ===========================================================================
|
|
@@ -459,13 +388,11 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
|
459
388
|
if (reverse) return { fk: 'id', pk: reverse.foreignKey };
|
|
460
389
|
return null;
|
|
461
390
|
};
|
|
462
|
-
// Provide change sequence getter when change tracking is enabled
|
|
463
|
-
// This allows .subscribe() to detect row UPDATEs (not just inserts/deletes)
|
|
464
|
-
const changeSeqGetter = this.options.changeTracking
|
|
465
|
-
? () => this.getChangeSeq(entityName)
|
|
466
|
-
: null;
|
|
467
391
|
|
|
468
|
-
|
|
392
|
+
// Pass revision getter — allows .subscribe() to detect ALL changes
|
|
393
|
+
const revisionGetter = () => this._getRevision(entityName);
|
|
394
|
+
|
|
395
|
+
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
|
|
469
396
|
if (initialCols.length > 0) builder.select(...initialCols);
|
|
470
397
|
return builder;
|
|
471
398
|
}
|
package/src/query-builder.ts
CHANGED
|
@@ -167,7 +167,7 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
167
167
|
private singleExecutor: (sql: string, params: any[], raw: boolean) => any | null;
|
|
168
168
|
private joinResolver: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null;
|
|
169
169
|
private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
|
|
170
|
-
private
|
|
170
|
+
private revisionGetter: (() => number) | null;
|
|
171
171
|
|
|
172
172
|
constructor(
|
|
173
173
|
tableName: string,
|
|
@@ -175,14 +175,14 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
175
175
|
singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
|
|
176
176
|
joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
|
|
177
177
|
conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
|
|
178
|
-
|
|
178
|
+
revisionGetter?: (() => number) | null,
|
|
179
179
|
) {
|
|
180
180
|
this.tableName = tableName;
|
|
181
181
|
this.executor = executor;
|
|
182
182
|
this.singleExecutor = singleExecutor;
|
|
183
183
|
this.joinResolver = joinResolver ?? null;
|
|
184
184
|
this.conditionResolver = conditionResolver ?? null;
|
|
185
|
-
this.
|
|
185
|
+
this.revisionGetter = revisionGetter ?? null;
|
|
186
186
|
this.iqo = {
|
|
187
187
|
selects: [],
|
|
188
188
|
wheres: [],
|
|
@@ -423,9 +423,9 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
423
423
|
/**
|
|
424
424
|
* Subscribe to query result changes using smart interval-based polling.
|
|
425
425
|
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
426
|
+
* Uses a lightweight fingerprint (`COUNT(*), MAX(id)`) combined with an
|
|
427
|
+
* in-memory revision counter to detect ALL changes (inserts, updates, deletes)
|
|
428
|
+
* with zero disk overhead.
|
|
429
429
|
*
|
|
430
430
|
* ```ts
|
|
431
431
|
* const unsub = db.messages.select()
|
|
@@ -459,10 +459,10 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
459
459
|
// Run lightweight fingerprint check
|
|
460
460
|
const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
|
|
461
461
|
const fpRow = fpRows[0] as any;
|
|
462
|
-
// Include
|
|
463
|
-
// This ensures
|
|
464
|
-
const
|
|
465
|
-
const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${
|
|
462
|
+
// Include in-memory revision in fingerprint.
|
|
463
|
+
// This ensures ALL changes (insert/update/delete) are detected.
|
|
464
|
+
const rev = this.revisionGetter?.() ?? 0;
|
|
465
|
+
const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
|
|
466
466
|
|
|
467
467
|
if (currentFingerprint !== lastFingerprint) {
|
|
468
468
|
lastFingerprint = currentFingerprint;
|
package/src/types.ts
CHANGED
|
@@ -21,7 +21,6 @@ export type IndexDef = string | string[];
|
|
|
21
21
|
|
|
22
22
|
/** Options for the Database constructor */
|
|
23
23
|
export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
24
|
-
changeTracking?: boolean;
|
|
25
24
|
indexes?: Record<string, IndexDef[]>;
|
|
26
25
|
/**
|
|
27
26
|
* Declare relationships between tables.
|
|
@@ -143,8 +142,6 @@ export type NavEntityAccessor<
|
|
|
143
142
|
& ((data: Partial<Omit<z.input<S[Table & keyof S]>, 'id'>>) => UpdateBuilder<NavEntity<S, R, Table>>);
|
|
144
143
|
upsert: (conditions?: Partial<z.infer<S[Table & keyof S]>>, data?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>;
|
|
145
144
|
delete: (id: number) => void;
|
|
146
|
-
subscribe: (event: 'insert' | 'update' | 'delete', callback: (data: NavEntity<S, R, Table>) => void) => void;
|
|
147
|
-
unsubscribe: (event: 'insert' | 'update' | 'delete', callback: (data: NavEntity<S, R, Table>) => void) => void;
|
|
148
145
|
select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
|
|
149
146
|
_tableName: string;
|
|
150
147
|
readonly _schema?: S[Table & keyof S];
|
|
@@ -170,8 +167,6 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
|
|
|
170
167
|
update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
|
|
171
168
|
upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
|
|
172
169
|
delete: (id: number) => void;
|
|
173
|
-
subscribe: (event: 'insert' | 'update' | 'delete', callback: (data: AugmentedEntity<S>) => void) => void;
|
|
174
|
-
unsubscribe: (event: 'insert' | 'update' | 'delete', callback: (data: AugmentedEntity<S>) => void) => void;
|
|
175
170
|
select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
|
|
176
171
|
_tableName: string;
|
|
177
172
|
readonly _schema?: S;
|