sqlite-zod-orm 3.25.0 → 3.26.1

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
@@ -228,6 +228,21 @@ db.exec('UPDATE users SET score = 0 WHERE role = ?', 'guest');
228
228
  - Debug mode (SQL logging)
229
229
  - Raw SQL escape hatch
230
230
 
231
+ ## Contributing
232
+
233
+ SatiDB is opinionated by design. Before proposing new features, understand what it intentionally does **not** do:
234
+
235
+ | ❌ Don't add | Why |
236
+ |---|---|
237
+ | Tagged SQL templates | The whole point is "zero SQL" — `db.raw()` is the escape hatch |
238
+ | FTS5 wrapper | Wrapping it poorly is worse than not wrapping it — use `db.raw()` |
239
+ | Query middleware | `measure-fn` handles observability, `hooks` handle lifecycle |
240
+ | Cursor pagination | Offset pagination covers SQLite's single-process use case |
241
+ | Schema introspection API | Zod schemas are compile-time known — runtime reflection invites dynamic queries |
242
+ | Migration CLI | Auto-migration handles additive changes; use `db.exec()` for destructive ones |
243
+
244
+ **The rule:** if the query builder can already do it, don't add a new API surface for it.
245
+
231
246
  ## Requirements
232
247
 
233
248
  - **Bun** ≥ 1.0 (uses `bun:sqlite` native bindings)
package/dist/index.js CHANGED
@@ -23,8 +23,9 @@ var toAlpha = (num) => {
23
23
  } while (n >= 0);
24
24
  return result;
25
25
  };
26
- var maxResultLen = 80;
27
- var safeStringify = (value) => {
26
+ var maxResultLen = 0;
27
+ var safeStringify = (value, limit) => {
28
+ const cap = limit ?? maxResultLen;
28
29
  if (value === undefined)
29
30
  return "";
30
31
  if (value === null)
@@ -37,7 +38,9 @@ var safeStringify = (value) => {
37
38
  return value.toString();
38
39
  if (typeof value === "string") {
39
40
  const q = JSON.stringify(value);
40
- return q.length > maxResultLen ? q.slice(0, maxResultLen - 1) + '\u2026"' : q;
41
+ if (cap === 0)
42
+ return q;
43
+ return q.length > cap ? q.slice(0, cap - 1) + '\u2026"' : q;
41
44
  }
42
45
  try {
43
46
  const seen = new WeakSet;
@@ -53,7 +56,9 @@ var safeStringify = (value) => {
53
56
  return `${val}n`;
54
57
  return val;
55
58
  });
56
- return str.length > maxResultLen ? str.slice(0, maxResultLen) + "\u2026" : str;
59
+ if (cap === 0)
60
+ return str;
61
+ return str.length > cap ? str.slice(0, cap) + "\u2026" : str;
57
62
  } catch {
58
63
  return String(value);
59
64
  }
@@ -67,7 +72,7 @@ var formatDuration = (ms) => {
67
72
  const secs = Math.round(ms % 60000 / 1000);
68
73
  return `${mins}m ${secs}s`;
69
74
  };
70
- var timestamps = process.env.MEASURE_TIMESTAMPS === "1" || process.env.MEASURE_TIMESTAMPS === "true";
75
+ var timestamps = typeof process !== "undefined" && (process.env.MEASURE_TIMESTAMPS === "1" || process.env.MEASURE_TIMESTAMPS === "true");
71
76
  var ts = () => {
72
77
  if (!timestamps)
73
78
  return "";
@@ -78,7 +83,9 @@ var ts = () => {
78
83
  const ms = String(now.getMilliseconds()).padStart(3, "0");
79
84
  return `[${h}:${m}:${s}.${ms}] `;
80
85
  };
81
- var silent = process.env.MEASURE_SILENT === "1" || process.env.MEASURE_SILENT === "true";
86
+ var silent = typeof process !== "undefined" && (process.env.MEASURE_SILENT === "1" || process.env.MEASURE_SILENT === "true");
87
+ var dotEndLabel = true;
88
+ var dotChar = "\xB7";
82
89
  var logger = null;
83
90
  var buildActionLabel = (actionInternal) => {
84
91
  return typeof actionInternal === "object" && actionInternal !== null && "label" in actionInternal ? String(actionInternal.label) : String(actionInternal);
@@ -90,6 +97,20 @@ var extractBudget = (actionInternal) => {
90
97
  return Number(actionInternal.budget);
91
98
  return;
92
99
  };
100
+ var extractTimeout = (actionInternal) => {
101
+ if (typeof actionInternal !== "object" || actionInternal === null)
102
+ return;
103
+ if ("timeout" in actionInternal)
104
+ return Number(actionInternal.timeout);
105
+ return;
106
+ };
107
+ var extractMaxResultLength = (actionInternal) => {
108
+ if (typeof actionInternal !== "object" || actionInternal === null)
109
+ return;
110
+ if ("maxResultLength" in actionInternal)
111
+ return Number(actionInternal.maxResultLength);
112
+ return;
113
+ };
93
114
  var extractMeta = (actionInternal) => {
94
115
  if (typeof actionInternal !== "object" || actionInternal === null)
95
116
  return;
@@ -98,6 +119,8 @@ var extractMeta = (actionInternal) => {
98
119
  delete details.label;
99
120
  if ("budget" in details)
100
121
  delete details.budget;
122
+ if ("maxResultLength" in details)
123
+ delete details.maxResultLength;
101
124
  if (Object.keys(details).length === 0)
102
125
  return;
103
126
  return details;
@@ -126,16 +149,18 @@ var defaultLogger = (event, prefix) => {
126
149
  console.log(`${t}${id} ... ${event.label}${formatMeta(event.meta)}`);
127
150
  break;
128
151
  case "success": {
129
- const resultStr = event.result !== undefined ? safeStringify(event.result) : "";
152
+ const endLabel = dotEndLabel ? dotChar.repeat(event.label.length) : event.label;
153
+ const resultStr = event.result !== undefined ? safeStringify(event.result, event.maxResultLength) : "";
130
154
  const arrow = resultStr ? ` \u2192 ${resultStr}` : "";
131
155
  const budgetWarn = event.budget && event.duration > event.budget ? ` \u26A0 OVER BUDGET (${formatDuration(event.budget)})` : "";
132
- console.log(`${t}${id} \u2713 ${event.label} ${formatDuration(event.duration)}${arrow}${budgetWarn}`);
156
+ console.log(`${t}${id} ${endLabel} ${formatDuration(event.duration)}${arrow}${budgetWarn}`);
133
157
  break;
134
158
  }
135
159
  case "error": {
160
+ const endLabel = dotEndLabel ? dotChar.repeat(event.label.length) : event.label;
136
161
  const errorMsg = event.error instanceof Error ? event.error.message : String(event.error);
137
162
  const budgetWarn = event.budget && event.duration > event.budget ? ` \u26A0 OVER BUDGET (${formatDuration(event.budget)})` : "";
138
- console.log(`${t}${id} \u2717 ${event.label} ${formatDuration(event.duration)} (${errorMsg})${budgetWarn}`);
163
+ console.log(`${t}${id} \u2717 ${endLabel} ${formatDuration(event.duration)} (${errorMsg})${budgetWarn}`);
139
164
  if (event.error instanceof Error) {
140
165
  console.error(`${id}`, event.error.stack ?? event.error.message);
141
166
  if (event.error.cause) {
@@ -151,13 +176,14 @@ var defaultLogger = (event, prefix) => {
151
176
  break;
152
177
  }
153
178
  };
154
- var createNestedResolver = (isAsync, fullIdChain, childCounterRef, depth, resolver, prefix) => {
179
+ var createNestedResolver = (isAsync, fullIdChain, childCounterRef, depth, resolver, prefix, inheritedMaxLen) => {
155
180
  return (...args) => {
156
181
  const label = args[0];
157
182
  const fn = args[1];
183
+ const onError = args[2];
158
184
  if (typeof fn === "function") {
159
185
  const childParentChain = [...fullIdChain, childCounterRef.value++];
160
- return resolver(fn, label, childParentChain, depth + 1);
186
+ return resolver(fn, label, childParentChain, depth + 1, typeof onError === "function" ? onError : undefined, inheritedMaxLen);
161
187
  } else {
162
188
  emit({
163
189
  type: "annotation",
@@ -171,20 +197,24 @@ var createNestedResolver = (isAsync, fullIdChain, childCounterRef, depth, resolv
171
197
  };
172
198
  };
173
199
  var globalRootCounter = 0;
174
- var createMeasureImpl = (prefix, counterRef) => {
200
+ var createMeasureImpl = (prefix, counterRef, scopeOpts) => {
175
201
  const counter = counterRef ?? { get value() {
176
202
  return globalRootCounter;
177
203
  }, set value(v) {
178
204
  globalRootCounter = v;
179
205
  } };
206
+ const scopeMaxLen = scopeOpts?.maxResultLength;
180
207
  let _lastError = null;
181
- const _measureInternal = async (fnInternal, actionInternal, parentIdChain, depth) => {
208
+ const _measureInternal = async (fnInternal, actionInternal, parentIdChain, depth, onError, inheritedMaxLen) => {
182
209
  const start = performance.now();
183
210
  const childCounterRef = { value: 0 };
184
211
  const label = buildActionLabel(actionInternal);
185
212
  const budget = extractBudget(actionInternal);
186
- const currentId = toAlpha(parentIdChain.pop() ?? 0);
187
- const fullIdChain = [...parentIdChain, currentId];
213
+ const timeout = extractTimeout(actionInternal);
214
+ const localMaxLen = extractMaxResultLength(actionInternal);
215
+ const effectiveMaxLen = localMaxLen ?? inheritedMaxLen;
216
+ const currentId = toAlpha(Number(parentIdChain.pop() ?? 0));
217
+ const fullIdChain = [...parentIdChain.map(String), currentId];
188
218
  const idStr = fullIdChain.join("-");
189
219
  emit({
190
220
  type: "start",
@@ -193,27 +223,46 @@ var createMeasureImpl = (prefix, counterRef) => {
193
223
  depth,
194
224
  meta: extractMeta(actionInternal)
195
225
  }, prefix);
196
- const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, depth, _measureInternal, prefix);
226
+ const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, depth, _measureInternal, prefix, effectiveMaxLen);
197
227
  try {
198
- const result = await fnInternal(measureForNextLevel);
228
+ let result;
229
+ if (timeout && timeout > 0) {
230
+ result = await Promise.race([
231
+ fnInternal(measureForNextLevel),
232
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout (${formatDuration(timeout)})`)), timeout))
233
+ ]);
234
+ } else {
235
+ result = await fnInternal(measureForNextLevel);
236
+ }
199
237
  const duration = performance.now() - start;
200
- emit({ type: "success", id: idStr, label, depth, duration, result, budget }, prefix);
238
+ emit({ type: "success", id: idStr, label, depth, duration, result, budget, maxResultLength: effectiveMaxLen }, prefix);
201
239
  return result;
202
240
  } catch (error) {
203
241
  const duration = performance.now() - start;
204
- emit({ type: "error", id: idStr, label, depth, duration, error, budget }, prefix);
242
+ emit({ type: "error", id: idStr, label, depth, duration, error, budget, maxResultLength: effectiveMaxLen }, prefix);
205
243
  _lastError = error;
244
+ if (onError) {
245
+ try {
246
+ return onError(error);
247
+ } catch (onErrorError) {
248
+ emit({ type: "error", id: idStr, label: `${label} (onError)`, depth, duration: performance.now() - start, error: onErrorError, budget, maxResultLength: effectiveMaxLen }, prefix);
249
+ _lastError = onErrorError;
250
+ return null;
251
+ }
252
+ }
206
253
  return null;
207
254
  }
208
255
  };
209
- const _measureInternalSync = (fnInternal, actionInternal, parentIdChain, depth) => {
256
+ const _measureInternalSync = (fnInternal, actionInternal, parentIdChain, depth, _onError, inheritedMaxLen) => {
210
257
  const start = performance.now();
211
258
  const childCounterRef = { value: 0 };
212
259
  const label = buildActionLabel(actionInternal);
213
260
  const hasNested = fnInternal.length > 0;
214
261
  const budget = extractBudget(actionInternal);
215
- const currentId = toAlpha(parentIdChain.pop() ?? 0);
216
- const fullIdChain = [...parentIdChain, currentId];
262
+ const localMaxLen = extractMaxResultLength(actionInternal);
263
+ const effectiveMaxLen = localMaxLen ?? inheritedMaxLen;
264
+ const currentId = toAlpha(Number(parentIdChain.pop() ?? 0));
265
+ const fullIdChain = [...parentIdChain.map(String), currentId];
217
266
  const idStr = fullIdChain.join("-");
218
267
  if (hasNested) {
219
268
  emit({
@@ -224,22 +273,22 @@ var createMeasureImpl = (prefix, counterRef) => {
224
273
  meta: extractMeta(actionInternal)
225
274
  }, prefix);
226
275
  }
227
- const measureForNextLevel = createNestedResolver(false, fullIdChain, childCounterRef, depth, _measureInternalSync, prefix);
276
+ const measureForNextLevel = createNestedResolver(false, fullIdChain, childCounterRef, depth, _measureInternalSync, prefix, effectiveMaxLen);
228
277
  try {
229
278
  const result = fnInternal(measureForNextLevel);
230
279
  const duration = performance.now() - start;
231
- emit({ type: "success", id: idStr, label, depth, duration, result, budget }, prefix);
280
+ emit({ type: "success", id: idStr, label, depth, duration, result, budget, maxResultLength: effectiveMaxLen }, prefix);
232
281
  return result;
233
282
  } catch (error) {
234
283
  const duration = performance.now() - start;
235
- emit({ type: "error", id: idStr, label, depth, duration, error, budget }, prefix);
284
+ emit({ type: "error", id: idStr, label, depth, duration, error, budget, maxResultLength: effectiveMaxLen }, prefix);
236
285
  _lastError = error;
237
286
  return null;
238
287
  }
239
288
  };
240
- const measureFn = async (arg1, arg2) => {
289
+ const measureFn = async (arg1, arg2, arg3) => {
241
290
  if (typeof arg2 === "function") {
242
- return _measureInternal(arg2, arg1, [counter.value++], 0);
291
+ return _measureInternal(arg2, arg1, [counter.value++], 0, arg3, scopeMaxLen);
243
292
  } else {
244
293
  const currentId = toAlpha(counter.value++);
245
294
  emit({
@@ -349,7 +398,7 @@ var createMeasureImpl = (prefix, counterRef) => {
349
398
  };
350
399
  const measureSyncFn = (arg1, arg2) => {
351
400
  if (typeof arg2 === "function") {
352
- return _measureInternalSync(arg2, arg1, [counter.value++], 0);
401
+ return _measureInternalSync(arg2, arg1, [counter.value++], 0, undefined, scopeMaxLen);
353
402
  } else {
354
403
  const currentId = toAlpha(counter.value++);
355
404
  emit({
@@ -385,9 +434,9 @@ var createMeasureImpl = (prefix, counterRef) => {
385
434
  var globalInstance = createMeasureImpl();
386
435
  var measure = globalInstance.measure;
387
436
  var measureSync = globalInstance.measureSync;
388
- var createMeasure = (scopePrefix) => {
437
+ var createMeasure = (scopePrefix, opts) => {
389
438
  const scopeCounter = { value: 0 };
390
- const scoped = createMeasureImpl(scopePrefix, scopeCounter);
439
+ const scoped = createMeasureImpl(scopePrefix, scopeCounter, opts);
391
440
  return {
392
441
  ...scoped,
393
442
  resetCounter: () => {
@@ -5221,7 +5270,7 @@ function createQueryBuilder(ctx, entityName, initialCols) {
5221
5270
  const schema = ctx.schemas[entityName];
5222
5271
  const executor = (sql, params, raw) => {
5223
5272
  return ctx._m(`SQL: ${sql.slice(0, 60)}`, () => {
5224
- const rows = ctx.db.query(sql).all(...params);
5273
+ const rows = ctx._stmt(sql).all(...params);
5225
5274
  if (raw)
5226
5275
  return rows;
5227
5276
  return rows.map((row) => ctx.attachMethods(entityName, transformFromStorage(row, schema)));
@@ -5269,7 +5318,7 @@ function createQueryBuilder(ctx, entityName, initialCols) {
5269
5318
  if (belongsTo2) {
5270
5319
  const fk = belongsTo2.foreignKey;
5271
5320
  const placeholders = parentIds.map(() => "?").join(", ");
5272
- const childRows = ctx.db.query(`SELECT * FROM ${hasMany.to} WHERE ${fk} IN (${placeholders})`).all(...parentIds);
5321
+ const childRows = ctx._stmt(`SELECT * FROM ${hasMany.to} WHERE ${fk} IN (${placeholders})`).all(...parentIds);
5273
5322
  const groups = new Map;
5274
5323
  const childSchema = ctx.schemas[hasMany.to];
5275
5324
  for (const rawRow of childRows) {
@@ -5381,21 +5430,21 @@ function buildWhereClause(conditions, tablePrefix) {
5381
5430
 
5382
5431
  // src/crud.ts
5383
5432
  function getById(ctx, entityName, id) {
5384
- const row = ctx.db.query(`SELECT * FROM "${entityName}" WHERE id = ?`).get(id);
5433
+ const row = ctx._stmt(`SELECT * FROM "${entityName}" WHERE id = ?`).get(id);
5385
5434
  if (!row)
5386
5435
  return null;
5387
5436
  return ctx.attachMethods(entityName, transformFromStorage(row, ctx.schemas[entityName]));
5388
5437
  }
5389
5438
  function getOne(ctx, entityName, conditions) {
5390
5439
  const { clause, values } = ctx.buildWhereClause(conditions);
5391
- const row = ctx.db.query(`SELECT * FROM "${entityName}" ${clause} LIMIT 1`).get(...values);
5440
+ const row = ctx._stmt(`SELECT * FROM "${entityName}" ${clause} LIMIT 1`).get(...values);
5392
5441
  if (!row)
5393
5442
  return null;
5394
5443
  return ctx.attachMethods(entityName, transformFromStorage(row, ctx.schemas[entityName]));
5395
5444
  }
5396
5445
  function findMany(ctx, entityName, conditions = {}) {
5397
5446
  const { clause, values } = ctx.buildWhereClause(conditions);
5398
- const rows = ctx.db.query(`SELECT * FROM "${entityName}" ${clause}`).all(...values);
5447
+ const rows = ctx._stmt(`SELECT * FROM "${entityName}" ${clause}`).all(...values);
5399
5448
  return rows.map((row) => ctx.attachMethods(entityName, transformFromStorage(row, ctx.schemas[entityName])));
5400
5449
  }
5401
5450
  function insert(ctx, entityName, data) {
@@ -5419,7 +5468,7 @@ function insert(ctx, entityName, data) {
5419
5468
  const sql = columns.length === 0 ? `INSERT INTO "${entityName}" DEFAULT VALUES` : `INSERT INTO "${entityName}" (${quotedCols.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`;
5420
5469
  let lastId = 0;
5421
5470
  ctx._m(`SQL: ${sql.slice(0, 40)}`, () => {
5422
- const result = ctx.db.query(sql).run(...Object.values(transformed));
5471
+ const result = ctx._stmt(sql).run(...Object.values(transformed));
5423
5472
  lastId = result.lastInsertRowid;
5424
5473
  });
5425
5474
  const newEntity = getById(ctx, entityName, lastId);
@@ -5448,7 +5497,7 @@ function update(ctx, entityName, id, data) {
5448
5497
  const setClause = Object.keys(transformed).map((key) => `"${key}" = ?`).join(", ");
5449
5498
  const sql = `UPDATE "${entityName}" SET ${setClause} WHERE id = ?`;
5450
5499
  ctx._m(`SQL: UPDATE ${entityName} SET ...`, () => {
5451
- ctx.db.query(sql).run(...Object.values(transformed), id);
5500
+ ctx._stmt(sql).run(...Object.values(transformed), id);
5452
5501
  });
5453
5502
  const updated = getById(ctx, entityName, id);
5454
5503
  if (hooks?.afterUpdate && updated)
@@ -5466,7 +5515,7 @@ function updateWhere(ctx, entityName, data, conditions) {
5466
5515
  throw new Error("update().where() requires at least one condition");
5467
5516
  const setCols = Object.keys(transformed);
5468
5517
  const setClause = setCols.map((key) => `"${key}" = ?`).join(", ");
5469
- const result = ctx.db.query(`UPDATE "${entityName}" SET ${setClause} ${clause}`).run(...setCols.map((key) => transformed[key]), ...whereValues);
5518
+ const result = ctx._stmt(`UPDATE "${entityName}" SET ${setClause} ${clause}`).run(...setCols.map((key) => transformed[key]), ...whereValues);
5470
5519
  return result.changes ?? 0;
5471
5520
  }
5472
5521
  function createUpdateBuilder(ctx, entityName, data) {
@@ -5507,7 +5556,7 @@ function deleteEntity(ctx, entityName, id) {
5507
5556
  if (result === false)
5508
5557
  return;
5509
5558
  }
5510
- ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
5559
+ ctx._stmt(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
5511
5560
  if (hooks?.afterDelete)
5512
5561
  hooks.afterDelete(id);
5513
5562
  }
@@ -5518,11 +5567,11 @@ function deleteWhere(ctx, entityName, conditions) {
5518
5567
  if (ctx.softDeletes) {
5519
5568
  const now = new Date().toISOString();
5520
5569
  const sql2 = `UPDATE "${entityName}" SET "deletedAt" = ? ${clause}`;
5521
- const result2 = ctx._m(`SQL: ${sql2.slice(0, 50)}`, () => ctx.db.query(sql2).run(now, ...values));
5570
+ const result2 = ctx._m(`SQL: ${sql2.slice(0, 50)}`, () => ctx._stmt(sql2).run(now, ...values));
5522
5571
  return result2.changes ?? 0;
5523
5572
  }
5524
5573
  const sql = `DELETE FROM "${entityName}" ${clause}`;
5525
- const result = ctx._m(`SQL: ${sql.slice(0, 50)}`, () => ctx.db.query(sql).run(...values));
5574
+ const result = ctx._m(`SQL: ${sql.slice(0, 50)}`, () => ctx._stmt(sql).run(...values));
5526
5575
  return result.changes ?? 0;
5527
5576
  }
5528
5577
  function createDeleteBuilder(ctx, entityName) {
@@ -5561,7 +5610,7 @@ function insertMany(ctx, entityName, rows) {
5561
5610
  const columns = Object.keys(transformed);
5562
5611
  const quotedCols = columns.map((c) => `"${c}"`);
5563
5612
  const sql = columns.length === 0 ? `INSERT INTO "${entityName}" DEFAULT VALUES` : `INSERT INTO "${entityName}" (${quotedCols.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`;
5564
- const result = ctx.db.query(sql).run(...Object.values(transformed));
5613
+ const result = ctx._stmt(sql).run(...Object.values(transformed));
5565
5614
  ids2.push(result.lastInsertRowid);
5566
5615
  }
5567
5616
  return ids2;
@@ -5647,6 +5696,15 @@ class _Database {
5647
5696
  _pollTimer = null;
5648
5697
  _pollInterval;
5649
5698
  _measure;
5699
+ _stmtCache = new Map;
5700
+ _stmt(sql) {
5701
+ let stmt = this._stmtCache.get(sql);
5702
+ if (!stmt) {
5703
+ stmt = this.db.query(sql);
5704
+ this._stmtCache.set(sql, stmt);
5705
+ }
5706
+ return stmt;
5707
+ }
5650
5708
  _m(label, fn) {
5651
5709
  if (this._debug)
5652
5710
  return this._measure.measureSync.assert(label, fn);
@@ -5678,7 +5736,8 @@ class _Database {
5678
5736
  hooks: options.hooks ?? {},
5679
5737
  computed: options.computed ?? {},
5680
5738
  cascade: options.cascade ?? {},
5681
- _m: (label, fn) => this._m(label, fn)
5739
+ _m: (label, fn) => this._m(label, fn),
5740
+ _stmt: (sql) => this._stmt(sql)
5682
5741
  };
5683
5742
  this._m("Init tables", () => this.initializeTables());
5684
5743
  if (this._reactive)
@@ -5717,16 +5776,16 @@ class _Database {
5717
5776
  if (rel) {
5718
5777
  if (this._softDeletes) {
5719
5778
  const now = new Date().toISOString();
5720
- this.db.query(`UPDATE "${childTable}" SET "deletedAt" = ? WHERE "${rel.foreignKey}" = ?`).run(now, id);
5779
+ this._stmt(`UPDATE "${childTable}" SET "deletedAt" = ? WHERE "${rel.foreignKey}" = ?`).run(now, id);
5721
5780
  } else {
5722
- this.db.query(`DELETE FROM "${childTable}" WHERE "${rel.foreignKey}" = ?`).run(id);
5781
+ this._stmt(`DELETE FROM "${childTable}" WHERE "${rel.foreignKey}" = ?`).run(id);
5723
5782
  }
5724
5783
  }
5725
5784
  }
5726
5785
  }
5727
5786
  if (this._softDeletes) {
5728
5787
  const now = new Date().toISOString();
5729
- this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
5788
+ this._stmt(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
5730
5789
  if (hooks?.afterDelete)
5731
5790
  hooks.afterDelete(id);
5732
5791
  return;
@@ -5740,12 +5799,12 @@ class _Database {
5740
5799
  if (!this._softDeletes)
5741
5800
  throw new Error("restore() requires softDeletes: true");
5742
5801
  this._m(`${entityName}.restore(${id})`, () => {
5743
- this.db.query(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
5802
+ this._stmt(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
5744
5803
  });
5745
5804
  },
5746
5805
  select: (...cols) => createQueryBuilder(this._ctx, entityName, cols),
5747
5806
  count: () => this._m(`${entityName}.count`, () => {
5748
- const row = this.db.query(`SELECT COUNT(*) as count FROM "${entityName}"${this._softDeletes ? ' WHERE "deletedAt" IS NULL' : ""}`).get();
5807
+ const row = this._stmt(`SELECT COUNT(*) as count FROM "${entityName}"${this._softDeletes ? ' WHERE "deletedAt" IS NULL' : ""}`).get();
5749
5808
  return row?.count ?? 0;
5750
5809
  }),
5751
5810
  on: (event, callback) => {
@@ -5861,11 +5920,11 @@ class _Database {
5861
5920
  }
5862
5921
  }
5863
5922
  _processChanges() {
5864
- const head = this.db.query('SELECT MAX(id) as m FROM "_changes"').get();
5923
+ const head = this._stmt('SELECT MAX(id) as m FROM "_changes"').get();
5865
5924
  const maxId = head?.m ?? 0;
5866
5925
  if (maxId <= this._changeWatermark)
5867
5926
  return;
5868
- const changes = this.db.query('SELECT id, tbl, op, row_id FROM "_changes" WHERE id > ? ORDER BY id').all(this._changeWatermark);
5927
+ const changes = this._stmt('SELECT id, tbl, op, row_id FROM "_changes" WHERE id > ? ORDER BY id').all(this._changeWatermark);
5869
5928
  for (const change of changes) {
5870
5929
  const listeners = this._listeners.filter((l) => l.table === change.tbl && l.event === change.op);
5871
5930
  if (listeners.length > 0) {
@@ -5889,22 +5948,23 @@ class _Database {
5889
5948
  }
5890
5949
  this._changeWatermark = change.id;
5891
5950
  }
5892
- this.db.query('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
5951
+ this._stmt('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
5893
5952
  }
5894
5953
  transaction(callback) {
5895
5954
  return this._m("transaction", () => this.db.transaction(callback)());
5896
5955
  }
5897
5956
  close() {
5898
5957
  this._stopPolling();
5958
+ this._stmtCache.clear();
5899
5959
  this.db.close();
5900
5960
  }
5901
5961
  query(callback) {
5902
5962
  return this._m("query(proxy)", () => executeProxyQuery(this.schemas, callback, (sql, params) => {
5903
- return this.db.query(sql).all(...params);
5963
+ return this._stmt(sql).all(...params);
5904
5964
  }));
5905
5965
  }
5906
5966
  raw(sql, ...params) {
5907
- return this._m(`raw: ${sql.slice(0, 60)}`, () => this.db.query(sql).all(...params));
5967
+ return this._m(`raw: ${sql.slice(0, 60)}`, () => this._stmt(sql).all(...params));
5908
5968
  }
5909
5969
  exec(sql, ...params) {
5910
5970
  this._m(`exec: ${sql.slice(0, 60)}`, () => this.db.run(sql, ...params));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sqlite-zod-orm",
3
- "version": "3.25.0",
3
+ "version": "3.26.1",
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",
@@ -49,10 +49,10 @@
49
49
  "typescript": "^5.0.0"
50
50
  },
51
51
  "dependencies": {
52
- "measure-fn": "^3.3.0",
52
+ "measure-fn": "^3.10.0",
53
53
  "zod": "^3.25.67"
54
54
  },
55
55
  "engines": {
56
56
  "bun": ">=1.0.0"
57
57
  }
58
- }
58
+ }
package/src/context.ts CHANGED
@@ -46,4 +46,10 @@ export interface DatabaseContext {
46
46
  * When debug is off, executes fn directly with zero overhead.
47
47
  */
48
48
  _m<T>(label: string, fn: () => T): T;
49
+
50
+ /**
51
+ * Get a cached prepared statement. Compiles SQL once, reuses on subsequent calls.
52
+ * Falls back to `db.query(sql)` if the statement was finalized.
53
+ */
54
+ _stmt(sql: string): ReturnType<SqliteDatabase['query']>;
49
55
  }
package/src/crud.ts CHANGED
@@ -14,21 +14,21 @@ import type { DatabaseContext } from './context';
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
16
  export function getById(ctx: DatabaseContext, entityName: string, id: number): AugmentedEntity<any> | null {
17
- const row = ctx.db.query(`SELECT * FROM "${entityName}" WHERE id = ?`).get(id) as any;
17
+ const row = ctx._stmt(`SELECT * FROM "${entityName}" WHERE id = ?`).get(id) as any;
18
18
  if (!row) return null;
19
19
  return ctx.attachMethods(entityName, transformFromStorage(row, ctx.schemas[entityName]!));
20
20
  }
21
21
 
22
22
  export function getOne(ctx: DatabaseContext, entityName: string, conditions: Record<string, any>): AugmentedEntity<any> | null {
23
23
  const { clause, values } = ctx.buildWhereClause(conditions);
24
- const row = ctx.db.query(`SELECT * FROM "${entityName}" ${clause} LIMIT 1`).get(...values) as any;
24
+ const row = ctx._stmt(`SELECT * FROM "${entityName}" ${clause} LIMIT 1`).get(...values) as any;
25
25
  if (!row) return null;
26
26
  return ctx.attachMethods(entityName, transformFromStorage(row, ctx.schemas[entityName]!));
27
27
  }
28
28
 
29
29
  export function findMany(ctx: DatabaseContext, entityName: string, conditions: Record<string, any> = {}): AugmentedEntity<any>[] {
30
30
  const { clause, values } = ctx.buildWhereClause(conditions);
31
- const rows = ctx.db.query(`SELECT * FROM "${entityName}" ${clause}`).all(...values);
31
+ const rows = ctx._stmt(`SELECT * FROM "${entityName}" ${clause}`).all(...values);
32
32
  return rows.map((row: any) =>
33
33
  ctx.attachMethods(entityName, transformFromStorage(row, ctx.schemas[entityName]!))
34
34
  );
@@ -68,7 +68,7 @@ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, enti
68
68
 
69
69
  let lastId = 0;
70
70
  ctx._m(`SQL: ${sql.slice(0, 40)}`, () => {
71
- const result = ctx.db.query(sql).run(...Object.values(transformed));
71
+ const result = ctx._stmt(sql).run(...Object.values(transformed));
72
72
  lastId = result.lastInsertRowid as number;
73
73
  });
74
74
  const newEntity = getById(ctx, entityName, lastId);
@@ -103,7 +103,7 @@ export function update<T extends Record<string, any>>(ctx: DatabaseContext, enti
103
103
  const setClause = Object.keys(transformed).map(key => `"${key}" = ?`).join(', ');
104
104
  const sql = `UPDATE "${entityName}" SET ${setClause} WHERE id = ?`;
105
105
  ctx._m(`SQL: UPDATE ${entityName} SET ...`, () => {
106
- ctx.db.query(sql).run(...Object.values(transformed), id);
106
+ ctx._stmt(sql).run(...Object.values(transformed), id);
107
107
  });
108
108
 
109
109
  const updated = getById(ctx, entityName, id);
@@ -125,7 +125,7 @@ export function updateWhere(ctx: DatabaseContext, entityName: string, data: Reco
125
125
 
126
126
  const setCols = Object.keys(transformed);
127
127
  const setClause = setCols.map(key => `"${key}" = ?`).join(', ');
128
- const result = ctx.db.query(`UPDATE "${entityName}" SET ${setClause} ${clause}`).run(
128
+ const result = ctx._stmt(`UPDATE "${entityName}" SET ${setClause} ${clause}`).run(
129
129
  ...setCols.map(key => transformed[key]),
130
130
  ...whereValues
131
131
  );
@@ -181,7 +181,7 @@ export function deleteEntity(ctx: DatabaseContext, entityName: string, id: numbe
181
181
  if (result === false) return;
182
182
  }
183
183
 
184
- ctx.db.query(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
184
+ ctx._stmt(`DELETE FROM "${entityName}" WHERE id = ?`).run(id);
185
185
 
186
186
  // afterDelete hook
187
187
  if (hooks?.afterDelete) hooks.afterDelete(id);
@@ -196,12 +196,12 @@ export function deleteWhere(ctx: DatabaseContext, entityName: string, conditions
196
196
  // Soft delete: set deletedAt instead of removing rows
197
197
  const now = new Date().toISOString();
198
198
  const sql = `UPDATE "${entityName}" SET "deletedAt" = ? ${clause}`;
199
- const result = ctx._m(`SQL: ${sql.slice(0, 50)}`, () => ctx.db.query(sql).run(now, ...values));
199
+ const result = ctx._m(`SQL: ${sql.slice(0, 50)}`, () => ctx._stmt(sql).run(now, ...values));
200
200
  return (result as any).changes ?? 0;
201
201
  }
202
202
 
203
203
  const sql = `DELETE FROM "${entityName}" ${clause}`;
204
- const result = ctx._m(`SQL: ${sql.slice(0, 50)}`, () => ctx.db.query(sql).run(...values));
204
+ const result = ctx._m(`SQL: ${sql.slice(0, 50)}`, () => ctx._stmt(sql).run(...values));
205
205
  return (result as any).changes ?? 0;
206
206
  }
207
207
 
@@ -247,7 +247,7 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
247
247
  const sql = columns.length === 0
248
248
  ? `INSERT INTO "${entityName}" DEFAULT VALUES`
249
249
  : `INSERT INTO "${entityName}" (${quotedCols.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
250
- const result = ctx.db.query(sql).run(...Object.values(transformed));
250
+ const result = ctx._stmt(sql).run(...Object.values(transformed));
251
251
  ids.push(result.lastInsertRowid as number);
252
252
  }
253
253
  return ids;
package/src/database.ts CHANGED
@@ -67,6 +67,19 @@ class _Database<Schemas extends SchemaMap> {
67
67
  /** Scoped measure-fn instance for instrumentation. */
68
68
  private _measure: ReturnType<typeof createMeasure>;
69
69
 
70
+ /** Prepared statement cache — avoids re-compiling identical SQL. */
71
+ private _stmtCache = new Map<string, ReturnType<SqliteDatabase['query']>>();
72
+
73
+ /** Get or create a cached prepared statement. */
74
+ private _stmt(sql: string): ReturnType<SqliteDatabase['query']> {
75
+ let stmt = this._stmtCache.get(sql);
76
+ if (!stmt) {
77
+ stmt = this.db.query(sql);
78
+ this._stmtCache.set(sql, stmt);
79
+ }
80
+ return stmt;
81
+ }
82
+
70
83
  /**
71
84
  * Conditional measurement helper — wraps with measure-fn only when debug is on.
72
85
  * When debug is off, executes fn directly with zero overhead.
@@ -105,6 +118,7 @@ class _Database<Schemas extends SchemaMap> {
105
118
  computed: options.computed ?? {},
106
119
  cascade: options.cascade ?? {},
107
120
  _m: <T>(label: string, fn: () => T): T => this._m(label, fn),
121
+ _stmt: (sql: string) => this._stmt(sql),
108
122
  };
109
123
 
110
124
  this._m('Init tables', () => this.initializeTables());
@@ -146,9 +160,9 @@ class _Database<Schemas extends SchemaMap> {
146
160
  if (rel) {
147
161
  if (this._softDeletes) {
148
162
  const now = new Date().toISOString();
149
- this.db.query(`UPDATE "${childTable}" SET "deletedAt" = ? WHERE "${rel.foreignKey}" = ?`).run(now, id);
163
+ this._stmt(`UPDATE "${childTable}" SET "deletedAt" = ? WHERE "${rel.foreignKey}" = ?`).run(now, id);
150
164
  } else {
151
- this.db.query(`DELETE FROM "${childTable}" WHERE "${rel.foreignKey}" = ?`).run(id);
165
+ this._stmt(`DELETE FROM "${childTable}" WHERE "${rel.foreignKey}" = ?`).run(id);
152
166
  }
153
167
  }
154
168
  }
@@ -156,7 +170,7 @@ class _Database<Schemas extends SchemaMap> {
156
170
 
157
171
  if (this._softDeletes) {
158
172
  const now = new Date().toISOString();
159
- this.db.query(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
173
+ this._stmt(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`).run(now, id);
160
174
  if (hooks?.afterDelete) hooks.afterDelete(id);
161
175
  return;
162
176
  }
@@ -168,12 +182,12 @@ class _Database<Schemas extends SchemaMap> {
168
182
  restore: ((id: number) => {
169
183
  if (!this._softDeletes) throw new Error('restore() requires softDeletes: true');
170
184
  this._m(`${entityName}.restore(${id})`, () => {
171
- this.db.query(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
185
+ this._stmt(`UPDATE "${entityName}" SET "deletedAt" = NULL WHERE id = ?`).run(id);
172
186
  });
173
187
  }) as any,
174
188
  select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
175
189
  count: () => this._m(`${entityName}.count`, () => {
176
- const row = this.db.query(`SELECT COUNT(*) as count FROM "${entityName}"${this._softDeletes ? ' WHERE "deletedAt" IS NULL' : ''}`).get() as any;
190
+ const row = this._stmt(`SELECT COUNT(*) as count FROM "${entityName}"${this._softDeletes ? ' WHERE "deletedAt" IS NULL' : ''}`).get() as any;
177
191
  return row?.count ?? 0;
178
192
  }),
179
193
  on: (event: ChangeEvent, callback: (row: any) => void | Promise<void>) => {
@@ -339,11 +353,11 @@ class _Database<Schemas extends SchemaMap> {
339
353
  */
340
354
  private _processChanges(): void {
341
355
  // Fast path: check if anything changed at all (single scalar, index-only)
342
- const head = this.db.query('SELECT MAX(id) as m FROM "_changes"').get() as any;
356
+ const head = this._stmt('SELECT MAX(id) as m FROM "_changes"').get() as any;
343
357
  const maxId: number = head?.m ?? 0;
344
358
  if (maxId <= this._changeWatermark) return;
345
359
 
346
- const changes = this.db.query(
360
+ const changes = this._stmt(
347
361
  'SELECT id, tbl, op, row_id FROM "_changes" WHERE id > ? ORDER BY id'
348
362
  ).all(this._changeWatermark) as { id: number; tbl: string; op: string; row_id: number }[];
349
363
 
@@ -374,7 +388,7 @@ class _Database<Schemas extends SchemaMap> {
374
388
  }
375
389
 
376
390
  // Clean up consumed changes
377
- this.db.query('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
391
+ this._stmt('DELETE FROM "_changes" WHERE id <= ?').run(this._changeWatermark);
378
392
  }
379
393
 
380
394
  // =========================================================================
@@ -385,9 +399,10 @@ class _Database<Schemas extends SchemaMap> {
385
399
  return this._m('transaction', () => this.db.transaction(callback)());
386
400
  }
387
401
 
388
- /** Close the database: stops polling and releases the SQLite handle. */
402
+ /** Close the database: stops polling, clears cache, and releases the SQLite handle. */
389
403
  public close(): void {
390
404
  this._stopPolling();
405
+ this._stmtCache.clear();
391
406
  this.db.close();
392
407
  }
393
408
 
@@ -403,7 +418,7 @@ class _Database<Schemas extends SchemaMap> {
403
418
  this.schemas,
404
419
  callback as any,
405
420
  (sql: string, params: any[]) => {
406
- return this.db.query(sql).all(...params) as T[];
421
+ return this._stmt(sql).all(...params) as T[];
407
422
  },
408
423
  ));
409
424
  }
@@ -414,7 +429,7 @@ class _Database<Schemas extends SchemaMap> {
414
429
 
415
430
  /** Execute a raw SQL query and return results. */
416
431
  public raw<T = any>(sql: string, ...params: any[]): T[] {
417
- return this._m(`raw: ${sql.slice(0, 60)}`, () => this.db.query(sql).all(...params) as T[]);
432
+ return this._m(`raw: ${sql.slice(0, 60)}`, () => this._stmt(sql).all(...params) as T[]);
418
433
  }
419
434
 
420
435
  /** Execute a raw SQL statement (INSERT/UPDATE/DELETE) without returning rows. */
package/src/query.ts CHANGED
@@ -40,7 +40,7 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
40
40
 
41
41
  const executor = (sql: string, params: any[], raw: boolean): any[] => {
42
42
  return ctx._m(`SQL: ${sql.slice(0, 60)}`, () => {
43
- const rows = ctx.db.query(sql).all(...params);
43
+ const rows = ctx._stmt(sql).all(...params);
44
44
  if (raw) return rows;
45
45
  return rows.map((row: any) => ctx.attachMethods(entityName, transformFromStorage(row, schema)));
46
46
  });
@@ -103,7 +103,7 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
103
103
  if (belongsTo) {
104
104
  const fk = belongsTo.foreignKey;
105
105
  const placeholders = parentIds.map(() => '?').join(', ');
106
- const childRows = ctx.db.query(
106
+ const childRows = ctx._stmt(
107
107
  `SELECT * FROM ${hasMany.to} WHERE ${fk} IN (${placeholders})`
108
108
  ).all(...parentIds) as any[];
109
109