sqlite-zod-orm 3.2.0 → 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 +45 -16
- package/dist/index.js +29 -84
- package/package.json +1 -1
- package/src/database.ts +32 -100
- package/src/query-builder.ts +10 -4
- package/src/types.ts +0 -5
package/README.md
CHANGED
|
@@ -207,29 +207,60 @@ const db = new Database(':memory:', schemas, {
|
|
|
207
207
|
|
|
208
208
|
---
|
|
209
209
|
|
|
210
|
-
##
|
|
210
|
+
## Reactivity — `select().subscribe()`
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
const db = new Database(':memory:', schemas, { changeTracking: true });
|
|
214
|
-
db.getChangesSince(0);
|
|
215
|
-
|
|
216
|
-
db.users.subscribe('insert', (user) => console.log('New:', user.name));
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
---
|
|
220
|
-
|
|
221
|
-
## Smart Polling
|
|
212
|
+
One API to watch any query for changes. Detects **all** mutations (inserts, updates, deletes) with zero disk overhead.
|
|
222
213
|
|
|
223
214
|
```typescript
|
|
224
215
|
const unsub = db.users.select()
|
|
225
216
|
.where({ role: 'admin' })
|
|
217
|
+
.orderBy('name', 'asc')
|
|
226
218
|
.subscribe((admins) => {
|
|
227
|
-
console.log('Admin list
|
|
219
|
+
console.log('Admin list:', admins.map(a => a.name));
|
|
228
220
|
}, { interval: 1000 });
|
|
229
221
|
|
|
222
|
+
// Stop watching
|
|
230
223
|
unsub();
|
|
231
224
|
```
|
|
232
225
|
|
|
226
|
+
**Options:**
|
|
227
|
+
|
|
228
|
+
| Option | Default | Description |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| `interval` | `500` | Polling interval in milliseconds |
|
|
231
|
+
| `immediate` | `true` | Fire callback immediately with current data |
|
|
232
|
+
|
|
233
|
+
### How it works
|
|
234
|
+
|
|
235
|
+
```
|
|
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
|
+
└──────────────────────────────────────────────────┘
|
|
248
|
+
```
|
|
249
|
+
|
|
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.
|
|
251
|
+
|
|
252
|
+
| Operation | Detected | How |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| INSERT | ✅ | MAX(id) increases + revision bumps |
|
|
255
|
+
| DELETE | ✅ | COUNT changes + revision bumps |
|
|
256
|
+
| UPDATE | ✅ | revision bumps |
|
|
257
|
+
|
|
258
|
+
**Use cases:**
|
|
259
|
+
- Live dashboards (poll every 1-5s)
|
|
260
|
+
- Real-time chat / message lists
|
|
261
|
+
- Auto-refreshing data tables
|
|
262
|
+
- Watching filtered subsets of data
|
|
263
|
+
|
|
233
264
|
---
|
|
234
265
|
|
|
235
266
|
## Examples & Tests
|
|
@@ -262,10 +293,8 @@ bun test # 91 tests
|
|
|
262
293
|
| `entity.navMethod()` | Lazy navigation (FK name minus `_id`) |
|
|
263
294
|
| `entity.update(data)` | Update entity in-place |
|
|
264
295
|
| `entity.delete()` | Delete entity |
|
|
265
|
-
| **
|
|
266
|
-
| `
|
|
267
|
-
| `db.table.select().subscribe(cb, opts)` | Smart polling |
|
|
268
|
-
| `db.getChangesSince(version, table?)` | Change tracking |
|
|
296
|
+
| **Reactivity** | |
|
|
297
|
+
| `select().subscribe(cb, opts?)` | Watch any query for changes (all mutations) |
|
|
269
298
|
|
|
270
299
|
## License
|
|
271
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,12 +173,14 @@ class QueryBuilder {
|
|
|
174
173
|
singleExecutor;
|
|
175
174
|
joinResolver;
|
|
176
175
|
conditionResolver;
|
|
177
|
-
|
|
176
|
+
revisionGetter;
|
|
177
|
+
constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter) {
|
|
178
178
|
this.tableName = tableName;
|
|
179
179
|
this.executor = executor;
|
|
180
180
|
this.singleExecutor = singleExecutor;
|
|
181
181
|
this.joinResolver = joinResolver ?? null;
|
|
182
182
|
this.conditionResolver = conditionResolver ?? null;
|
|
183
|
+
this.revisionGetter = revisionGetter ?? null;
|
|
183
184
|
this.iqo = {
|
|
184
185
|
selects: [],
|
|
185
186
|
wheres: [],
|
|
@@ -333,7 +334,8 @@ class QueryBuilder {
|
|
|
333
334
|
try {
|
|
334
335
|
const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
|
|
335
336
|
const fpRow = fpRows[0];
|
|
336
|
-
const
|
|
337
|
+
const rev = this.revisionGetter?.() ?? 0;
|
|
338
|
+
const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
|
|
337
339
|
if (currentFingerprint !== lastFingerprint) {
|
|
338
340
|
lastFingerprint = currentFingerprint;
|
|
339
341
|
const rows = this.all();
|
|
@@ -4649,26 +4651,22 @@ function transformFromStorage(row, schema) {
|
|
|
4649
4651
|
}
|
|
4650
4652
|
|
|
4651
4653
|
// src/database.ts
|
|
4652
|
-
class _Database
|
|
4654
|
+
class _Database {
|
|
4653
4655
|
db;
|
|
4654
4656
|
schemas;
|
|
4655
4657
|
relationships;
|
|
4656
|
-
subscriptions;
|
|
4657
4658
|
options;
|
|
4659
|
+
_revisions = {};
|
|
4658
4660
|
constructor(dbFile, schemas, options = {}) {
|
|
4659
|
-
super();
|
|
4660
4661
|
this.db = new SqliteDatabase(dbFile);
|
|
4661
4662
|
this.db.run("PRAGMA foreign_keys = ON");
|
|
4662
4663
|
this.schemas = schemas;
|
|
4663
4664
|
this.options = options;
|
|
4664
|
-
this.subscriptions = { insert: {}, update: {}, delete: {} };
|
|
4665
4665
|
this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
|
|
4666
4666
|
this.initializeTables();
|
|
4667
4667
|
this.runMigrations();
|
|
4668
4668
|
if (options.indexes)
|
|
4669
4669
|
this.createIndexes(options.indexes);
|
|
4670
|
-
if (options.changeTracking)
|
|
4671
|
-
this.setupChangeTracking();
|
|
4672
4670
|
for (const entityName of Object.keys(schemas)) {
|
|
4673
4671
|
const key = entityName;
|
|
4674
4672
|
const accessor = {
|
|
@@ -4680,8 +4678,6 @@ class _Database extends EventEmitter {
|
|
|
4680
4678
|
},
|
|
4681
4679
|
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
4682
4680
|
delete: (id) => this.delete(entityName, id),
|
|
4683
|
-
subscribe: (event, callback) => this.subscribe(event, entityName, callback),
|
|
4684
|
-
unsubscribe: (event, callback) => this.unsubscribe(event, entityName, callback),
|
|
4685
4681
|
select: (...cols) => this._createQueryBuilder(entityName, cols),
|
|
4686
4682
|
_tableName: entityName
|
|
4687
4683
|
};
|
|
@@ -4703,66 +4699,32 @@ class _Database extends EventEmitter {
|
|
|
4703
4699
|
}
|
|
4704
4700
|
}
|
|
4705
4701
|
runMigrations() {
|
|
4706
|
-
this.db.run(`CREATE TABLE IF NOT EXISTS _schema_meta (
|
|
4707
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4708
|
-
table_name TEXT NOT NULL,
|
|
4709
|
-
column_name TEXT NOT NULL,
|
|
4710
|
-
added_at TEXT DEFAULT (datetime('now')),
|
|
4711
|
-
UNIQUE(table_name, column_name)
|
|
4712
|
-
)`);
|
|
4713
4702
|
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
4714
|
-
const
|
|
4703
|
+
const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all();
|
|
4704
|
+
const existingNames = new Set(existingColumns.map((c) => c.name));
|
|
4715
4705
|
const storableFields = getStorableFields(schema);
|
|
4716
4706
|
for (const field of storableFields) {
|
|
4717
|
-
if (!
|
|
4718
|
-
|
|
4719
|
-
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}`);
|
|
4720
4710
|
}
|
|
4721
4711
|
}
|
|
4722
4712
|
}
|
|
4723
4713
|
}
|
|
4724
|
-
createIndexes(
|
|
4725
|
-
for (const [tableName,
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
const columns = Array.isArray(indexDef) ? indexDef : [indexDef];
|
|
4731
|
-
const indexName = `idx_${tableName}_${columns.join("_")}`;
|
|
4732
|
-
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(", ")})`);
|
|
4733
4720
|
}
|
|
4734
4721
|
}
|
|
4735
4722
|
}
|
|
4736
|
-
|
|
4737
|
-
this.
|
|
4738
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4739
|
-
table_name TEXT NOT NULL,
|
|
4740
|
-
row_id INTEGER NOT NULL,
|
|
4741
|
-
action TEXT NOT NULL CHECK(action IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
4742
|
-
changed_at TEXT DEFAULT (datetime('now'))
|
|
4743
|
-
)`);
|
|
4744
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS idx_changes_table ON _changes (table_name, id)`);
|
|
4745
|
-
for (const entityName of Object.keys(this.schemas)) {
|
|
4746
|
-
for (const action of ["insert", "update", "delete"]) {
|
|
4747
|
-
const ref = action === "delete" ? "OLD" : "NEW";
|
|
4748
|
-
this.db.run(`CREATE TRIGGER IF NOT EXISTS _trg_${entityName}_${action}
|
|
4749
|
-
AFTER ${action.toUpperCase()} ON ${entityName}
|
|
4750
|
-
BEGIN
|
|
4751
|
-
INSERT INTO _changes (table_name, row_id, action) VALUES ('${entityName}', ${ref}.id, '${action.toUpperCase()}');
|
|
4752
|
-
END`);
|
|
4753
|
-
}
|
|
4754
|
-
}
|
|
4723
|
+
_bumpRevision(entityName) {
|
|
4724
|
+
this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
|
|
4755
4725
|
}
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
return -1;
|
|
4759
|
-
const sql = tableName ? `SELECT MAX(id) as seq FROM _changes WHERE table_name = ?` : `SELECT MAX(id) as seq FROM _changes`;
|
|
4760
|
-
const row = this.db.query(sql).get(...tableName ? [tableName] : []);
|
|
4761
|
-
return row?.seq ?? 0;
|
|
4762
|
-
}
|
|
4763
|
-
getChangesSince(sinceSeq, tableName) {
|
|
4764
|
-
const sql = tableName ? `SELECT * FROM _changes WHERE id > ? AND table_name = ? ORDER BY id ASC` : `SELECT * FROM _changes WHERE id > ? ORDER BY id ASC`;
|
|
4765
|
-
return this.db.query(sql).all(...tableName ? [sinceSeq, tableName] : [sinceSeq]);
|
|
4726
|
+
_getRevision(entityName) {
|
|
4727
|
+
return this._revisions[entityName] ?? 0;
|
|
4766
4728
|
}
|
|
4767
4729
|
insert(entityName, data) {
|
|
4768
4730
|
const schema = this.schemas[entityName];
|
|
@@ -4774,8 +4736,7 @@ class _Database extends EventEmitter {
|
|
|
4774
4736
|
const newEntity = this._getById(entityName, result.lastInsertRowid);
|
|
4775
4737
|
if (!newEntity)
|
|
4776
4738
|
throw new Error("Failed to retrieve entity after insertion");
|
|
4777
|
-
this.
|
|
4778
|
-
this.subscriptions.insert[entityName]?.forEach((cb) => cb(newEntity));
|
|
4739
|
+
this._bumpRevision(entityName);
|
|
4779
4740
|
return newEntity;
|
|
4780
4741
|
}
|
|
4781
4742
|
_getById(entityName, id) {
|
|
@@ -4804,11 +4765,8 @@ class _Database extends EventEmitter {
|
|
|
4804
4765
|
return this._getById(entityName, id);
|
|
4805
4766
|
const setClause = Object.keys(transformed).map((key) => `${key} = ?`).join(", ");
|
|
4806
4767
|
this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
|
|
4768
|
+
this._bumpRevision(entityName);
|
|
4807
4769
|
const updatedEntity = this._getById(entityName, id);
|
|
4808
|
-
if (updatedEntity) {
|
|
4809
|
-
this.emit("update", entityName, updatedEntity);
|
|
4810
|
-
this.subscriptions.update[entityName]?.forEach((cb) => cb(updatedEntity));
|
|
4811
|
-
}
|
|
4812
4770
|
return updatedEntity;
|
|
4813
4771
|
}
|
|
4814
4772
|
_updateWhere(entityName, data, conditions) {
|
|
@@ -4824,12 +4782,8 @@ class _Database extends EventEmitter {
|
|
|
4824
4782
|
const setClause = setCols.map((key) => `${key} = ?`).join(", ");
|
|
4825
4783
|
const result = this.db.query(`UPDATE ${entityName} SET ${setClause} ${clause}`).run(...setCols.map((key) => transformed[key]), ...whereValues);
|
|
4826
4784
|
const affected = result.changes ?? 0;
|
|
4827
|
-
if (affected > 0
|
|
4828
|
-
|
|
4829
|
-
this.emit("update", entityName, entity);
|
|
4830
|
-
this.subscriptions.update[entityName]?.forEach((cb) => cb(entity));
|
|
4831
|
-
}
|
|
4832
|
-
}
|
|
4785
|
+
if (affected > 0)
|
|
4786
|
+
this._bumpRevision(entityName);
|
|
4833
4787
|
return affected;
|
|
4834
4788
|
}
|
|
4835
4789
|
_createUpdateBuilder(entityName, data) {
|
|
@@ -4859,8 +4813,7 @@ class _Database extends EventEmitter {
|
|
|
4859
4813
|
const entity = this._getById(entityName, id);
|
|
4860
4814
|
if (entity) {
|
|
4861
4815
|
this.db.query(`DELETE FROM ${entityName} WHERE id = ?`).run(id);
|
|
4862
|
-
this.
|
|
4863
|
-
this.subscriptions.delete[entityName]?.forEach((cb) => cb(entity));
|
|
4816
|
+
this._bumpRevision(entityName);
|
|
4864
4817
|
}
|
|
4865
4818
|
}
|
|
4866
4819
|
_attachMethods(entityName, entity) {
|
|
@@ -4957,15 +4910,6 @@ class _Database extends EventEmitter {
|
|
|
4957
4910
|
throw new Error(`Transaction failed: ${error.message}`);
|
|
4958
4911
|
}
|
|
4959
4912
|
}
|
|
4960
|
-
subscribe(event, entityName, callback) {
|
|
4961
|
-
this.subscriptions[event][entityName] = this.subscriptions[event][entityName] || [];
|
|
4962
|
-
this.subscriptions[event][entityName].push(callback);
|
|
4963
|
-
}
|
|
4964
|
-
unsubscribe(event, entityName, callback) {
|
|
4965
|
-
if (this.subscriptions[event][entityName]) {
|
|
4966
|
-
this.subscriptions[event][entityName] = this.subscriptions[event][entityName].filter((cb) => cb !== callback);
|
|
4967
|
-
}
|
|
4968
|
-
}
|
|
4969
4913
|
_createQueryBuilder(entityName, initialCols) {
|
|
4970
4914
|
const schema = this.schemas[entityName];
|
|
4971
4915
|
const executor = (sql, params, raw) => {
|
|
@@ -4987,7 +4931,8 @@ class _Database extends EventEmitter {
|
|
|
4987
4931
|
return { fk: "id", pk: reverse.foreignKey };
|
|
4988
4932
|
return null;
|
|
4989
4933
|
};
|
|
4990
|
-
const
|
|
4934
|
+
const revisionGetter = () => this._getRevision(entityName);
|
|
4935
|
+
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, null, revisionGetter);
|
|
4991
4936
|
if (initialCols.length > 0)
|
|
4992
4937
|
builder.select(...initialCols);
|
|
4993
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
|
// ===========================================================================
|
|
@@ -460,7 +389,10 @@ class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
|
460
389
|
return null;
|
|
461
390
|
};
|
|
462
391
|
|
|
463
|
-
|
|
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);
|
|
464
396
|
if (initialCols.length > 0) builder.select(...initialCols);
|
|
465
397
|
return builder;
|
|
466
398
|
}
|
package/src/query-builder.ts
CHANGED
|
@@ -167,6 +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 revisionGetter: (() => number) | null;
|
|
170
171
|
|
|
171
172
|
constructor(
|
|
172
173
|
tableName: string,
|
|
@@ -174,12 +175,14 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
174
175
|
singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
|
|
175
176
|
joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
|
|
176
177
|
conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
|
|
178
|
+
revisionGetter?: (() => number) | null,
|
|
177
179
|
) {
|
|
178
180
|
this.tableName = tableName;
|
|
179
181
|
this.executor = executor;
|
|
180
182
|
this.singleExecutor = singleExecutor;
|
|
181
183
|
this.joinResolver = joinResolver ?? null;
|
|
182
184
|
this.conditionResolver = conditionResolver ?? null;
|
|
185
|
+
this.revisionGetter = revisionGetter ?? null;
|
|
183
186
|
this.iqo = {
|
|
184
187
|
selects: [],
|
|
185
188
|
wheres: [],
|
|
@@ -420,9 +423,9 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
420
423
|
/**
|
|
421
424
|
* Subscribe to query result changes using smart interval-based polling.
|
|
422
425
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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.
|
|
426
429
|
*
|
|
427
430
|
* ```ts
|
|
428
431
|
* const unsub = db.messages.select()
|
|
@@ -456,7 +459,10 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
456
459
|
// Run lightweight fingerprint check
|
|
457
460
|
const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
|
|
458
461
|
const fpRow = fpRows[0] as any;
|
|
459
|
-
|
|
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}`;
|
|
460
466
|
|
|
461
467
|
if (currentFingerprint !== lastFingerprint) {
|
|
462
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;
|