sqlite-zod-orm 3.7.3 → 3.9.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/src/database.ts CHANGED
@@ -1,49 +1,86 @@
1
1
  /**
2
2
  * database.ts — Main Database class for sqlite-zod-orm
3
3
  *
4
- * Orchestrates schema-driven table creation, CRUD, relationships,
5
- * query builders, and event handling.
4
+ * Slim orchestrator: initializes the schema, creates tables/triggers,
5
+ * and delegates CRUD, entity augmentation, and query building to
6
+ * focused modules.
6
7
  */
7
8
  import { Database as SqliteDatabase } from 'bun:sqlite';
8
9
  import { z } from 'zod';
9
- import { QueryBuilder } from './query-builder';
10
- import { executeProxyQuery, type ProxyQueryResult } from './proxy-query';
10
+ import { QueryBuilder, executeProxyQuery, createQueryBuilder, type ProxyQueryResult } from './query';
11
11
  import type {
12
12
  SchemaMap, DatabaseOptions, Relationship, RelationsConfig,
13
13
  EntityAccessor, TypedAccessors, TypedNavAccessors, AugmentedEntity, UpdateBuilder,
14
- ProxyColumns, InferSchema,
14
+ ProxyColumns, InferSchema, ChangeEvent,
15
15
  } from './types';
16
16
  import { asZodObject } from './types';
17
17
  import {
18
18
  parseRelationsConfig,
19
19
  getStorableFields,
20
- zodTypeToSqlType, transformForStorage, transformFromStorage,
20
+ zodTypeToSqlType,
21
21
  } from './schema';
22
+ import { transformFromStorage } from './schema';
23
+ import type { DatabaseContext } from './context';
24
+ import { buildWhereClause } from './helpers';
25
+ import { attachMethods } from './entity';
26
+ import {
27
+ insert, insertMany, update, upsert, deleteEntity,
28
+ getById, getOne, findMany, updateWhere, createUpdateBuilder,
29
+ } from './crud';
22
30
 
23
31
  // =============================================================================
24
32
  // Database Class
25
33
  // =============================================================================
26
34
 
35
+ type Listener = {
36
+ table: string;
37
+ event: ChangeEvent;
38
+ callback: (row: any) => void | Promise<void>;
39
+ };
40
+
27
41
  class _Database<Schemas extends SchemaMap> {
28
42
  private db: SqliteDatabase;
43
+ private _reactive: boolean;
29
44
  private schemas: Schemas;
30
45
  private relationships: Relationship[];
31
46
  private options: DatabaseOptions;
32
- private pollInterval: number;
33
47
 
34
- /** In-memory revision counter per table — bumps on every write (insert/update/delete).
35
- * Used by QueryBuilder.subscribe() fingerprint to detect ALL changes with zero overhead. */
36
- private _revisions: Record<string, number> = {};
48
+ /** Shared context for extracted modules. */
49
+ private _ctx: DatabaseContext;
50
+
51
+ /** Registered change listeners. */
52
+ private _listeners: Listener[] = [];
53
+
54
+ /** Watermark: last processed change id from _changes table. */
55
+ private _changeWatermark: number = 0;
56
+
57
+ /** Global poll timer (single loop for all listeners). */
58
+ private _pollTimer: ReturnType<typeof setInterval> | null = null;
59
+
60
+ /** Poll interval in ms. */
61
+ private _pollInterval: number;
37
62
 
38
63
  constructor(dbFile: string, schemas: Schemas, options: DatabaseOptions = {}) {
39
64
  this.db = new SqliteDatabase(dbFile);
40
- this.db.run('PRAGMA journal_mode = WAL'); // WAL enables concurrent read + write
65
+ this.db.run('PRAGMA journal_mode = WAL');
41
66
  this.db.run('PRAGMA foreign_keys = ON');
42
67
  this.schemas = schemas;
43
68
  this.options = options;
44
- this.pollInterval = options.pollInterval ?? 500;
69
+ this._reactive = options.reactive !== false; // default true
70
+ this._pollInterval = options.pollInterval ?? 100;
45
71
  this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
72
+
73
+ // Build the context that extracted modules use
74
+ this._ctx = {
75
+ db: this.db,
76
+ schemas: this.schemas as SchemaMap,
77
+ relationships: this.relationships,
78
+ attachMethods: (name, entity) => attachMethods(this._ctx, name, entity),
79
+ buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
80
+ };
81
+
46
82
  this.initializeTables();
83
+ if (this._reactive) this.initializeChangeTracking();
47
84
  this.runMigrations();
48
85
  if (options.indexes) this.createIndexes(options.indexes);
49
86
 
@@ -51,54 +88,100 @@ class _Database<Schemas extends SchemaMap> {
51
88
  for (const entityName of Object.keys(schemas)) {
52
89
  const key = entityName as keyof Schemas;
53
90
  const accessor: EntityAccessor<Schemas[typeof key]> = {
54
- insert: (data) => this.insert(entityName, data),
91
+ insert: (data) => insert(this._ctx, entityName, data),
92
+ insertMany: (rows: any[]) => insertMany(this._ctx, entityName, rows),
55
93
  update: (idOrData: any, data?: any) => {
56
- if (typeof idOrData === 'number') return this.update(entityName, idOrData, data);
57
- return this._createUpdateBuilder(entityName, idOrData);
94
+ if (typeof idOrData === 'number') return update(this._ctx, entityName, idOrData, data);
95
+ return createUpdateBuilder(this._ctx, entityName, idOrData);
96
+ },
97
+ upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
98
+ delete: (id) => deleteEntity(this._ctx, entityName, id),
99
+ select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
100
+ on: (event: ChangeEvent, callback: (row: any) => void | Promise<void>) => {
101
+ return this._registerListener(entityName, event, callback);
58
102
  },
59
- upsert: (conditions, data) => this.upsert(entityName, data, conditions),
60
- delete: (id) => this.delete(entityName, id),
61
- select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
62
103
  _tableName: entityName,
63
104
  };
64
105
  (this as any)[key] = accessor;
65
106
  }
66
107
  }
67
108
 
68
- // ===========================================================================
109
+ // =========================================================================
69
110
  // Table Initialization & Migrations
70
- // ===========================================================================
111
+ // =========================================================================
71
112
 
72
113
  private initializeTables(): void {
73
114
  for (const [entityName, schema] of Object.entries(this.schemas)) {
74
115
  const storableFields = getStorableFields(schema);
75
- const columnDefs = storableFields.map(f => `${f.name} ${zodTypeToSqlType(f.type)}`);
116
+ const columnDefs = storableFields.map(f => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
76
117
  const constraints: string[] = [];
77
118
 
78
- // Add FOREIGN KEY constraints for FK columns declared in the schema
79
119
  const belongsToRels = this.relationships.filter(
80
120
  rel => rel.type === 'belongs-to' && rel.from === entityName
81
121
  );
82
122
  for (const rel of belongsToRels) {
83
- constraints.push(`FOREIGN KEY (${rel.foreignKey}) REFERENCES ${rel.to}(id) ON DELETE SET NULL`);
123
+ constraints.push(`FOREIGN KEY ("${rel.foreignKey}") REFERENCES "${rel.to}"(id) ON DELETE SET NULL`);
84
124
  }
85
125
 
86
126
  const allCols = columnDefs.join(', ');
87
127
  const allConstraints = constraints.length > 0 ? ', ' + constraints.join(', ') : '';
88
- this.db.run(`CREATE TABLE IF NOT EXISTS ${entityName} (id INTEGER PRIMARY KEY AUTOINCREMENT, ${allCols}${allConstraints})`);
128
+ this.db.run(`CREATE TABLE IF NOT EXISTS "${entityName}" (id INTEGER PRIMARY KEY AUTOINCREMENT, ${allCols}${allConstraints})`);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Initialize per-table change tracking using triggers.
134
+ *
135
+ * Creates a `_changes` table that logs every insert/update/delete with
136
+ * the table name, operation, and affected row id. This enables
137
+ * row-level change detection for the `on()` API.
138
+ */
139
+ private initializeChangeTracking(): void {
140
+ this.db.run(`CREATE TABLE IF NOT EXISTS "_changes" (
141
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
142
+ tbl TEXT NOT NULL,
143
+ op TEXT NOT NULL,
144
+ row_id INTEGER NOT NULL
145
+ )`);
146
+
147
+ for (const entityName of Object.keys(this.schemas)) {
148
+ // INSERT trigger — logs NEW.id
149
+ this.db.run(`CREATE TRIGGER IF NOT EXISTS "_trg_${entityName}_insert"
150
+ AFTER INSERT ON "${entityName}"
151
+ BEGIN
152
+ INSERT INTO "_changes" (tbl, op, row_id) VALUES ('${entityName}', 'insert', NEW.id);
153
+ END`);
154
+
155
+ // UPDATE trigger — logs NEW.id (post-update row)
156
+ this.db.run(`CREATE TRIGGER IF NOT EXISTS "_trg_${entityName}_update"
157
+ AFTER UPDATE ON "${entityName}"
158
+ BEGIN
159
+ INSERT INTO "_changes" (tbl, op, row_id) VALUES ('${entityName}', 'update', NEW.id);
160
+ END`);
161
+
162
+ // DELETE trigger — logs OLD.id (row that was deleted)
163
+ this.db.run(`CREATE TRIGGER IF NOT EXISTS "_trg_${entityName}_delete"
164
+ AFTER DELETE ON "${entityName}"
165
+ BEGIN
166
+ INSERT INTO "_changes" (tbl, op, row_id) VALUES ('${entityName}', 'delete', OLD.id);
167
+ END`);
89
168
  }
169
+
170
+ // Initialize watermark to current max (skip replaying historical changes)
171
+ const row = this.db.query('SELECT MAX(id) as maxId FROM "_changes"').get() as any;
172
+ this._changeWatermark = row?.maxId ?? 0;
90
173
  }
91
174
 
92
175
  private runMigrations(): void {
93
176
  for (const [entityName, schema] of Object.entries(this.schemas)) {
94
- const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all() as any[];
177
+ const existingColumns = this.db.query(`PRAGMA table_info("${entityName}")`).all() as any[];
95
178
  const existingNames = new Set(existingColumns.map(c => c.name));
96
179
 
97
180
  const storableFields = getStorableFields(schema);
98
181
  for (const field of storableFields) {
99
182
  if (!existingNames.has(field.name)) {
100
183
  const sqlType = zodTypeToSqlType(field.type);
101
- this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${sqlType}`);
184
+ this.db.run(`ALTER TABLE "${entityName}" ADD COLUMN "${field.name}" ${sqlType}`);
102
185
  }
103
186
  }
104
187
  }
@@ -109,389 +192,110 @@ class _Database<Schemas extends SchemaMap> {
109
192
  for (const def of indexDefs) {
110
193
  const cols = Array.isArray(def) ? def : [def];
111
194
  const idxName = `idx_${tableName}_${cols.join('_')}`;
112
- this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${tableName} (${cols.join(', ')})`);
195
+ this.db.run(`CREATE INDEX IF NOT EXISTS "${idxName}" ON "${tableName}" (${cols.map(c => `"${c}"`).join(', ')})`);
113
196
  }
114
197
  }
115
198
  }
116
199
 
117
- // ===========================================================================
118
- // Revision Tracking (in-memory + cross-process)
119
- // ===========================================================================
120
-
121
- /** Bump the revision counter for a table. Called on every write. */
122
- private _bumpRevision(entityName: string): void {
123
- this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
124
- }
200
+ // =========================================================================
201
+ // Change Listeners — db.table.on('insert' | 'update' | 'delete', cb)
202
+ // =========================================================================
125
203
 
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
- // CRUD
144
- // ===========================================================================
145
-
146
- private insert<T extends Record<string, any>>(entityName: string, data: Omit<T, 'id'>): AugmentedEntity<any> {
147
- const schema = this.schemas[entityName]!;
148
- const validatedData = asZodObject(schema).passthrough().parse(data);
149
- const transformed = transformForStorage(validatedData);
150
- const columns = Object.keys(transformed);
151
-
152
- const sql = columns.length === 0
153
- ? `INSERT INTO ${entityName} DEFAULT VALUES`
154
- : `INSERT INTO ${entityName} (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
155
-
156
- const result = this.db.query(sql).run(...Object.values(transformed));
157
- const newEntity = this._getById(entityName, result.lastInsertRowid as number);
158
- if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
159
-
160
- this._bumpRevision(entityName);
161
- return newEntity;
162
- }
163
-
164
- /** Internal: get a single entity by ID */
165
- private _getById(entityName: string, id: number): AugmentedEntity<any> | null {
166
- const row = this.db.query(`SELECT * FROM ${entityName} WHERE id = ?`).get(id) as any;
167
- if (!row) return null;
168
- return this._attachMethods(entityName, transformFromStorage(row, this.schemas[entityName]!));
169
- }
170
-
171
- /** Internal: get a single entity by conditions */
172
- private _getOne(entityName: string, conditions: Record<string, any>): AugmentedEntity<any> | null {
173
- const { clause, values } = this.buildWhereClause(conditions);
174
- const row = this.db.query(`SELECT * FROM ${entityName} ${clause} LIMIT 1`).get(...values) as any;
175
- if (!row) return null;
176
- return this._attachMethods(entityName, transformFromStorage(row, this.schemas[entityName]!));
177
- }
178
-
179
- /** Internal: find multiple entities by conditions */
180
- private _findMany(entityName: string, conditions: Record<string, any> = {}): AugmentedEntity<any>[] {
181
- const { clause, values } = this.buildWhereClause(conditions);
182
- const rows = this.db.query(`SELECT * FROM ${entityName} ${clause}`).all(...values);
183
- return rows.map((row: any) =>
184
- this._attachMethods(entityName, transformFromStorage(row, this.schemas[entityName]!))
185
- );
186
- }
187
-
188
- private update<T extends Record<string, any>>(entityName: string, id: number, data: Partial<Omit<T, 'id'>>): AugmentedEntity<any> | null {
189
- const schema = this.schemas[entityName]!;
190
- const validatedData = asZodObject(schema).partial().parse(data);
191
- const transformed = transformForStorage(validatedData);
192
- if (Object.keys(transformed).length === 0) return this._getById(entityName, id);
193
-
194
- const setClause = Object.keys(transformed).map(key => `${key} = ?`).join(', ');
195
- this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
196
-
197
- this._bumpRevision(entityName);
198
- const updatedEntity = this._getById(entityName, id);
199
- return updatedEntity;
200
- }
201
-
202
- private _updateWhere(entityName: string, data: Record<string, any>, conditions: Record<string, any>): number {
203
- const schema = this.schemas[entityName]!;
204
- const validatedData = asZodObject(schema).partial().parse(data);
205
- const transformed = transformForStorage(validatedData);
206
- if (Object.keys(transformed).length === 0) return 0;
207
-
208
- const { clause, values: whereValues } = this.buildWhereClause(conditions);
209
- if (!clause) throw new Error('update().where() requires at least one condition');
210
-
211
- const setCols = Object.keys(transformed);
212
- const setClause = setCols.map(key => `${key} = ?`).join(', ');
213
- const result = this.db.query(`UPDATE ${entityName} SET ${setClause} ${clause}`).run(
214
- ...setCols.map(key => transformed[key]),
215
- ...whereValues
216
- );
204
+ private _registerListener(table: string, event: ChangeEvent, callback: (row: any) => void | Promise<void>): () => void {
205
+ if (!this._reactive) {
206
+ throw new Error(
207
+ 'Change listeners are disabled. Set { reactive: true } (or omit it) in Database options to enable .on().'
208
+ );
209
+ }
217
210
 
218
- const affected = (result as any).changes ?? 0;
219
- if (affected > 0) this._bumpRevision(entityName);
220
- return affected;
221
- }
211
+ const listener: Listener = { table, event, callback };
212
+ this._listeners.push(listener);
213
+ this._startPolling();
222
214
 
223
- private _createUpdateBuilder(entityName: string, data: Record<string, any>): UpdateBuilder<any> {
224
- let _conditions: Record<string, any> = {};
225
- const builder: UpdateBuilder<any> = {
226
- where: (conditions) => { _conditions = { ..._conditions, ...conditions }; return builder; },
227
- exec: () => this._updateWhere(entityName, data, _conditions),
215
+ return () => {
216
+ const idx = this._listeners.indexOf(listener);
217
+ if (idx >= 0) this._listeners.splice(idx, 1);
218
+ if (this._listeners.length === 0) this._stopPolling();
228
219
  };
229
- return builder;
230
220
  }
231
221
 
232
- private upsert<T extends Record<string, any>>(entityName: string, data: any, conditions: any = {}): AugmentedEntity<any> {
233
- const hasId = data?.id && typeof data.id === 'number';
234
- const existing = hasId
235
- ? this._getById(entityName, data.id)
236
- : Object.keys(conditions ?? {}).length > 0
237
- ? this._getOne(entityName, conditions)
238
- : null;
239
-
240
- if (existing) {
241
- const updateData = { ...data };
242
- delete updateData.id;
243
- return this.update(entityName, existing.id, updateData) as AugmentedEntity<any>;
244
- }
245
- const insertData = { ...(conditions ?? {}), ...(data ?? {}) };
246
- delete insertData.id;
247
- return this.insert(entityName, insertData);
222
+ private _startPolling(): void {
223
+ if (this._pollTimer) return;
224
+ this._pollTimer = setInterval(() => this._processChanges(), this._pollInterval);
248
225
  }
249
226
 
250
- private delete(entityName: string, id: number): void {
251
- const entity = this._getById(entityName, id);
252
- if (entity) {
253
- this.db.query(`DELETE FROM ${entityName} WHERE id = ?`).run(id);
254
- this._bumpRevision(entityName);
227
+ private _stopPolling(): void {
228
+ if (this._pollTimer) {
229
+ clearInterval(this._pollTimer);
230
+ this._pollTimer = null;
255
231
  }
256
232
  }
257
233
 
258
- // ===========================================================================
259
- // Entity Methods
260
- // ===========================================================================
261
-
262
- private _attachMethods<T extends Record<string, any>>(
263
- entityName: string, entity: T
264
- ): AugmentedEntity<any> {
265
- const augmented = entity as any;
266
- augmented.update = (data: any) => this.update(entityName, entity.id, data);
267
- augmented.delete = () => this.delete(entityName, entity.id);
268
-
269
- // Attach lazy relationship navigation
270
- for (const rel of this.relationships) {
271
- if (rel.from === entityName && rel.type === 'belongs-to') {
272
- // book.author() lazy load parent via author_id FK
273
- augmented[rel.relationshipField] = () => {
274
- const fkValue = entity[rel.foreignKey];
275
- return fkValue ? this._getById(rel.to, fkValue) : null;
276
- };
277
- } else if (rel.from === entityName && rel.type === 'one-to-many') {
278
- // author.books() → lazy load children
279
- const belongsToRel = this.relationships.find(
280
- r => r.type === 'belongs-to' && r.from === rel.to && r.to === rel.from
281
- );
282
- if (belongsToRel) {
283
- const fk = belongsToRel.foreignKey;
284
- augmented[rel.relationshipField] = () => {
285
- return this._findMany(rel.to, { [fk]: entity.id });
286
- };
287
- }
288
- }
289
- }
290
-
291
- // Auto-persist proxy: setting a field auto-updates the DB row
292
- const storableFieldNames = new Set(getStorableFields(this.schemas[entityName]!).map(f => f.name));
293
- return new Proxy(augmented, {
294
- set: (target, prop: string, value) => {
295
- if (storableFieldNames.has(prop) && target[prop] !== value) {
296
- this.update(entityName, target.id, { [prop]: value });
297
- }
298
- target[prop] = value;
299
- return true;
300
- },
301
- get: (target, prop, receiver) => Reflect.get(target, prop, receiver),
302
- });
303
- }
234
+ /**
235
+ * Core change dispatch loop.
236
+ *
237
+ * Fast path: checks MAX(id) against watermark first — if equal,
238
+ * there are no new changes and we skip entirely (no row materialization).
239
+ * Only fetches actual change rows when something has changed.
240
+ */
241
+ private _processChanges(): void {
242
+ // Fast path: check if anything changed at all (single scalar, index-only)
243
+ const head = this.db.query('SELECT MAX(id) as m FROM "_changes"').get() as any;
244
+ const maxId: number = head?.m ?? 0;
245
+ if (maxId <= this._changeWatermark) return;
246
+
247
+ const changes = this.db.query(
248
+ 'SELECT id, tbl, op, row_id FROM "_changes" WHERE id > ? ORDER BY id'
249
+ ).all(this._changeWatermark) as { id: number; tbl: string; op: string; row_id: number }[];
250
+
251
+ for (const change of changes) {
252
+ const listeners = this._listeners.filter(
253
+ l => l.table === change.tbl && l.event === change.op
254
+ );
304
255
 
305
- // ===========================================================================
306
- // SQL Helpers
307
- // ===========================================================================
308
-
309
- private buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] } {
310
- const parts: string[] = [];
311
- const values: any[] = [];
312
-
313
- for (const key in conditions) {
314
- if (key.startsWith('$')) {
315
- if (key === '$or' && Array.isArray(conditions[key])) {
316
- const orBranches = conditions[key] as Record<string, any>[];
317
- const orParts: string[] = [];
318
- for (const branch of orBranches) {
319
- const sub = this.buildWhereClause(branch, tablePrefix);
320
- if (sub.clause) {
321
- orParts.push(`(${sub.clause.replace(/^WHERE /, '')})`);
322
- values.push(...sub.values);
256
+ if (listeners.length > 0) {
257
+ if (change.op === 'delete') {
258
+ // Row is gone — pass just the id
259
+ const payload = { id: change.row_id };
260
+ for (const l of listeners) {
261
+ try { l.callback(payload); } catch { /* listener error */ }
262
+ }
263
+ } else {
264
+ // insert or update re-fetch the current row
265
+ const row = getById(this._ctx, change.tbl, change.row_id);
266
+ if (row) {
267
+ for (const l of listeners) {
268
+ try { l.callback(row); } catch { /* listener error */ }
323
269
  }
324
270
  }
325
- if (orParts.length > 0) parts.push(`(${orParts.join(' OR ')})`);
326
271
  }
327
- continue;
328
272
  }
329
- const value = conditions[key];
330
- const fieldName = tablePrefix ? `${tablePrefix}.${key}` : key;
331
-
332
- if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
333
- const operator = Object.keys(value)[0];
334
- if (!operator?.startsWith('$')) {
335
- throw new Error(`Querying on nested object '${key}' not supported. Use operators like $gt.`);
336
- }
337
- const operand = value[operator];
338
-
339
- if (operator === '$in') {
340
- if (!Array.isArray(operand)) throw new Error(`$in for '${key}' requires an array`);
341
- if (operand.length === 0) { parts.push('1 = 0'); continue; }
342
- parts.push(`${fieldName} IN (${operand.map(() => '?').join(', ')})`);
343
- values.push(...operand.map((v: any) => transformForStorage({ v }).v));
344
- continue;
345
- }
346
273
 
347
- const sqlOp = ({ $gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=' } as Record<string, string>)[operator];
348
- if (!sqlOp) throw new Error(`Unsupported operator '${operator}' on '${key}'`);
349
- parts.push(`${fieldName} ${sqlOp} ?`);
350
- values.push(transformForStorage({ operand }).operand);
351
- } else {
352
- parts.push(`${fieldName} = ?`);
353
- values.push(transformForStorage({ value }).value);
354
- }
274
+ this._changeWatermark = change.id;
355
275
  }
356
276
 
357
- return { clause: parts.length > 0 ? `WHERE ${parts.join(' AND ')}` : '', values };
277
+ // Clean up consumed changes
278
+ this.db.run('DELETE FROM "_changes" WHERE id <= ?', this._changeWatermark);
358
279
  }
359
280
 
360
- // ===========================================================================
281
+ // =========================================================================
361
282
  // Transactions
362
- // ===========================================================================
283
+ // =========================================================================
363
284
 
364
285
  public transaction<T>(callback: () => T): T {
365
- try {
366
- this.db.run('BEGIN TRANSACTION');
367
- const result = callback();
368
- this.db.run('COMMIT');
369
- return result;
370
- } catch (error) {
371
- this.db.run('ROLLBACK');
372
- throw new Error(`Transaction failed: ${(error as Error).message}`);
373
- }
286
+ return this.db.transaction(callback)();
374
287
  }
375
288
 
376
- // ===========================================================================
377
- // Query Builders
378
- // ===========================================================================
379
-
380
- private _createQueryBuilder(entityName: string, initialCols: string[]): QueryBuilder<any> {
381
- const schema = this.schemas[entityName]!;
382
-
383
- const executor = (sql: string, params: any[], raw: boolean): any[] => {
384
- const rows = this.db.query(sql).all(...params);
385
- if (raw) return rows;
386
- return rows.map((row: any) => this._attachMethods(entityName, transformFromStorage(row, schema)));
387
- };
388
-
389
- const singleExecutor = (sql: string, params: any[], raw: boolean): any | null => {
390
- const results = executor(sql, params, raw);
391
- return results.length > 0 ? results[0] : null;
392
- };
393
-
394
- const joinResolver = (fromTable: string, toTable: string): { fk: string; pk: string } | null => {
395
- const belongsTo = this.relationships.find(
396
- r => r.type === 'belongs-to' && r.from === fromTable && r.to === toTable
397
- );
398
- if (belongsTo) return { fk: belongsTo.foreignKey, pk: 'id' };
399
- const reverse = this.relationships.find(
400
- r => r.type === 'belongs-to' && r.from === toTable && r.to === fromTable
401
- );
402
- if (reverse) return { fk: 'id', pk: reverse.foreignKey };
403
- return null;
404
- };
405
-
406
- // Pass revision getter — allows .subscribe() to detect ALL changes
407
- const revisionGetter = () => this._getRevision(entityName);
408
-
409
- // Condition resolver: { author: aliceEntity } → { author_id: 1 }
410
- const conditionResolver = (conditions: Record<string, any>): Record<string, any> => {
411
- const resolved: Record<string, any> = {};
412
- for (const [key, value] of Object.entries(conditions)) {
413
- // Detect entity references: objects with `id` and `delete` (augmented entities)
414
- if (value && typeof value === 'object' && typeof value.id === 'number' && typeof value.delete === 'function') {
415
- // Find a belongs-to relationship: entityName has a FK named `key_id` pointing to another table
416
- const fkCol = key + '_id';
417
- const rel = this.relationships.find(
418
- r => r.type === 'belongs-to' && r.from === entityName && r.foreignKey === fkCol
419
- );
420
- if (rel) {
421
- resolved[fkCol] = value.id;
422
- } else {
423
- // Fallback: try any relationship that matches the key as the nav name
424
- const relByNav = this.relationships.find(
425
- r => r.type === 'belongs-to' && r.from === entityName && r.to === key + 's'
426
- ) || this.relationships.find(
427
- r => r.type === 'belongs-to' && r.from === entityName && r.to === key
428
- );
429
- if (relByNav) {
430
- resolved[relByNav.foreignKey] = value.id;
431
- } else {
432
- resolved[key] = value; // pass through
433
- }
434
- }
435
- } else {
436
- resolved[key] = value;
437
- }
438
- }
439
- return resolved;
440
- };
441
-
442
- // Eager loader: resolves .with('books') → batch load children
443
- const eagerLoader = (parentTable: string, relation: string, parentIds: number[]): { key: string; groups: Map<number, any[]> } | null => {
444
- // 1. Try one-to-many: parentTable has-many relation (e.g., authors → books)
445
- const hasMany = this.relationships.find(
446
- r => r.type === 'one-to-many' && r.from === parentTable && r.relationshipField === relation
447
- );
448
- if (hasMany) {
449
- // Find the belongs-to FK on the child table
450
- const belongsTo = this.relationships.find(
451
- r => r.type === 'belongs-to' && r.from === hasMany.to && r.to === parentTable
452
- );
453
- if (belongsTo) {
454
- const fk = belongsTo.foreignKey;
455
- const placeholders = parentIds.map(() => '?').join(', ');
456
- const childRows = this.db.query(
457
- `SELECT * FROM ${hasMany.to} WHERE ${fk} IN (${placeholders})`
458
- ).all(...parentIds) as any[];
459
-
460
- const groups = new Map<number, any[]>();
461
- const childSchema = this.schemas[hasMany.to]!;
462
- for (const rawRow of childRows) {
463
- const entity = this._attachMethods(
464
- hasMany.to,
465
- transformFromStorage(rawRow, childSchema)
466
- );
467
- const parentId = rawRow[fk] as number;
468
- if (!groups.has(parentId)) groups.set(parentId, []);
469
- groups.get(parentId)!.push(entity);
470
- }
471
- return { key: relation, groups };
472
- }
473
- }
474
-
475
- // 2. Try belongs-to: parentTable belongs-to relation (e.g., books → author)
476
- const belongsTo = this.relationships.find(
477
- r => r.type === 'belongs-to' && r.from === parentTable && r.relationshipField === relation
478
- );
479
- if (belongsTo) {
480
- // Load parent entities and map by id
481
- const fkValues = [...new Set(parentIds)];
482
- // Actually we need FK values from parent rows, not parent IDs
483
- // This case is trickier — skip for now, belongs-to is already handled by lazy nav
484
- return null;
485
- }
486
-
487
- return null;
488
- };
489
-
490
- const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader, this.pollInterval);
491
- if (initialCols.length > 0) builder.select(...initialCols);
492
- return builder;
289
+ /** Close the database: stops polling and releases the SQLite handle. */
290
+ public close(): void {
291
+ this._stopPolling();
292
+ this.db.close();
493
293
  }
494
294
 
295
+ // =========================================================================
296
+ // Proxy Query
297
+ // =========================================================================
298
+
495
299
  /** Proxy callback query for complex SQL-like JOINs */
496
300
  public query<T extends Record<string, any> = Record<string, any>>(
497
301
  callback: (ctx: { [K in keyof Schemas]: ProxyColumns<InferSchema<Schemas[K]>> }) => ProxyQueryResult