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 CHANGED
@@ -207,9 +207,37 @@ const db = new Database(':memory:', schemas, {
207
207
 
208
208
  ---
209
209
 
210
- ## Reactivity — `select().subscribe()`
210
+ ## Reactivity
211
211
 
212
- One API to watch any query for changes. Detects **all** mutations (inserts, updates, deletes) with zero disk overhead.
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 revision counter (free)
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
- 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.
278
+ Two signals combine to detect **all** changes from **any** source:
251
279
 
252
- | Operation | Detected | How |
280
+ | Signal | Catches | How |
253
281
  |---|---|---|
254
- | INSERT | | MAX(id) increases + revision bumps |
255
- | DELETE | | COUNT changes + revision bumps |
256
- | UPDATE | ✅ | revision bumps |
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/example.ts # comprehensive demo
270
- bun test # 91 tests
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
- | `select().subscribe(cb, opts?)` | Watch any query for changes (all mutations) |
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
- return this._revisions[entityName] ?? 0;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "Type-safe SQLite ORM for Bun — Zod schemas, fluent queries, auto relationships, zero SQL",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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, zero overhead)
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
- /** Get the current revision for a table. Used by QueryBuilder.subscribe() fingerprint. */
124
- public _getRevision(entityName: string): number {
125
- return this._revisions[entityName] ?? 0;
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
  // ===========================================================================
@@ -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: (() => number) | null;
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?: (() => number) | null,
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 revision in fingerprint.
463
- // This ensures ALL changes (insert/update/delete) are detected.
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
  };