sqlite-zod-orm 3.5.1 → 3.6.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
@@ -41,6 +41,7 @@ const db = new Database(':memory:', {
41
41
  relations: {
42
42
  books: { author_id: 'authors' },
43
43
  },
44
+ pollInterval: 300, // global default for .on() and .subscribe() (default: 500ms)
44
45
  });
45
46
  ```
46
47
 
package/dist/index.js CHANGED
@@ -177,7 +177,8 @@ class QueryBuilder {
177
177
  conditionResolver;
178
178
  revisionGetter;
179
179
  eagerLoader;
180
- constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader) {
180
+ defaultPollInterval;
181
+ constructor(tableName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader, pollInterval) {
181
182
  this.tableName = tableName;
182
183
  this.executor = executor;
183
184
  this.singleExecutor = singleExecutor;
@@ -185,6 +186,7 @@ class QueryBuilder {
185
186
  this.conditionResolver = conditionResolver ?? null;
186
187
  this.revisionGetter = revisionGetter ?? null;
187
188
  this.eagerLoader = eagerLoader ?? null;
189
+ this.defaultPollInterval = pollInterval ?? 500;
188
190
  this.iqo = {
189
191
  selects: [],
190
192
  wheres: [],
@@ -357,7 +359,7 @@ class QueryBuilder {
357
359
  return results[0]?.count ?? 0;
358
360
  }
359
361
  subscribe(callback, options = {}) {
360
- const { interval = 500, immediate = true } = options;
362
+ const { interval = this.defaultPollInterval, immediate = true } = options;
361
363
  const fingerprintSQL = this.buildFingerprintSQL();
362
364
  let lastFingerprint = null;
363
365
  const poll = () => {
@@ -4686,6 +4688,7 @@ class _Database {
4686
4688
  schemas;
4687
4689
  relationships;
4688
4690
  options;
4691
+ pollInterval;
4689
4692
  _revisions = {};
4690
4693
  constructor(dbFile, schemas, options = {}) {
4691
4694
  this.db = new SqliteDatabase(dbFile);
@@ -4693,6 +4696,7 @@ class _Database {
4693
4696
  this.db.run("PRAGMA foreign_keys = ON");
4694
4697
  this.schemas = schemas;
4695
4698
  this.options = options;
4699
+ this.pollInterval = options.pollInterval ?? 500;
4696
4700
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
4697
4701
  this.initializeTables();
4698
4702
  this.runMigrations();
@@ -4710,7 +4714,13 @@ class _Database {
4710
4714
  upsert: (conditions, data) => this.upsert(entityName, data, conditions),
4711
4715
  delete: (id) => this.delete(entityName, id),
4712
4716
  select: (...cols) => this._createQueryBuilder(entityName, cols),
4713
- on: (callback, options2) => this._createOnStream(entityName, callback, options2),
4717
+ on: (event, callback, options2) => {
4718
+ if (event === "insert")
4719
+ return this._createOnStream(entityName, callback, options2?.interval);
4720
+ if (event === "change")
4721
+ return this._createChangeStream(entityName, callback, options2?.interval);
4722
+ throw new Error(`Unknown event type: '${event}'. Supported: 'insert', 'change'`);
4723
+ },
4714
4724
  _tableName: entityName
4715
4725
  };
4716
4726
  this[key] = accessor;
@@ -4760,8 +4770,8 @@ class _Database {
4760
4770
  const dataVersion = this.db.query("PRAGMA data_version").get()?.data_version ?? 0;
4761
4771
  return `${rev}:${dataVersion}`;
4762
4772
  }
4763
- _createOnStream(entityName, callback, options) {
4764
- const { interval = 500 } = options ?? {};
4773
+ _createOnStream(entityName, callback, intervalOverride) {
4774
+ const interval = intervalOverride ?? this.pollInterval;
4765
4775
  const maxRow = this.db.query(`SELECT MAX(id) as _max FROM "${entityName}"`).get();
4766
4776
  let lastMaxId = maxRow?._max ?? 0;
4767
4777
  let lastRevision = this._getRevision(entityName);
@@ -4789,6 +4799,61 @@ class _Database {
4789
4799
  stopped = true;
4790
4800
  };
4791
4801
  }
4802
+ _createChangeStream(entityName, callback, intervalOverride) {
4803
+ const interval = intervalOverride ?? this.pollInterval;
4804
+ const allRows = this.db.query(`SELECT * FROM "${entityName}" ORDER BY id ASC`).all();
4805
+ let snapshot = new Map;
4806
+ let snapshotEntities = new Map;
4807
+ for (const row of allRows) {
4808
+ snapshot.set(row.id, JSON.stringify(row));
4809
+ snapshotEntities.set(row.id, row);
4810
+ }
4811
+ let lastRevision = this._getRevision(entityName);
4812
+ let stopped = false;
4813
+ const poll = async () => {
4814
+ if (stopped)
4815
+ return;
4816
+ const currentRevision = this._getRevision(entityName);
4817
+ if (currentRevision !== lastRevision) {
4818
+ lastRevision = currentRevision;
4819
+ const currentRows = this.db.query(`SELECT * FROM "${entityName}" ORDER BY id ASC`).all();
4820
+ const currentMap = new Map;
4821
+ const currentEntities = new Map;
4822
+ for (const row of currentRows) {
4823
+ currentMap.set(row.id, JSON.stringify(row));
4824
+ currentEntities.set(row.id, row);
4825
+ }
4826
+ for (const [id, json] of currentMap) {
4827
+ if (stopped)
4828
+ return;
4829
+ if (!snapshot.has(id)) {
4830
+ const entity = this._attachMethods(entityName, transformFromStorage(currentEntities.get(id), this.schemas[entityName]));
4831
+ await callback({ type: "insert", row: entity });
4832
+ } else if (snapshot.get(id) !== json) {
4833
+ const entity = this._attachMethods(entityName, transformFromStorage(currentEntities.get(id), this.schemas[entityName]));
4834
+ const oldEntity = this._attachMethods(entityName, transformFromStorage(snapshotEntities.get(id), this.schemas[entityName]));
4835
+ await callback({ type: "update", row: entity, oldRow: oldEntity });
4836
+ }
4837
+ }
4838
+ for (const [id] of snapshot) {
4839
+ if (stopped)
4840
+ return;
4841
+ if (!currentMap.has(id)) {
4842
+ const oldEntity = this._attachMethods(entityName, transformFromStorage(snapshotEntities.get(id), this.schemas[entityName]));
4843
+ await callback({ type: "delete", row: oldEntity, oldRow: oldEntity });
4844
+ }
4845
+ }
4846
+ snapshot = currentMap;
4847
+ snapshotEntities = currentEntities;
4848
+ }
4849
+ if (!stopped)
4850
+ setTimeout(poll, interval);
4851
+ };
4852
+ setTimeout(poll, interval);
4853
+ return () => {
4854
+ stopped = true;
4855
+ };
4856
+ }
4792
4857
  insert(entityName, data) {
4793
4858
  const schema = this.schemas[entityName];
4794
4859
  const validatedData = asZodObject(schema).passthrough().parse(data);
@@ -5044,7 +5109,7 @@ class _Database {
5044
5109
  }
5045
5110
  return null;
5046
5111
  };
5047
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader);
5112
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader, this.pollInterval);
5048
5113
  if (initialCols.length > 0)
5049
5114
  builder.select(...initialCols);
5050
5115
  return builder;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.5.1",
3
+ "version": "3.6.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
@@ -29,6 +29,7 @@ class _Database<Schemas extends SchemaMap> {
29
29
  private schemas: Schemas;
30
30
  private relationships: Relationship[];
31
31
  private options: DatabaseOptions;
32
+ private pollInterval: number;
32
33
 
33
34
  /** In-memory revision counter per table — bumps on every write (insert/update/delete).
34
35
  * Used by QueryBuilder.subscribe() fingerprint to detect ALL changes with zero overhead. */
@@ -40,6 +41,7 @@ class _Database<Schemas extends SchemaMap> {
40
41
  this.db.run('PRAGMA foreign_keys = ON');
41
42
  this.schemas = schemas;
42
43
  this.options = options;
44
+ this.pollInterval = options.pollInterval ?? 500;
43
45
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
44
46
  this.initializeTables();
45
47
  this.runMigrations();
@@ -57,8 +59,11 @@ class _Database<Schemas extends SchemaMap> {
57
59
  upsert: (conditions, data) => this.upsert(entityName, data, conditions),
58
60
  delete: (id) => this.delete(entityName, id),
59
61
  select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
60
- on: (callback: (row: any) => void, options?: { interval?: number }) =>
61
- this._createOnStream(entityName, callback, options),
62
+ on: (event: string, callback: (row: any) => void | Promise<void>, options?: { interval?: number }) => {
63
+ if (event === 'insert') return this._createOnStream(entityName, callback, options?.interval);
64
+ if (event === 'change') return this._createChangeStream(entityName, callback, options?.interval);
65
+ throw new Error(`Unknown event type: '${event}'. Supported: 'insert', 'change'`);
66
+ },
62
67
  _tableName: entityName,
63
68
  };
64
69
  (this as any)[key] = accessor;
@@ -152,9 +157,9 @@ class _Database<Schemas extends SchemaMap> {
152
157
  public _createOnStream(
153
158
  entityName: string,
154
159
  callback: (row: any) => void | Promise<void>,
155
- options?: { interval?: number },
160
+ intervalOverride?: number,
156
161
  ): () => void {
157
- const { interval = 500 } = options ?? {};
162
+ const interval = intervalOverride ?? this.pollInterval;
158
163
 
159
164
  // Initialize watermark to current max id (only emit NEW rows)
160
165
  const maxRow = this.db.query(`SELECT MAX(id) as _max FROM "${entityName}"`).get() as any;
@@ -199,6 +204,94 @@ class _Database<Schemas extends SchemaMap> {
199
204
  return () => { stopped = true; };
200
205
  }
201
206
 
207
+ /**
208
+ * Stream all mutations (insert / update / delete) as ChangeEvent objects.
209
+ *
210
+ * Maintains a full snapshot and diffs on each poll.
211
+ * Heavier than _createOnStream (which only tracks watermark), but catches all changes.
212
+ */
213
+ public _createChangeStream(
214
+ entityName: string,
215
+ callback: (event: any) => void | Promise<void>,
216
+ intervalOverride?: number,
217
+ ): () => void {
218
+ const interval = intervalOverride ?? this.pollInterval;
219
+
220
+ // Build initial snapshot: Map<id, serialized row>
221
+ const allRows = this.db.query(`SELECT * FROM "${entityName}" ORDER BY id ASC`).all() as any[];
222
+ let snapshot = new Map<number, string>();
223
+ let snapshotEntities = new Map<number, any>();
224
+ for (const row of allRows) {
225
+ snapshot.set(row.id, JSON.stringify(row));
226
+ snapshotEntities.set(row.id, row);
227
+ }
228
+
229
+ let lastRevision: string = this._getRevision(entityName);
230
+ let stopped = false;
231
+
232
+ const poll = async () => {
233
+ if (stopped) return;
234
+
235
+ const currentRevision = this._getRevision(entityName);
236
+ if (currentRevision !== lastRevision) {
237
+ lastRevision = currentRevision;
238
+
239
+ const currentRows = this.db.query(`SELECT * FROM "${entityName}" ORDER BY id ASC`).all() as any[];
240
+ const currentMap = new Map<number, string>();
241
+ const currentEntities = new Map<number, any>();
242
+ for (const row of currentRows) {
243
+ currentMap.set(row.id, JSON.stringify(row));
244
+ currentEntities.set(row.id, row);
245
+ }
246
+
247
+ // Detect inserts and updates
248
+ for (const [id, json] of currentMap) {
249
+ if (stopped) return;
250
+ if (!snapshot.has(id)) {
251
+ // INSERT
252
+ const entity = this._attachMethods(
253
+ entityName,
254
+ transformFromStorage(currentEntities.get(id), this.schemas[entityName]!)
255
+ );
256
+ await callback({ type: 'insert', row: entity });
257
+ } else if (snapshot.get(id) !== json) {
258
+ // UPDATE
259
+ const entity = this._attachMethods(
260
+ entityName,
261
+ transformFromStorage(currentEntities.get(id), this.schemas[entityName]!)
262
+ );
263
+ const oldEntity = this._attachMethods(
264
+ entityName,
265
+ transformFromStorage(snapshotEntities.get(id), this.schemas[entityName]!)
266
+ );
267
+ await callback({ type: 'update', row: entity, oldRow: oldEntity });
268
+ }
269
+ }
270
+
271
+ // Detect deletes
272
+ for (const [id] of snapshot) {
273
+ if (stopped) return;
274
+ if (!currentMap.has(id)) {
275
+ const oldEntity = this._attachMethods(
276
+ entityName,
277
+ transformFromStorage(snapshotEntities.get(id), this.schemas[entityName]!)
278
+ );
279
+ await callback({ type: 'delete', row: oldEntity, oldRow: oldEntity });
280
+ }
281
+ }
282
+
283
+ snapshot = currentMap;
284
+ snapshotEntities = currentEntities;
285
+ }
286
+
287
+ if (!stopped) setTimeout(poll, interval);
288
+ };
289
+
290
+ setTimeout(poll, interval);
291
+
292
+ return () => { stopped = true; };
293
+ }
294
+
202
295
  // ===========================================================================
203
296
  // CRUD
204
297
  // ===========================================================================
@@ -547,7 +640,7 @@ class _Database<Schemas extends SchemaMap> {
547
640
  return null;
548
641
  };
549
642
 
550
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader);
643
+ const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader, this.pollInterval);
551
644
  if (initialCols.length > 0) builder.select(...initialCols);
552
645
  return builder;
553
646
  }
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ export { Database } from './database';
7
7
  export type { DatabaseType } from './database';
8
8
 
9
9
  export type {
10
- SchemaMap, DatabaseOptions, Relationship,
10
+ SchemaMap, DatabaseOptions, Relationship, ChangeEvent,
11
11
  EntityAccessor, TypedAccessors, AugmentedEntity, UpdateBuilder,
12
12
  InferSchema, EntityData, IndexDef,
13
13
  ProxyColumns, ColumnRef,
@@ -174,6 +174,7 @@ export class QueryBuilder<T extends Record<string, any>> {
174
174
  private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
175
175
  private revisionGetter: (() => string) | null;
176
176
  private eagerLoader: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null;
177
+ private defaultPollInterval: number;
177
178
 
178
179
  constructor(
179
180
  tableName: string,
@@ -183,6 +184,7 @@ export class QueryBuilder<T extends Record<string, any>> {
183
184
  conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
184
185
  revisionGetter?: (() => string) | null,
185
186
  eagerLoader?: ((parentTable: string, relation: string, parentIds: number[]) => { key: string; groups: Map<number, any[]> } | null) | null,
187
+ pollInterval?: number,
186
188
  ) {
187
189
  this.tableName = tableName;
188
190
  this.executor = executor;
@@ -191,6 +193,7 @@ export class QueryBuilder<T extends Record<string, any>> {
191
193
  this.conditionResolver = conditionResolver ?? null;
192
194
  this.revisionGetter = revisionGetter ?? null;
193
195
  this.eagerLoader = eagerLoader ?? null;
196
+ this.defaultPollInterval = pollInterval ?? 500;
194
197
  this.iqo = {
195
198
  selects: [],
196
199
  wheres: [],
@@ -497,7 +500,7 @@ export class QueryBuilder<T extends Record<string, any>> {
497
500
  callback: (rows: T[]) => void,
498
501
  options: { interval?: number; immediate?: boolean } = {},
499
502
  ): () => void {
500
- const { interval = 500, immediate = true } = options;
503
+ const { interval = this.defaultPollInterval, immediate = true } = options;
501
504
 
502
505
  // Build the fingerprint SQL (COUNT + MAX(id)) using the same WHERE
503
506
  const fingerprintSQL = this.buildFingerprintSQL();
package/src/types.ts CHANGED
@@ -30,6 +30,11 @@ export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
30
30
  * `books: { author_id: 'authors' }` → FOREIGN KEY, lazy nav, fluent join.
31
31
  */
32
32
  relations?: R;
33
+ /**
34
+ * Global polling interval (ms) for `.on()` and `.subscribe()`.
35
+ * Can be overridden per-call. Default: 500ms.
36
+ */
37
+ pollInterval?: number;
33
38
  };
34
39
 
35
40
  export type Relationship = {
@@ -40,6 +45,13 @@ export type Relationship = {
40
45
  foreignKey: string;
41
46
  };
42
47
 
48
+ /** Change event emitted by .on('change', callback) */
49
+ export type ChangeEvent<T = any> = {
50
+ type: 'insert' | 'update' | 'delete';
51
+ row: T;
52
+ oldRow?: T; // present on 'update' and 'delete'
53
+ };
54
+
43
55
  // =============================================================================
44
56
  // Type Helpers
45
57
  // =============================================================================
@@ -144,12 +156,18 @@ export type NavEntityAccessor<
144
156
  delete: (id: number) => void;
145
157
  select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
146
158
  /**
147
- * Stream new rows one at a time, in insertion order.
148
- * Only emits rows inserted AFTER subscription starts.
159
+ * Listen for table events.
160
+ *
161
+ * `'insert'` — streams new rows one at a time, in insertion order.
162
+ * `'change'` — streams all mutations: { type: 'insert'|'update'|'delete', row, oldRow? }.
163
+ *
149
164
  * Callbacks are awaited — strict ordering guaranteed even with async handlers.
150
165
  * @returns Unsubscribe function.
151
166
  */
152
- on: (callback: (row: NavEntity<S, R, Table>) => void | Promise<void>, options?: { interval?: number }) => () => void;
167
+ on: {
168
+ (event: 'insert', callback: (row: NavEntity<S, R, Table>) => void | Promise<void>, options?: { interval?: number }): () => void;
169
+ (event: 'change', callback: (event: ChangeEvent<NavEntity<S, R, Table>>) => void | Promise<void>, options?: { interval?: number }): () => void;
170
+ };
153
171
  _tableName: string;
154
172
  readonly _schema?: S[Table & keyof S];
155
173
  };
@@ -176,12 +194,18 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
176
194
  delete: (id: number) => void;
177
195
  select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
178
196
  /**
179
- * Stream new rows one at a time, in insertion order.
180
- * Only emits rows inserted AFTER subscription starts.
197
+ * Listen for table events.
198
+ *
199
+ * `'insert'` — streams new rows one at a time, in insertion order.
200
+ * `'change'` — streams all mutations: { type: 'insert'|'update'|'delete', row, oldRow? }.
201
+ *
181
202
  * Callbacks are awaited — strict ordering guaranteed even with async handlers.
182
203
  * @returns Unsubscribe function.
183
204
  */
184
- on: (callback: (row: AugmentedEntity<S>) => void | Promise<void>, options?: { interval?: number }) => () => void;
205
+ on: {
206
+ (event: 'insert', callback: (row: AugmentedEntity<S>) => void | Promise<void>, options?: { interval?: number }): () => void;
207
+ (event: 'change', callback: (event: ChangeEvent<AugmentedEntity<S>>) => void | Promise<void>, options?: { interval?: number }): () => void;
208
+ };
185
209
  _tableName: string;
186
210
  readonly _schema?: S;
187
211
  };