sqlite-zod-orm 3.3.0 → 3.4.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 +67 -12
- package/dist/index.js +25 -2
- package/package.json +1 -1
- package/src/database.ts +65 -4
- package/src/query-builder.ts +5 -5
- package/src/types.ts +12 -0
package/README.md
CHANGED
|
@@ -207,9 +207,37 @@ const db = new Database(':memory:', schemas, {
|
|
|
207
207
|
|
|
208
208
|
---
|
|
209
209
|
|
|
210
|
-
## Reactivity
|
|
210
|
+
## Reactivity
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
Two complementary APIs for watching data changes:
|
|
213
|
+
|
|
214
|
+
| API | Receives | Fires on | Use case |
|
|
215
|
+
|---|---|---|---|
|
|
216
|
+
| **`db.table.on(cb)`** | One row at a time, in order | New inserts only | Message streams, event queues |
|
|
217
|
+
| **`select().subscribe(cb)`** | Full result snapshot | Any change (insert/update/delete) | Live dashboards, filtered views |
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
### Row Stream — `db.table.on(callback)`
|
|
222
|
+
|
|
223
|
+
Streams new rows one at a time, in insertion order. Only emits rows created **after** subscription starts.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
const unsub = db.messages.on((msg) => {
|
|
227
|
+
console.log(`${msg.author}: ${msg.text}`);
|
|
228
|
+
}, { interval: 200 });
|
|
229
|
+
|
|
230
|
+
// Later: stop listening
|
|
231
|
+
unsub();
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
If 5 messages arrive between polls, the callback fires 5 times — once per row, in order. Uses a watermark (`id > lastSeen`) internally.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
### Snapshot — `select().subscribe(callback)`
|
|
239
|
+
|
|
240
|
+
Returns the **full query result** whenever data changes. Detects all mutations (inserts, updates, deletes).
|
|
213
241
|
|
|
214
242
|
```typescript
|
|
215
243
|
const unsub = db.users.select()
|
|
@@ -236,38 +264,64 @@ unsub();
|
|
|
236
264
|
┌──────────────────────────────────────────────────┐
|
|
237
265
|
│ Every {interval}ms: │
|
|
238
266
|
│ │
|
|
239
|
-
│ 1. Check in-memory
|
|
267
|
+
│ 1. Check revision (in-memory + data_version) │
|
|
240
268
|
│ 2. Run: SELECT COUNT(*), MAX(id) │
|
|
241
269
|
│ FROM users WHERE role = 'admin' │
|
|
242
270
|
│ │
|
|
243
|
-
│ 3. Combine into fingerprint: "count:max:rev"
|
|
271
|
+
│ 3. Combine into fingerprint: "count:max:rev:dv" │
|
|
244
272
|
│ │
|
|
245
273
|
│ 4. If fingerprint changed → re-run full query │
|
|
246
274
|
│ and call your callback │
|
|
247
275
|
└──────────────────────────────────────────────────┘
|
|
248
276
|
```
|
|
249
277
|
|
|
250
|
-
|
|
278
|
+
Two signals combine to detect **all** changes from **any** source:
|
|
251
279
|
|
|
252
|
-
|
|
|
280
|
+
| Signal | Catches | How |
|
|
253
281
|
|---|---|---|
|
|
254
|
-
|
|
|
255
|
-
|
|
|
256
|
-
|
|
282
|
+
| **In-memory revision** | Same-process writes | Bumped by CRUD methods |
|
|
283
|
+
| **PRAGMA data_version** | Cross-process writes | SQLite bumps it on external commits |
|
|
284
|
+
|
|
285
|
+
| Operation | Detected | Source |
|
|
286
|
+
|---|---|---|
|
|
287
|
+
| INSERT | ✅ | Same or other process |
|
|
288
|
+
| DELETE | ✅ | Same or other process |
|
|
289
|
+
| UPDATE | ✅ | Same or other process |
|
|
290
|
+
|
|
291
|
+
No triggers. No `_changes` table. Zero disk overhead. WAL mode is enabled by default for concurrent read/write.
|
|
292
|
+
|
|
293
|
+
### Multi-process example
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// Process A — watches for new/edited messages
|
|
297
|
+
const unsub = db.messages.select()
|
|
298
|
+
.orderBy('id', 'asc')
|
|
299
|
+
.subscribe((messages) => {
|
|
300
|
+
console.log('Messages:', messages);
|
|
301
|
+
}, { interval: 200 });
|
|
302
|
+
|
|
303
|
+
// Process B — writes to the same DB file (different process)
|
|
304
|
+
// sqlite3 chat.db "INSERT INTO messages (text, author) VALUES ('hello', 'Bob')"
|
|
305
|
+
// → Process A's callback fires with updated message list!
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Run `bun examples/messages-demo.ts` for a full working demo.
|
|
257
309
|
|
|
258
310
|
**Use cases:**
|
|
259
311
|
- Live dashboards (poll every 1-5s)
|
|
260
312
|
- Real-time chat / message lists
|
|
261
313
|
- Auto-refreshing data tables
|
|
262
314
|
- Watching filtered subsets of data
|
|
315
|
+
- Cross-process data synchronization
|
|
263
316
|
|
|
264
317
|
---
|
|
265
318
|
|
|
266
319
|
## Examples & Tests
|
|
267
320
|
|
|
268
321
|
```bash
|
|
269
|
-
bun examples/
|
|
270
|
-
bun
|
|
322
|
+
bun examples/messages-demo.ts # .on() vs .subscribe() demo
|
|
323
|
+
bun examples/example.ts # comprehensive demo
|
|
324
|
+
bun test # 93 tests
|
|
271
325
|
```
|
|
272
326
|
|
|
273
327
|
---
|
|
@@ -294,7 +348,8 @@ bun test # 91 tests
|
|
|
294
348
|
| `entity.update(data)` | Update entity in-place |
|
|
295
349
|
| `entity.delete()` | Delete entity |
|
|
296
350
|
| **Reactivity** | |
|
|
297
|
-
| `
|
|
351
|
+
| `db.table.on(cb, opts?)` | Stream new rows one at a time, in order |
|
|
352
|
+
| `select().subscribe(cb, opts?)` | Watch query result snapshot (all mutations) |
|
|
298
353
|
|
|
299
354
|
## License
|
|
300
355
|
|
package/dist/index.js
CHANGED
|
@@ -334,7 +334,7 @@ class QueryBuilder {
|
|
|
334
334
|
try {
|
|
335
335
|
const fpRows = this.executor(fingerprintSQL.sql, fingerprintSQL.params, true);
|
|
336
336
|
const fpRow = fpRows[0];
|
|
337
|
-
const rev = this.revisionGetter?.() ?? 0;
|
|
337
|
+
const rev = this.revisionGetter?.() ?? "0";
|
|
338
338
|
const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
|
|
339
339
|
if (currentFingerprint !== lastFingerprint) {
|
|
340
340
|
lastFingerprint = currentFingerprint;
|
|
@@ -4659,6 +4659,7 @@ class _Database {
|
|
|
4659
4659
|
_revisions = {};
|
|
4660
4660
|
constructor(dbFile, schemas, options = {}) {
|
|
4661
4661
|
this.db = new SqliteDatabase(dbFile);
|
|
4662
|
+
this.db.run("PRAGMA journal_mode = WAL");
|
|
4662
4663
|
this.db.run("PRAGMA foreign_keys = ON");
|
|
4663
4664
|
this.schemas = schemas;
|
|
4664
4665
|
this.options = options;
|
|
@@ -4679,6 +4680,7 @@ class _Database {
|
|
|
4679
4680
|
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
4680
4681
|
delete: (id) => this.delete(entityName, id),
|
|
4681
4682
|
select: (...cols) => this._createQueryBuilder(entityName, cols),
|
|
4683
|
+
on: (callback, options2) => this._createOnStream(entityName, callback, options2),
|
|
4682
4684
|
_tableName: entityName
|
|
4683
4685
|
};
|
|
4684
4686
|
this[key] = accessor;
|
|
@@ -4724,7 +4726,28 @@ class _Database {
|
|
|
4724
4726
|
this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
|
|
4725
4727
|
}
|
|
4726
4728
|
_getRevision(entityName) {
|
|
4727
|
-
|
|
4729
|
+
const rev = this._revisions[entityName] ?? 0;
|
|
4730
|
+
const dataVersion = this.db.query("PRAGMA data_version").get()?.data_version ?? 0;
|
|
4731
|
+
return `${rev}:${dataVersion}`;
|
|
4732
|
+
}
|
|
4733
|
+
_createOnStream(entityName, callback, options) {
|
|
4734
|
+
const { interval = 500 } = options ?? {};
|
|
4735
|
+
const maxRow = this.db.query(`SELECT MAX(id) as _max FROM "${entityName}"`).get();
|
|
4736
|
+
let lastMaxId = maxRow?._max ?? 0;
|
|
4737
|
+
let lastRevision = this._getRevision(entityName);
|
|
4738
|
+
const timer = setInterval(() => {
|
|
4739
|
+
const currentRevision = this._getRevision(entityName);
|
|
4740
|
+
if (currentRevision === lastRevision)
|
|
4741
|
+
return;
|
|
4742
|
+
lastRevision = currentRevision;
|
|
4743
|
+
const newRows = this.db.query(`SELECT * FROM "${entityName}" WHERE id > ? ORDER BY id ASC`).all(lastMaxId);
|
|
4744
|
+
for (const rawRow of newRows) {
|
|
4745
|
+
const entity = this._attachMethods(entityName, transformFromStorage(rawRow, this.schemas[entityName]));
|
|
4746
|
+
callback(entity);
|
|
4747
|
+
lastMaxId = rawRow.id;
|
|
4748
|
+
}
|
|
4749
|
+
}, interval);
|
|
4750
|
+
return () => clearInterval(timer);
|
|
4728
4751
|
}
|
|
4729
4752
|
insert(entityName, data) {
|
|
4730
4753
|
const schema = this.schemas[entityName];
|
package/package.json
CHANGED
package/src/database.ts
CHANGED
|
@@ -36,6 +36,7 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
36
36
|
|
|
37
37
|
constructor(dbFile: string, schemas: Schemas, options: DatabaseOptions = {}) {
|
|
38
38
|
this.db = new SqliteDatabase(dbFile);
|
|
39
|
+
this.db.run('PRAGMA journal_mode = WAL'); // WAL enables concurrent read + write
|
|
39
40
|
this.db.run('PRAGMA foreign_keys = ON');
|
|
40
41
|
this.schemas = schemas;
|
|
41
42
|
this.options = options;
|
|
@@ -56,6 +57,8 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
56
57
|
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
57
58
|
delete: (id) => this.delete(entityName, id),
|
|
58
59
|
select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
|
|
60
|
+
on: (callback: (row: any) => void, options?: { interval?: number }) =>
|
|
61
|
+
this._createOnStream(entityName, callback, options),
|
|
59
62
|
_tableName: entityName,
|
|
60
63
|
};
|
|
61
64
|
(this as any)[key] = accessor;
|
|
@@ -112,7 +115,7 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
// ===========================================================================
|
|
115
|
-
// Revision Tracking (in-memory
|
|
118
|
+
// Revision Tracking (in-memory + cross-process)
|
|
116
119
|
// ===========================================================================
|
|
117
120
|
|
|
118
121
|
/** Bump the revision counter for a table. Called on every write. */
|
|
@@ -120,9 +123,67 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
120
123
|
this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Get a composite revision string for a table.
|
|
128
|
+
*
|
|
129
|
+
* Combines two signals:
|
|
130
|
+
* - In-memory counter: catches writes from THIS process (our CRUD methods bump it)
|
|
131
|
+
* - PRAGMA data_version: catches writes from OTHER processes (SQLite bumps it
|
|
132
|
+
* whenever another connection commits, but NOT for the current connection)
|
|
133
|
+
*
|
|
134
|
+
* Together they detect ALL changes regardless of source, with zero disk overhead.
|
|
135
|
+
*/
|
|
136
|
+
public _getRevision(entityName: string): string {
|
|
137
|
+
const rev = this._revisions[entityName] ?? 0;
|
|
138
|
+
const dataVersion = (this.db.query('PRAGMA data_version').get() as any)?.data_version ?? 0;
|
|
139
|
+
return `${rev}:${dataVersion}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ===========================================================================
|
|
143
|
+
// Row Stream — .on(callback)
|
|
144
|
+
// ===========================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Stream new rows one at a time, in insertion order.
|
|
148
|
+
*
|
|
149
|
+
* Uses a watermark (last seen id) to query only `WHERE id > ?`.
|
|
150
|
+
* Checks revision + data_version first to avoid unnecessary queries.
|
|
151
|
+
*/
|
|
152
|
+
public _createOnStream(
|
|
153
|
+
entityName: string,
|
|
154
|
+
callback: (row: any) => void,
|
|
155
|
+
options?: { interval?: number },
|
|
156
|
+
): () => void {
|
|
157
|
+
const { interval = 500 } = options ?? {};
|
|
158
|
+
|
|
159
|
+
// Initialize watermark to current max id (only emit NEW rows)
|
|
160
|
+
const maxRow = this.db.query(`SELECT MAX(id) as _max FROM "${entityName}"`).get() as any;
|
|
161
|
+
let lastMaxId: number = maxRow?._max ?? 0;
|
|
162
|
+
let lastRevision: string = this._getRevision(entityName);
|
|
163
|
+
|
|
164
|
+
const timer = setInterval(() => {
|
|
165
|
+
// Fast check: did anything change?
|
|
166
|
+
const currentRevision = this._getRevision(entityName);
|
|
167
|
+
if (currentRevision === lastRevision) return;
|
|
168
|
+
lastRevision = currentRevision;
|
|
169
|
+
|
|
170
|
+
// Fetch new rows since watermark
|
|
171
|
+
const newRows = this.db.query(
|
|
172
|
+
`SELECT * FROM "${entityName}" WHERE id > ? ORDER BY id ASC`
|
|
173
|
+
).all(lastMaxId) as any[];
|
|
174
|
+
|
|
175
|
+
for (const rawRow of newRows) {
|
|
176
|
+
// Hydrate with entity methods and schema transforms
|
|
177
|
+
const entity = this._attachMethods(
|
|
178
|
+
entityName,
|
|
179
|
+
transformFromStorage(rawRow, this.schemas[entityName]!)
|
|
180
|
+
);
|
|
181
|
+
callback(entity);
|
|
182
|
+
lastMaxId = rawRow.id;
|
|
183
|
+
}
|
|
184
|
+
}, interval);
|
|
185
|
+
|
|
186
|
+
return () => clearInterval(timer);
|
|
126
187
|
}
|
|
127
188
|
|
|
128
189
|
// ===========================================================================
|
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 revisionGetter: (() =>
|
|
170
|
+
private revisionGetter: (() => string) | null;
|
|
171
171
|
|
|
172
172
|
constructor(
|
|
173
173
|
tableName: string,
|
|
@@ -175,7 +175,7 @@ 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
|
-
revisionGetter?: (() =>
|
|
178
|
+
revisionGetter?: (() => string) | null,
|
|
179
179
|
) {
|
|
180
180
|
this.tableName = tableName;
|
|
181
181
|
this.executor = executor;
|
|
@@ -459,9 +459,9 @@ 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 in-memory
|
|
463
|
-
// This
|
|
464
|
-
const rev = this.revisionGetter?.() ?? 0;
|
|
462
|
+
// Include revision in fingerprint (combines in-memory counter + PRAGMA data_version).
|
|
463
|
+
// This detects ALL changes: same-process and cross-process.
|
|
464
|
+
const rev = this.revisionGetter?.() ?? '0';
|
|
465
465
|
const currentFingerprint = `${fpRow?._cnt ?? 0}:${fpRow?._max ?? 0}:${rev}`;
|
|
466
466
|
|
|
467
467
|
if (currentFingerprint !== lastFingerprint) {
|
package/src/types.ts
CHANGED
|
@@ -143,6 +143,12 @@ export type NavEntityAccessor<
|
|
|
143
143
|
upsert: (conditions?: Partial<z.infer<S[Table & keyof S]>>, data?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>;
|
|
144
144
|
delete: (id: number) => void;
|
|
145
145
|
select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
|
|
146
|
+
/**
|
|
147
|
+
* Stream new rows one at a time, in insertion order.
|
|
148
|
+
* Only emits rows inserted AFTER subscription starts.
|
|
149
|
+
* @returns Unsubscribe function.
|
|
150
|
+
*/
|
|
151
|
+
on: (callback: (row: NavEntity<S, R, Table>) => void, options?: { interval?: number }) => () => void;
|
|
146
152
|
_tableName: string;
|
|
147
153
|
readonly _schema?: S[Table & keyof S];
|
|
148
154
|
};
|
|
@@ -168,6 +174,12 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
|
|
|
168
174
|
upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
|
|
169
175
|
delete: (id: number) => void;
|
|
170
176
|
select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
|
|
177
|
+
/**
|
|
178
|
+
* Stream new rows one at a time, in insertion order.
|
|
179
|
+
* Only emits rows inserted AFTER subscription starts.
|
|
180
|
+
* @returns Unsubscribe function.
|
|
181
|
+
*/
|
|
182
|
+
on: (callback: (row: AugmentedEntity<S>) => void, options?: { interval?: number }) => () => void;
|
|
171
183
|
_tableName: string;
|
|
172
184
|
readonly _schema?: S;
|
|
173
185
|
};
|