turbine-orm 0.32.0 → 0.32.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.
@@ -20,6 +20,105 @@ import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fin
20
20
  import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
21
21
  /** Relations already warned about missing FK indexes (once per process, dev only). */
22
22
  const unindexedRelationWarned = new Set();
23
+ /**
24
+ * Dev-mode SQL-cache lockstep cross-check gate.
25
+ *
26
+ * The SQL template cache requires three code paths to enumerate where-clause
27
+ * keys identically: `fingerprintWhere` (builds the cache key),
28
+ * `buildWhereClause` (builds SQL + `$N` params on a MISS), and
29
+ * `collectWhereParams` (re-collects params on a HIT without rebuilding). They
30
+ * are synchronized only by convention, and drift has shipped silent
31
+ * wrong-results bugs before (permuted where-key order; an orderBy fingerprint
32
+ * collision). This check catches such drift loudly the moment a cache HIT
33
+ * happens by rebuilding the SQL + params fresh and comparing them against what
34
+ * the cache-hit path produced.
35
+ *
36
+ * Enabled only when `NODE_ENV !== 'production'` (same convention as the other
37
+ * dev-only guards in this file) AND `TURBINE_DISABLE_CACHE_CHECK !== '1'`. The
38
+ * env vars are read inline (not captured once) so tests and perf-sensitive dev
39
+ * traffic can toggle them per process. In production the check never runs, so
40
+ * the hot path is unchanged.
41
+ */
42
+ function cacheCrossCheckEnabled() {
43
+ return process.env.NODE_ENV !== 'production' && process.env.TURBINE_DISABLE_CACHE_CHECK !== '1';
44
+ }
45
+ /**
46
+ * Strict structural equality for a single SQL parameter value. Handles the
47
+ * value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
48
+ * `undefined`, `Date` (by time), `Buffer`/typed arrays (by bytes), arrays
49
+ * (`in` lists, pgvector arrays), and plain objects (JSON filter payloads).
50
+ */
51
+ function cacheParamValueEqual(a, b) {
52
+ if (a === b)
53
+ return true; // identical ref or equal primitive (covers matching null/undefined)
54
+ if (a === null || b === null || a === undefined || b === undefined)
55
+ return false;
56
+ const ta = typeof a;
57
+ if (ta !== typeof b)
58
+ return false;
59
+ if (ta !== 'object') {
60
+ // Primitives that failed `===`: only NaN is legitimately "equal" to itself.
61
+ return typeof a === 'number' && Number.isNaN(a) && Number.isNaN(b);
62
+ }
63
+ if (a instanceof Date || b instanceof Date) {
64
+ return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
65
+ }
66
+ const aView = ArrayBuffer.isView(a);
67
+ const bView = ArrayBuffer.isView(b);
68
+ if (aView || bView) {
69
+ if (!aView || !bView)
70
+ return false;
71
+ const ua = a;
72
+ const ub = b;
73
+ if (ua.byteLength !== ub.byteLength)
74
+ return false;
75
+ const va = new Uint8Array(ua.buffer, ua.byteOffset, ua.byteLength);
76
+ const vb = new Uint8Array(ub.buffer, ub.byteOffset, ub.byteLength);
77
+ for (let i = 0; i < va.length; i++) {
78
+ if (va[i] !== vb[i])
79
+ return false;
80
+ }
81
+ return true;
82
+ }
83
+ const aArr = Array.isArray(a);
84
+ const bArr = Array.isArray(b);
85
+ if (aArr || bArr) {
86
+ if (!aArr || !bArr)
87
+ return false;
88
+ const arrA = a;
89
+ const arrB = b;
90
+ if (arrA.length !== arrB.length)
91
+ return false;
92
+ for (let i = 0; i < arrA.length; i++) {
93
+ if (!cacheParamValueEqual(arrA[i], arrB[i]))
94
+ return false;
95
+ }
96
+ return true;
97
+ }
98
+ const objA = a;
99
+ const objB = b;
100
+ const keysA = Object.keys(objA);
101
+ const keysB = Object.keys(objB);
102
+ if (keysA.length !== keysB.length)
103
+ return false;
104
+ for (const k of keysA) {
105
+ if (!Object.hasOwn(objB, k))
106
+ return false;
107
+ if (!cacheParamValueEqual(objA[k], objB[k]))
108
+ return false;
109
+ }
110
+ return true;
111
+ }
112
+ /** Element-wise strict equality of two SQL parameter arrays. */
113
+ function cacheParamsEqual(a, b) {
114
+ if (a.length !== b.length)
115
+ return false;
116
+ for (let i = 0; i < a.length; i++) {
117
+ if (!cacheParamValueEqual(a[i], b[i]))
118
+ return false;
119
+ }
120
+ return true;
121
+ }
23
122
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
24
123
  export class QueryInterface {
25
124
  pool;
@@ -28,6 +127,15 @@ export class QueryInterface {
28
127
  tableMeta;
29
128
  /** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
30
129
  sqlTemplateCache = new LRUCache(1000);
130
+ /**
131
+ * Whether the most recent {@link acquireSql} call was a cache HIT. Read by
132
+ * {@link crossCheckCache} to decide whether to run the dev-mode lockstep
133
+ * cross-check. Safe as a single mutable flag: each `build*()` method calls
134
+ * `acquireSql` then `crossCheckCache` synchronously with no intervening
135
+ * `await` and no re-entrant `acquireSql` (relation subqueries are built
136
+ * inline, not through the top-level cache).
137
+ */
138
+ lastCacheHit = false;
31
139
  middlewares;
32
140
  defaultLimit;
33
141
  warnOnUnlimited;
@@ -346,24 +454,84 @@ export class QueryInterface {
346
454
  * On hit, increments counters and returns the cached entry.
347
455
  *
348
456
  * When `sqlCache` is disabled, always calls `build()` without caching.
457
+ *
458
+ * `build` receives a fresh `$N` param scratch array. On a miss those params
459
+ * are discarded (the returned params come from each call site's dedicated
460
+ * collect path); the array exists so the build path can number placeholders
461
+ * via `params.length` exactly as it does today. On a HIT, `build` is skipped
462
+ * here but re-run by {@link crossCheckCache} (dev only) with a fresh array to
463
+ * verify the collect path stayed in lockstep with the build path.
464
+ *
465
+ * Sets {@link lastCacheHit} so the caller's `crossCheckCache` knows whether a
466
+ * cross-check is warranted.
349
467
  */
350
468
  acquireSql(cacheKey, build) {
351
469
  if (!this.sqlCacheEnabled) {
352
- const sql = build();
470
+ this.lastCacheHit = false;
471
+ const sql = build([]);
353
472
  this.cacheMisses++;
354
473
  return { sql, name: sqlToPreparedName(sql) };
355
474
  }
356
475
  const cached = this.sqlTemplateCache.get(cacheKey);
357
476
  if (cached) {
358
477
  this.cacheHits++;
478
+ this.lastCacheHit = true;
359
479
  return cached;
360
480
  }
361
- const sql = build();
481
+ this.lastCacheHit = false;
482
+ const sql = build([]);
362
483
  const entry = { sql, name: sqlToPreparedName(sql) };
363
484
  this.sqlTemplateCache.set(cacheKey, entry);
364
485
  this.cacheMisses++;
365
486
  return entry;
366
487
  }
488
+ /**
489
+ * Dev-mode SQL-cache lockstep cross-check (see {@link cacheCrossCheckEnabled}).
490
+ *
491
+ * Runs only when the most recent {@link acquireSql} was a cache HIT and the
492
+ * check is enabled. Rebuilds the SQL + `$N` params fresh via the same `build`
493
+ * closure the caller passed to `acquireSql`, then compares:
494
+ * (a) the cached SQL string byte-for-byte against the fresh SQL, and
495
+ * (b) the params the cache-hit collect path produced against the fresh
496
+ * build-path params (length and element-wise strict deep-equal).
497
+ *
498
+ * A mismatch means the fingerprint / build / collect paths have drifted out
499
+ * of lockstep (the exact class of bug that has silently corrupted results
500
+ * before), so it throws a {@link ValidationError} (E003) naming the
501
+ * fingerprint, the operation, and both SQL strings (truncated). Failing loud
502
+ * in dev/test is the point. Production never reaches the comparison.
503
+ *
504
+ * @param op human label of the calling build method (for the error message).
505
+ * @param cacheKey the cache fingerprint that HIT.
506
+ * @param entry the cached SQL entry that will be executed.
507
+ * @param build the same closure passed to `acquireSql`; re-run here to
508
+ * capture the fresh build-path SQL + params.
509
+ * @param collectedParams the params the caller's collect path produced.
510
+ */
511
+ crossCheckCache(op, cacheKey, entry, build, collectedParams) {
512
+ if (!this.lastCacheHit)
513
+ return;
514
+ if (!cacheCrossCheckEnabled())
515
+ return;
516
+ const freshParams = [];
517
+ const freshSql = build(freshParams);
518
+ const sqlOk = freshSql === entry.sql;
519
+ const paramsOk = cacheParamsEqual(collectedParams, freshParams);
520
+ if (sqlOk && paramsOk)
521
+ return;
522
+ const truncate = (s) => (s.length > 300 ? `${s.slice(0, 300)}… (${s.length} chars total)` : s);
523
+ const details = [];
524
+ if (!sqlOk) {
525
+ details.push(`cached SQL and freshly-built SQL diverge:\n cached = <${truncate(entry.sql)}>\n fresh = <${truncate(freshSql)}>`);
526
+ }
527
+ if (!paramsOk) {
528
+ details.push(`cache-hit params and freshly-built params diverge (collected ${collectedParams.length}, built ${freshParams.length})`);
529
+ }
530
+ throw new ValidationError(`[turbine] SQL cache lockstep violation on ${op} (fingerprint "${cacheKey}"). ` +
531
+ `This is a Turbine internal invariant violation, please report it at ` +
532
+ `https://github.com/zvndev/turbine-orm/issues. The fingerprint, SQL-build, and ` +
533
+ `param-collect paths must enumerate where-clause keys identically.\n${details.join('\n')}`);
534
+ }
367
535
  /**
368
536
  * Reset the per-instance unlimited-query warning dedupe set.
369
537
  * Exposed for tests so a single test process can verify the warning fires
@@ -562,19 +730,22 @@ export class QueryInterface {
562
730
  });
563
731
  // Simple path: plain equality, no operators/null/OR
564
732
  if (!args.with && isSimpleWhere) {
565
- const entry = this.acquireSql(ck, () => {
733
+ const buildSql = (freshParams) => {
566
734
  const qt = this.q(this.table);
567
- const tempParams = whereKeys.map((k) => whereObj[k]);
568
- const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = ${this.p(i + 1)}`);
735
+ const whereClauses = whereKeys.map((k, i) => {
736
+ freshParams.push(whereObj[k]);
737
+ return `${this.toSqlColumn(k)} = ${this.p(i + 1)}`;
738
+ });
569
739
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
570
740
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
571
- void tempParams; // params are positional, SQL is value-invariant
572
741
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
573
- });
742
+ };
743
+ const entry = this.acquireSql(ck, buildSql);
574
744
  // Collect params (same order as build)
575
745
  for (const k of whereKeys) {
576
746
  params.push(whereObj[k]);
577
747
  }
748
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
578
749
  return {
579
750
  sql: entry.sql,
580
751
  params,
@@ -588,16 +759,17 @@ export class QueryInterface {
588
759
  }
589
760
  // General path (with operators, null, OR, with clause)
590
761
  if (!args.with) {
591
- const entry = this.acquireSql(ck, () => {
592
- const freshParams = [];
762
+ const buildSql = (freshParams) => {
593
763
  const clause = this.buildWhereClause(whereObj, freshParams);
594
764
  const whereSql = clause ? ` WHERE ${clause}` : '';
595
765
  const qt = this.q(this.table);
596
766
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
597
767
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
598
- });
768
+ };
769
+ const entry = this.acquireSql(ck, buildSql);
599
770
  // Collect params
600
771
  this.collectWhereParams(whereObj, params);
772
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
601
773
  return {
602
774
  sql: entry.sql,
603
775
  params,
@@ -614,16 +786,17 @@ export class QueryInterface {
614
786
  // 1. buildWhere pushes where params
615
787
  // 2. buildSelectWithRelations pushes relation params to same array
616
788
  // We must preserve this exact order.
617
- const entry = this.acquireSql(ck, () => {
618
- const freshParams = [];
789
+ const buildSql = (freshParams) => {
619
790
  const clause = this.buildWhereClause(whereObj, freshParams);
620
791
  const whereSql = clause ? ` WHERE ${clause}` : '';
621
792
  const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
622
793
  return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
623
- });
794
+ };
795
+ const entry = this.acquireSql(ck, buildSql);
624
796
  // Collect params in exact build order: where first, then with-clause relations
625
797
  this.collectWhereParams(whereObj, params);
626
798
  this.collectWithParams(args.with, params);
799
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
627
800
  const parseWith = this.makeNestedParser(args.with);
628
801
  return {
629
802
  sql: entry.sql,
@@ -688,7 +861,7 @@ export class QueryInterface {
688
861
  if (this.warnedTables.has(this.table))
689
862
  return;
690
863
  this.warnedTables.add(this.table);
691
- console.warn(`[turbine] warning: findMany on "${this.table}" has no limit this will fetch every row. ` +
864
+ console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: this will fetch every row. ` +
692
865
  'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
693
866
  }
694
867
  /**
@@ -745,15 +918,24 @@ export class QueryInterface {
745
918
  .sort()
746
919
  .join(',')
747
920
  : '';
748
- const distinctFp = args?.distinct ? args.distinct.slice().sort().join(',') : '';
921
+ // distinct must fingerprint in USER order: the SQL emits `DISTINCT ON` in
922
+ // the caller's column order, so a permuted array rebuilds different SQL and
923
+ // must not collapse onto the same cache entry (would trip the cross-check).
924
+ const distinctFp = args?.distinct ? args.distinct.join(',') : '';
749
925
  const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
750
- const limitFp = effectiveLimit !== undefined ? '1' : '0';
751
- const offsetFp = args?.offset !== undefined ? '1' : '0';
926
+ // On engines that inline the literal LIMIT/OFFSET into the SQL text
927
+ // (dialect.inlineLimitOffset, MySQL), the value is part of the SQL, not the
928
+ // params, so it MUST be part of the fingerprint or two different limits share
929
+ // one cached statement (silent wrong row counts). Parameterized engines
930
+ // (PG/SQLite/SQL Server, whose buildLimitOffset uses placeholders) keep the
931
+ // presence-only fingerprint so the cache is not needlessly fragmented.
932
+ const inlinePagination = this.dialect.inlineLimitOffset === true;
933
+ const limitFp = effectiveLimit !== undefined ? (inlinePagination ? `v${effectiveLimit}` : '1') : '0';
934
+ const offsetFp = args?.offset !== undefined ? (inlinePagination ? `v${args.offset}` : '1') : '0';
752
935
  const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
753
936
  const params = [];
754
- const entry = this.acquireSql(ck, () => {
755
- // Fresh build generates SQL and populates freshParams
756
- const freshParams = [];
937
+ const buildSql = (freshParams) => {
938
+ // Fresh build: generates SQL and populates freshParams
757
939
  const { sql: freshWhereSql } = hasWhere
758
940
  ? (() => {
759
941
  const clause = this.buildWhereClause(whereObj, freshParams);
@@ -833,7 +1015,8 @@ export class QueryInterface {
833
1015
  }
834
1016
  sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
835
1017
  return sql;
836
- });
1018
+ };
1019
+ const entry = this.acquireSql(ck, buildSql);
837
1020
  // Collect params in exact build order:
838
1021
  // 1. WHERE params (includes the AND-merged global filter, if any)
839
1022
  if (hasWhere) {
@@ -864,6 +1047,7 @@ export class QueryInterface {
864
1047
  if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
865
1048
  params.push(Number(args.offset));
866
1049
  }
1050
+ this.crossCheckCache('findMany', ck, entry, buildSql, params);
867
1051
  // Build the row parser once (positional shapes are computed here, not per row).
868
1052
  const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
869
1053
  return {
@@ -1189,8 +1373,7 @@ export class QueryInterface {
1189
1373
  const whereFp = this.fingerprintWhere(whereObj);
1190
1374
  const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1191
1375
  const params = [];
1192
- const buildSql = () => {
1193
- const freshParams = [];
1376
+ const buildSql = (freshParams) => {
1194
1377
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1195
1378
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1196
1379
  if (lock) {
@@ -1214,13 +1397,15 @@ export class QueryInterface {
1214
1397
  };
1215
1398
  let sql;
1216
1399
  let preparedName;
1400
+ let cacheEntry;
1217
1401
  if (ck) {
1218
- const entry = this.acquireSql(ck, buildSql);
1219
- sql = entry.sql;
1220
- preparedName = entry.name;
1402
+ cacheEntry = this.acquireSql(ck, buildSql);
1403
+ sql = cacheEntry.sql;
1404
+ preparedName = cacheEntry.name;
1221
1405
  }
1222
1406
  else {
1223
- sql = buildSql();
1407
+ // optimisticLock path: value-variant version check → uncacheable, no cross-check.
1408
+ sql = buildSql([]);
1224
1409
  }
1225
1410
  // Collect params: SET first, then WHERE, then version check (same order as fresh build)
1226
1411
  this.collectSetParams(dataObj, params);
@@ -1228,6 +1413,9 @@ export class QueryInterface {
1228
1413
  if (lock) {
1229
1414
  params.push(lock.expected);
1230
1415
  }
1416
+ if (ck && cacheEntry) {
1417
+ this.crossCheckCache('update', ck, cacheEntry, buildSql, params);
1418
+ }
1231
1419
  return {
1232
1420
  sql,
1233
1421
  params,
@@ -1359,8 +1547,7 @@ export class QueryInterface {
1359
1547
  const whereFp = this.fingerprintWhere(whereObj);
1360
1548
  const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
1361
1549
  const params = [];
1362
- const entry = this.acquireSql(ck, () => {
1363
- const freshParams = [];
1550
+ const buildSql = (freshParams) => {
1364
1551
  const clause = this.buildWhereClause(whereObj, freshParams);
1365
1552
  const whereSql = clause ? ` WHERE ${clause}` : '';
1366
1553
  // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
@@ -1368,8 +1555,10 @@ export class QueryInterface {
1368
1555
  return this.dialect.buildDeleteStatement
1369
1556
  ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1370
1557
  : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1371
- });
1558
+ };
1559
+ const entry = this.acquireSql(ck, buildSql);
1372
1560
  this.collectWhereParams(whereObj, params);
1561
+ this.crossCheckCache('delete', ck, entry, buildSql, params);
1373
1562
  return {
1374
1563
  sql: entry.sql,
1375
1564
  params,
@@ -1497,16 +1686,17 @@ export class QueryInterface {
1497
1686
  const whereFp = this.fingerprintWhere(whereObj);
1498
1687
  const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1499
1688
  const params = [];
1500
- const entry = this.acquireSql(ck, () => {
1501
- const freshParams = [];
1689
+ const buildSql = (freshParams) => {
1502
1690
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1503
1691
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1504
1692
  const whereClause = this.buildWhereClause(whereObj, freshParams);
1505
1693
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1506
1694
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1507
- });
1695
+ };
1696
+ const entry = this.acquireSql(ck, buildSql);
1508
1697
  this.collectSetParams(dataObj, params);
1509
1698
  this.collectWhereParams(whereObj, params);
1699
+ this.crossCheckCache('updateMany', ck, entry, buildSql, params);
1510
1700
  return {
1511
1701
  sql: entry.sql,
1512
1702
  params,
@@ -1533,13 +1723,14 @@ export class QueryInterface {
1533
1723
  const whereFp = this.fingerprintWhere(whereObj);
1534
1724
  const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
1535
1725
  const params = [];
1536
- const entry = this.acquireSql(ck, () => {
1537
- const freshParams = [];
1726
+ const buildSql = (freshParams) => {
1538
1727
  const clause = this.buildWhereClause(whereObj, freshParams);
1539
1728
  const whereSql = clause ? ` WHERE ${clause}` : '';
1540
1729
  return `DELETE FROM ${this.q(this.table)}${whereSql}`;
1541
- });
1730
+ };
1731
+ const entry = this.acquireSql(ck, buildSql);
1542
1732
  this.collectWhereParams(whereObj, params);
1733
+ this.crossCheckCache('deleteMany', ck, entry, buildSql, params);
1543
1734
  return {
1544
1735
  sql: entry.sql,
1545
1736
  params,
@@ -1566,15 +1757,16 @@ export class QueryInterface {
1566
1757
  const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
1567
1758
  const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
1568
1759
  const params = [];
1569
- const entry = this.acquireSql(ck, () => {
1570
- const freshParams = [];
1760
+ const buildSql = (freshParams) => {
1571
1761
  const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
1572
1762
  const whereSql = clause ? ` WHERE ${clause}` : '';
1573
1763
  return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1574
- });
1764
+ };
1765
+ const entry = this.acquireSql(ck, buildSql);
1575
1766
  if (hasWhere) {
1576
1767
  this.collectWhereParams(whereObj, params);
1577
1768
  }
1769
+ this.crossCheckCache('count', ck, entry, buildSql, params);
1578
1770
  return {
1579
1771
  sql: entry.sql,
1580
1772
  params,
@@ -2903,9 +3095,11 @@ export class QueryInterface {
2903
3095
  const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
2904
3096
  subParts.push(`o=${oEntries.join(',')}`);
2905
3097
  }
2906
- // limit presence
3098
+ // limit presence, but on inline-pagination engines (MySQL) the literal
3099
+ // value is baked into the subquery SQL, so fingerprint the value there or
3100
+ // `{limit:3}` and `{limit:5}` would share one cached statement.
2907
3101
  if (opts.limit !== undefined) {
2908
- subParts.push('l=1');
3102
+ subParts.push(this.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
2909
3103
  }
2910
3104
  // nested with (recurse)
2911
3105
  if (opts.with) {
@@ -85,10 +85,19 @@ export interface QueryInterfaceOptions {
85
85
  preparedStatements?: boolean;
86
86
  /**
87
87
  * Enable the SQL template cache. When true, repeated queries with the
88
- * same shape (same keys, operators, relations different values) reuse
88
+ * same shape (same keys, operators, relations, different values) reuse
89
89
  * cached SQL text instead of rebuilding from scratch.
90
90
  *
91
91
  * Default: `true`. Set to `false` as a nuclear kill switch.
92
+ *
93
+ * Dev-mode safety net: when `NODE_ENV !== 'production'`, every cache HIT is
94
+ * cross-checked by rebuilding the SQL + params fresh and comparing them
95
+ * against the cache-hit result, catching any drift between the fingerprint,
96
+ * SQL-build, and param-collect paths (which has silently corrupted results
97
+ * before). A mismatch throws a `ValidationError` (E003). This runs only
98
+ * outside production, so it never touches the production hot path. Set the
99
+ * env var `TURBINE_DISABLE_CACHE_CHECK=1` to opt out when dev traffic is
100
+ * perf-sensitive.
92
101
  */
93
102
  sqlCache?: boolean;
94
103
  /** SQL dialect implementation. Defaults to PostgreSQL. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {
@@ -75,7 +75,8 @@
75
75
  "lint:fix": "biome check --write src/",
76
76
  "format": "biome format --write src/",
77
77
  "prepublishOnly": "npm run build && npm run typecheck && npm run lint && npm run test:unit",
78
- "prepare": "husky",
78
+ "prepack": "node scripts/strip-prepare.mjs",
79
+ "postpack": "node scripts/restore-prepare.mjs",
79
80
  "size": "size-limit",
80
81
  "size:check": "size-limit",
81
82
  "test:watch": "tsx --watch --test src/test/*.test.ts",