turbine-orm 0.32.0 → 0.32.2

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/dist/cjs/powql.js CHANGED
@@ -618,7 +618,7 @@ class PowqlInterface {
618
618
  const limit = args.limit ?? args.take ?? this.defaultLimit;
619
619
  if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
620
620
  this.warnedUnlimited = true;
621
- console.warn(`[turbine] findMany on "${this.table}" has no limit this scans the whole table.`);
621
+ console.warn(`[turbine] findMany on "${this.table}" has no limit: this scans the whole table.`);
622
622
  }
623
623
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
624
624
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
@@ -1216,6 +1216,17 @@ class PowqlInterface {
1216
1216
  proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
1217
1217
  }
1218
1218
  const having = this.buildHaving(args.having, params);
1219
+ // groupBy aggregate ordering (`_count` / `_sum` / … keys) has no PowQL
1220
+ // equivalent here: `buildOrder` treats an `orderBy` key as a field ref, so
1221
+ // a bare `_count: 'desc'` would silently emit an invalid `._count` sort.
1222
+ // Refuse those keys explicitly; plain by-field ordering still flows through.
1223
+ if (args.orderBy) {
1224
+ for (const key of Object.keys(args.orderBy)) {
1225
+ if (key === '_count' || key === '_sum' || key === '_avg' || key === '_min' || key === '_max') {
1226
+ throw new errors_js_1.UnsupportedFeatureError('groupBy ordering by an aggregate', 'PowDB', `orderBy key "${key}"`);
1227
+ }
1228
+ }
1229
+ }
1219
1230
  const order = this.buildOrder(args.orderBy);
1220
1231
  const powql = `${this.qt}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1221
1232
  const { rows } = await this.exec(powql, params, args.timeout);
@@ -56,6 +56,105 @@ const filters_js_1 = require("./filters.js");
56
56
  const utils_js_1 = require("./utils.js");
57
57
  /** Relations already warned about missing FK indexes (once per process, dev only). */
58
58
  const unindexedRelationWarned = new Set();
59
+ /**
60
+ * Dev-mode SQL-cache lockstep cross-check gate.
61
+ *
62
+ * The SQL template cache requires three code paths to enumerate where-clause
63
+ * keys identically: `fingerprintWhere` (builds the cache key),
64
+ * `buildWhereClause` (builds SQL + `$N` params on a MISS), and
65
+ * `collectWhereParams` (re-collects params on a HIT without rebuilding). They
66
+ * are synchronized only by convention, and drift has shipped silent
67
+ * wrong-results bugs before (permuted where-key order; an orderBy fingerprint
68
+ * collision). This check catches such drift loudly the moment a cache HIT
69
+ * happens by rebuilding the SQL + params fresh and comparing them against what
70
+ * the cache-hit path produced.
71
+ *
72
+ * Enabled only when `NODE_ENV !== 'production'` (same convention as the other
73
+ * dev-only guards in this file) AND `TURBINE_DISABLE_CACHE_CHECK !== '1'`. The
74
+ * env vars are read inline (not captured once) so tests and perf-sensitive dev
75
+ * traffic can toggle them per process. In production the check never runs, so
76
+ * the hot path is unchanged.
77
+ */
78
+ function cacheCrossCheckEnabled() {
79
+ return process.env.NODE_ENV !== 'production' && process.env.TURBINE_DISABLE_CACHE_CHECK !== '1';
80
+ }
81
+ /**
82
+ * Strict structural equality for a single SQL parameter value. Handles the
83
+ * value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
84
+ * `undefined`, `Date` (by time), `Buffer`/typed arrays (by bytes), arrays
85
+ * (`in` lists, pgvector arrays), and plain objects (JSON filter payloads).
86
+ */
87
+ function cacheParamValueEqual(a, b) {
88
+ if (a === b)
89
+ return true; // identical ref or equal primitive (covers matching null/undefined)
90
+ if (a === null || b === null || a === undefined || b === undefined)
91
+ return false;
92
+ const ta = typeof a;
93
+ if (ta !== typeof b)
94
+ return false;
95
+ if (ta !== 'object') {
96
+ // Primitives that failed `===`: only NaN is legitimately "equal" to itself.
97
+ return typeof a === 'number' && Number.isNaN(a) && Number.isNaN(b);
98
+ }
99
+ if (a instanceof Date || b instanceof Date) {
100
+ return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
101
+ }
102
+ const aView = ArrayBuffer.isView(a);
103
+ const bView = ArrayBuffer.isView(b);
104
+ if (aView || bView) {
105
+ if (!aView || !bView)
106
+ return false;
107
+ const ua = a;
108
+ const ub = b;
109
+ if (ua.byteLength !== ub.byteLength)
110
+ return false;
111
+ const va = new Uint8Array(ua.buffer, ua.byteOffset, ua.byteLength);
112
+ const vb = new Uint8Array(ub.buffer, ub.byteOffset, ub.byteLength);
113
+ for (let i = 0; i < va.length; i++) {
114
+ if (va[i] !== vb[i])
115
+ return false;
116
+ }
117
+ return true;
118
+ }
119
+ const aArr = Array.isArray(a);
120
+ const bArr = Array.isArray(b);
121
+ if (aArr || bArr) {
122
+ if (!aArr || !bArr)
123
+ return false;
124
+ const arrA = a;
125
+ const arrB = b;
126
+ if (arrA.length !== arrB.length)
127
+ return false;
128
+ for (let i = 0; i < arrA.length; i++) {
129
+ if (!cacheParamValueEqual(arrA[i], arrB[i]))
130
+ return false;
131
+ }
132
+ return true;
133
+ }
134
+ const objA = a;
135
+ const objB = b;
136
+ const keysA = Object.keys(objA);
137
+ const keysB = Object.keys(objB);
138
+ if (keysA.length !== keysB.length)
139
+ return false;
140
+ for (const k of keysA) {
141
+ if (!Object.hasOwn(objB, k))
142
+ return false;
143
+ if (!cacheParamValueEqual(objA[k], objB[k]))
144
+ return false;
145
+ }
146
+ return true;
147
+ }
148
+ /** Element-wise strict equality of two SQL parameter arrays. */
149
+ function cacheParamsEqual(a, b) {
150
+ if (a.length !== b.length)
151
+ return false;
152
+ for (let i = 0; i < a.length; i++) {
153
+ if (!cacheParamValueEqual(a[i], b[i]))
154
+ return false;
155
+ }
156
+ return true;
157
+ }
59
158
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
60
159
  class QueryInterface {
61
160
  pool;
@@ -64,6 +163,15 @@ class QueryInterface {
64
163
  tableMeta;
65
164
  /** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
66
165
  sqlTemplateCache = new utils_js_1.LRUCache(1000);
166
+ /**
167
+ * Whether the most recent {@link acquireSql} call was a cache HIT. Read by
168
+ * {@link crossCheckCache} to decide whether to run the dev-mode lockstep
169
+ * cross-check. Safe as a single mutable flag: each `build*()` method calls
170
+ * `acquireSql` then `crossCheckCache` synchronously with no intervening
171
+ * `await` and no re-entrant `acquireSql` (relation subqueries are built
172
+ * inline, not through the top-level cache).
173
+ */
174
+ lastCacheHit = false;
67
175
  middlewares;
68
176
  defaultLimit;
69
177
  warnOnUnlimited;
@@ -382,24 +490,84 @@ class QueryInterface {
382
490
  * On hit, increments counters and returns the cached entry.
383
491
  *
384
492
  * When `sqlCache` is disabled, always calls `build()` without caching.
493
+ *
494
+ * `build` receives a fresh `$N` param scratch array. On a miss those params
495
+ * are discarded (the returned params come from each call site's dedicated
496
+ * collect path); the array exists so the build path can number placeholders
497
+ * via `params.length` exactly as it does today. On a HIT, `build` is skipped
498
+ * here but re-run by {@link crossCheckCache} (dev only) with a fresh array to
499
+ * verify the collect path stayed in lockstep with the build path.
500
+ *
501
+ * Sets {@link lastCacheHit} so the caller's `crossCheckCache` knows whether a
502
+ * cross-check is warranted.
385
503
  */
386
504
  acquireSql(cacheKey, build) {
387
505
  if (!this.sqlCacheEnabled) {
388
- const sql = build();
506
+ this.lastCacheHit = false;
507
+ const sql = build([]);
389
508
  this.cacheMisses++;
390
509
  return { sql, name: (0, utils_js_1.sqlToPreparedName)(sql) };
391
510
  }
392
511
  const cached = this.sqlTemplateCache.get(cacheKey);
393
512
  if (cached) {
394
513
  this.cacheHits++;
514
+ this.lastCacheHit = true;
395
515
  return cached;
396
516
  }
397
- const sql = build();
517
+ this.lastCacheHit = false;
518
+ const sql = build([]);
398
519
  const entry = { sql, name: (0, utils_js_1.sqlToPreparedName)(sql) };
399
520
  this.sqlTemplateCache.set(cacheKey, entry);
400
521
  this.cacheMisses++;
401
522
  return entry;
402
523
  }
524
+ /**
525
+ * Dev-mode SQL-cache lockstep cross-check (see {@link cacheCrossCheckEnabled}).
526
+ *
527
+ * Runs only when the most recent {@link acquireSql} was a cache HIT and the
528
+ * check is enabled. Rebuilds the SQL + `$N` params fresh via the same `build`
529
+ * closure the caller passed to `acquireSql`, then compares:
530
+ * (a) the cached SQL string byte-for-byte against the fresh SQL, and
531
+ * (b) the params the cache-hit collect path produced against the fresh
532
+ * build-path params (length and element-wise strict deep-equal).
533
+ *
534
+ * A mismatch means the fingerprint / build / collect paths have drifted out
535
+ * of lockstep (the exact class of bug that has silently corrupted results
536
+ * before), so it throws a {@link ValidationError} (E003) naming the
537
+ * fingerprint, the operation, and both SQL strings (truncated). Failing loud
538
+ * in dev/test is the point. Production never reaches the comparison.
539
+ *
540
+ * @param op human label of the calling build method (for the error message).
541
+ * @param cacheKey the cache fingerprint that HIT.
542
+ * @param entry the cached SQL entry that will be executed.
543
+ * @param build the same closure passed to `acquireSql`; re-run here to
544
+ * capture the fresh build-path SQL + params.
545
+ * @param collectedParams the params the caller's collect path produced.
546
+ */
547
+ crossCheckCache(op, cacheKey, entry, build, collectedParams) {
548
+ if (!this.lastCacheHit)
549
+ return;
550
+ if (!cacheCrossCheckEnabled())
551
+ return;
552
+ const freshParams = [];
553
+ const freshSql = build(freshParams);
554
+ const sqlOk = freshSql === entry.sql;
555
+ const paramsOk = cacheParamsEqual(collectedParams, freshParams);
556
+ if (sqlOk && paramsOk)
557
+ return;
558
+ const truncate = (s) => (s.length > 300 ? `${s.slice(0, 300)}… (${s.length} chars total)` : s);
559
+ const details = [];
560
+ if (!sqlOk) {
561
+ details.push(`cached SQL and freshly-built SQL diverge:\n cached = <${truncate(entry.sql)}>\n fresh = <${truncate(freshSql)}>`);
562
+ }
563
+ if (!paramsOk) {
564
+ details.push(`cache-hit params and freshly-built params diverge (collected ${collectedParams.length}, built ${freshParams.length})`);
565
+ }
566
+ throw new errors_js_1.ValidationError(`[turbine] SQL cache lockstep violation on ${op} (fingerprint "${cacheKey}"). ` +
567
+ `This is a Turbine internal invariant violation, please report it at ` +
568
+ `https://github.com/zvndev/turbine-orm/issues. The fingerprint, SQL-build, and ` +
569
+ `param-collect paths must enumerate where-clause keys identically.\n${details.join('\n')}`);
570
+ }
403
571
  /**
404
572
  * Reset the per-instance unlimited-query warning dedupe set.
405
573
  * Exposed for tests so a single test process can verify the warning fires
@@ -598,19 +766,22 @@ class QueryInterface {
598
766
  });
599
767
  // Simple path: plain equality, no operators/null/OR
600
768
  if (!args.with && isSimpleWhere) {
601
- const entry = this.acquireSql(ck, () => {
769
+ const buildSql = (freshParams) => {
602
770
  const qt = this.q(this.table);
603
- const tempParams = whereKeys.map((k) => whereObj[k]);
604
- const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = ${this.p(i + 1)}`);
771
+ const whereClauses = whereKeys.map((k, i) => {
772
+ freshParams.push(whereObj[k]);
773
+ return `${this.toSqlColumn(k)} = ${this.p(i + 1)}`;
774
+ });
605
775
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
606
776
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
607
- void tempParams; // params are positional, SQL is value-invariant
608
777
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
609
- });
778
+ };
779
+ const entry = this.acquireSql(ck, buildSql);
610
780
  // Collect params (same order as build)
611
781
  for (const k of whereKeys) {
612
782
  params.push(whereObj[k]);
613
783
  }
784
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
614
785
  return {
615
786
  sql: entry.sql,
616
787
  params,
@@ -624,16 +795,17 @@ class QueryInterface {
624
795
  }
625
796
  // General path (with operators, null, OR, with clause)
626
797
  if (!args.with) {
627
- const entry = this.acquireSql(ck, () => {
628
- const freshParams = [];
798
+ const buildSql = (freshParams) => {
629
799
  const clause = this.buildWhereClause(whereObj, freshParams);
630
800
  const whereSql = clause ? ` WHERE ${clause}` : '';
631
801
  const qt = this.q(this.table);
632
802
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
633
803
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
634
- });
804
+ };
805
+ const entry = this.acquireSql(ck, buildSql);
635
806
  // Collect params
636
807
  this.collectWhereParams(whereObj, params);
808
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
637
809
  return {
638
810
  sql: entry.sql,
639
811
  params,
@@ -650,16 +822,17 @@ class QueryInterface {
650
822
  // 1. buildWhere pushes where params
651
823
  // 2. buildSelectWithRelations pushes relation params to same array
652
824
  // We must preserve this exact order.
653
- const entry = this.acquireSql(ck, () => {
654
- const freshParams = [];
825
+ const buildSql = (freshParams) => {
655
826
  const clause = this.buildWhereClause(whereObj, freshParams);
656
827
  const whereSql = clause ? ` WHERE ${clause}` : '';
657
828
  const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
658
829
  return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
659
- });
830
+ };
831
+ const entry = this.acquireSql(ck, buildSql);
660
832
  // Collect params in exact build order: where first, then with-clause relations
661
833
  this.collectWhereParams(whereObj, params);
662
834
  this.collectWithParams(args.with, params);
835
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
663
836
  const parseWith = this.makeNestedParser(args.with);
664
837
  return {
665
838
  sql: entry.sql,
@@ -724,7 +897,7 @@ class QueryInterface {
724
897
  if (this.warnedTables.has(this.table))
725
898
  return;
726
899
  this.warnedTables.add(this.table);
727
- console.warn(`[turbine] warning: findMany on "${this.table}" has no limit this will fetch every row. ` +
900
+ console.warn(`[turbine] warning: findMany on "${this.table}" has no limit: this will fetch every row. ` +
728
901
  'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
729
902
  }
730
903
  /**
@@ -781,15 +954,24 @@ class QueryInterface {
781
954
  .sort()
782
955
  .join(',')
783
956
  : '';
784
- const distinctFp = args?.distinct ? args.distinct.slice().sort().join(',') : '';
957
+ // distinct must fingerprint in USER order: the SQL emits `DISTINCT ON` in
958
+ // the caller's column order, so a permuted array rebuilds different SQL and
959
+ // must not collapse onto the same cache entry (would trip the cross-check).
960
+ const distinctFp = args?.distinct ? args.distinct.join(',') : '';
785
961
  const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
786
- const limitFp = effectiveLimit !== undefined ? '1' : '0';
787
- const offsetFp = args?.offset !== undefined ? '1' : '0';
962
+ // On engines that inline the literal LIMIT/OFFSET into the SQL text
963
+ // (dialect.inlineLimitOffset, MySQL), the value is part of the SQL, not the
964
+ // params, so it MUST be part of the fingerprint or two different limits share
965
+ // one cached statement (silent wrong row counts). Parameterized engines
966
+ // (PG/SQLite/SQL Server, whose buildLimitOffset uses placeholders) keep the
967
+ // presence-only fingerprint so the cache is not needlessly fragmented.
968
+ const inlinePagination = this.dialect.inlineLimitOffset === true;
969
+ const limitFp = effectiveLimit !== undefined ? (inlinePagination ? `v${effectiveLimit}` : '1') : '0';
970
+ const offsetFp = args?.offset !== undefined ? (inlinePagination ? `v${args.offset}` : '1') : '0';
788
971
  const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
789
972
  const params = [];
790
- const entry = this.acquireSql(ck, () => {
791
- // Fresh build generates SQL and populates freshParams
792
- const freshParams = [];
973
+ const buildSql = (freshParams) => {
974
+ // Fresh build: generates SQL and populates freshParams
793
975
  const { sql: freshWhereSql } = hasWhere
794
976
  ? (() => {
795
977
  const clause = this.buildWhereClause(whereObj, freshParams);
@@ -869,7 +1051,8 @@ class QueryInterface {
869
1051
  }
870
1052
  sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
871
1053
  return sql;
872
- });
1054
+ };
1055
+ const entry = this.acquireSql(ck, buildSql);
873
1056
  // Collect params in exact build order:
874
1057
  // 1. WHERE params (includes the AND-merged global filter, if any)
875
1058
  if (hasWhere) {
@@ -900,6 +1083,7 @@ class QueryInterface {
900
1083
  if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
901
1084
  params.push(Number(args.offset));
902
1085
  }
1086
+ this.crossCheckCache('findMany', ck, entry, buildSql, params);
903
1087
  // Build the row parser once (positional shapes are computed here, not per row).
904
1088
  const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
905
1089
  return {
@@ -1225,8 +1409,7 @@ class QueryInterface {
1225
1409
  const whereFp = this.fingerprintWhere(whereObj);
1226
1410
  const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1227
1411
  const params = [];
1228
- const buildSql = () => {
1229
- const freshParams = [];
1412
+ const buildSql = (freshParams) => {
1230
1413
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1231
1414
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1232
1415
  if (lock) {
@@ -1250,13 +1433,15 @@ class QueryInterface {
1250
1433
  };
1251
1434
  let sql;
1252
1435
  let preparedName;
1436
+ let cacheEntry;
1253
1437
  if (ck) {
1254
- const entry = this.acquireSql(ck, buildSql);
1255
- sql = entry.sql;
1256
- preparedName = entry.name;
1438
+ cacheEntry = this.acquireSql(ck, buildSql);
1439
+ sql = cacheEntry.sql;
1440
+ preparedName = cacheEntry.name;
1257
1441
  }
1258
1442
  else {
1259
- sql = buildSql();
1443
+ // optimisticLock path: value-variant version check → uncacheable, no cross-check.
1444
+ sql = buildSql([]);
1260
1445
  }
1261
1446
  // Collect params: SET first, then WHERE, then version check (same order as fresh build)
1262
1447
  this.collectSetParams(dataObj, params);
@@ -1264,6 +1449,9 @@ class QueryInterface {
1264
1449
  if (lock) {
1265
1450
  params.push(lock.expected);
1266
1451
  }
1452
+ if (ck && cacheEntry) {
1453
+ this.crossCheckCache('update', ck, cacheEntry, buildSql, params);
1454
+ }
1267
1455
  return {
1268
1456
  sql,
1269
1457
  params,
@@ -1395,8 +1583,7 @@ class QueryInterface {
1395
1583
  const whereFp = this.fingerprintWhere(whereObj);
1396
1584
  const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
1397
1585
  const params = [];
1398
- const entry = this.acquireSql(ck, () => {
1399
- const freshParams = [];
1586
+ const buildSql = (freshParams) => {
1400
1587
  const clause = this.buildWhereClause(whereObj, freshParams);
1401
1588
  const whereSql = clause ? ` WHERE ${clause}` : '';
1402
1589
  // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
@@ -1404,8 +1591,10 @@ class QueryInterface {
1404
1591
  return this.dialect.buildDeleteStatement
1405
1592
  ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1406
1593
  : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1407
- });
1594
+ };
1595
+ const entry = this.acquireSql(ck, buildSql);
1408
1596
  this.collectWhereParams(whereObj, params);
1597
+ this.crossCheckCache('delete', ck, entry, buildSql, params);
1409
1598
  return {
1410
1599
  sql: entry.sql,
1411
1600
  params,
@@ -1533,16 +1722,17 @@ class QueryInterface {
1533
1722
  const whereFp = this.fingerprintWhere(whereObj);
1534
1723
  const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1535
1724
  const params = [];
1536
- const entry = this.acquireSql(ck, () => {
1537
- const freshParams = [];
1725
+ const buildSql = (freshParams) => {
1538
1726
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1539
1727
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1540
1728
  const whereClause = this.buildWhereClause(whereObj, freshParams);
1541
1729
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1542
1730
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1543
- });
1731
+ };
1732
+ const entry = this.acquireSql(ck, buildSql);
1544
1733
  this.collectSetParams(dataObj, params);
1545
1734
  this.collectWhereParams(whereObj, params);
1735
+ this.crossCheckCache('updateMany', ck, entry, buildSql, params);
1546
1736
  return {
1547
1737
  sql: entry.sql,
1548
1738
  params,
@@ -1569,13 +1759,14 @@ class QueryInterface {
1569
1759
  const whereFp = this.fingerprintWhere(whereObj);
1570
1760
  const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
1571
1761
  const params = [];
1572
- const entry = this.acquireSql(ck, () => {
1573
- const freshParams = [];
1762
+ const buildSql = (freshParams) => {
1574
1763
  const clause = this.buildWhereClause(whereObj, freshParams);
1575
1764
  const whereSql = clause ? ` WHERE ${clause}` : '';
1576
1765
  return `DELETE FROM ${this.q(this.table)}${whereSql}`;
1577
- });
1766
+ };
1767
+ const entry = this.acquireSql(ck, buildSql);
1578
1768
  this.collectWhereParams(whereObj, params);
1769
+ this.crossCheckCache('deleteMany', ck, entry, buildSql, params);
1579
1770
  return {
1580
1771
  sql: entry.sql,
1581
1772
  params,
@@ -1602,15 +1793,16 @@ class QueryInterface {
1602
1793
  const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
1603
1794
  const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
1604
1795
  const params = [];
1605
- const entry = this.acquireSql(ck, () => {
1606
- const freshParams = [];
1796
+ const buildSql = (freshParams) => {
1607
1797
  const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
1608
1798
  const whereSql = clause ? ` WHERE ${clause}` : '';
1609
1799
  return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1610
- });
1800
+ };
1801
+ const entry = this.acquireSql(ck, buildSql);
1611
1802
  if (hasWhere) {
1612
1803
  this.collectWhereParams(whereObj, params);
1613
1804
  }
1805
+ this.crossCheckCache('count', ck, entry, buildSql, params);
1614
1806
  return {
1615
1807
  sql: entry.sql,
1616
1808
  params,
@@ -1662,6 +1854,15 @@ class QueryInterface {
1662
1854
  const selectExprs = [];
1663
1855
  /** by entries in order: how to read each group key off the result row. */
1664
1856
  const byReaders = [];
1857
+ // ORDER BY registries: map each key the groupBy RESULT actually contains to
1858
+ // the exact SELECT expression that produced it, so `orderBy` re-emits that
1859
+ // expression (never a SELECT alias, since not every dialect accepts alias
1860
+ // references in ORDER BY, and re-emitting mirrors HAVING's `jsonAggExprs`).
1861
+ // `byOrderExprs`: plain by-field name / JSON group-key alias → column or
1862
+ // extract expression. `aggOrderExprs`: `${aggKey}:${field}` → aggregate
1863
+ // expression (including any already-bound JSON-path placeholder, reused
1864
+ // exactly like HAVING since ORDER BY is appended after all other params).
1865
+ const byOrderExprs = new Map();
1665
1866
  const usedResultKeys = new Set();
1666
1867
  const claimResultKey = (key, what) => {
1667
1868
  if (key === '_count' || usedResultKeys.has(key)) {
@@ -1682,6 +1883,7 @@ class QueryInterface {
1682
1883
  groupExprs.push(this.q(col));
1683
1884
  selectExprs.push(this.q(col));
1684
1885
  byReaders.push({ resultKey: entry, rowKey: col, raw: false });
1886
+ byOrderExprs.set(entry, this.q(col));
1685
1887
  }
1686
1888
  else {
1687
1889
  const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
@@ -1693,13 +1895,24 @@ class QueryInterface {
1693
1895
  selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
1694
1896
  groupExprs.push(extract);
1695
1897
  byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
1898
+ // ORDER BY by this JSON alias re-emits the extract expression (with its
1899
+ // already-bound $n): the same reuse HAVING does for JSON aggregates.
1900
+ byOrderExprs.set(alias, extract);
1696
1901
  }
1697
1902
  }
1698
1903
  // _count
1699
- if (args._count === true || args._count === undefined) {
1904
+ const countSelected = args._count === true || args._count === undefined;
1905
+ if (countSelected) {
1700
1906
  // default: always include count
1701
1907
  selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1702
1908
  }
1909
+ // ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
1910
+ // `_count`). Populated alongside the SELECT list below so `orderBy` can only
1911
+ // reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
1912
+ // the ordering expression (the SELECT cast is only for the returned value).
1913
+ const aggOrderExprs = new Map();
1914
+ if (countSelected)
1915
+ aggOrderExprs.set('_count', 'COUNT(*)');
1703
1916
  // _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
1704
1917
  // {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
1705
1918
  // as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
@@ -1722,6 +1935,7 @@ class QueryInterface {
1722
1935
  const inner = `${sqlFn}(${this.q(col)})`;
1723
1936
  const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1724
1937
  selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
1938
+ aggOrderExprs.set(`${aggKey}:${key}`, expr);
1725
1939
  continue;
1726
1940
  }
1727
1941
  const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
@@ -1739,6 +1953,7 @@ class QueryInterface {
1739
1953
  selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
1740
1954
  jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
1741
1955
  jsonAggExprs.set(`${key}:${aggKey}`, expr);
1956
+ aggOrderExprs.set(`${aggKey}:${key}`, expr);
1742
1957
  }
1743
1958
  };
1744
1959
  buildAggregates('_sum', 'SUM', args._sum);
@@ -1755,9 +1970,12 @@ class QueryInterface {
1755
1970
  sql += ` HAVING ${havingClauses.join(' AND ')}`;
1756
1971
  }
1757
1972
  }
1758
- // ORDER BY
1973
+ // ORDER BY, over the groupBy RESULT columns (by-fields, JSON aliases, and
1974
+ // requested aggregates), not the table's physical columns.
1759
1975
  if (args.orderBy) {
1760
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
1976
+ const orderSql = this.buildGroupByOrderBy(args.orderBy, byOrderExprs, aggOrderExprs);
1977
+ if (orderSql)
1978
+ sql += ` ORDER BY ${orderSql}`;
1761
1979
  }
1762
1980
  return {
1763
1981
  sql,
@@ -1822,6 +2040,74 @@ class QueryInterface {
1822
2040
  tag: `${this.table}.groupBy`,
1823
2041
  };
1824
2042
  }
2043
+ /**
2044
+ * Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
2045
+ * ({@link buildOrderBy}, which validates keys against the table's physical
2046
+ * columns), groupBy ordering targets the columns the RESULT actually
2047
+ * contains: plain by-fields, JSON group-key aliases, and requested aggregates
2048
+ * (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
2049
+ * SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
2050
+ * mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
2051
+ * to accept a SELECT-alias reference in ORDER BY, and any already-bound
2052
+ * JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
2053
+ * no `$n` renumbering). An aggregate key that was not requested, or an unknown
2054
+ * by-key, throws {@link ValidationError} E003 listing the valid keys.
2055
+ */
2056
+ buildGroupByOrderBy(orderBy, byOrderExprs, aggOrderExprs) {
2057
+ const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
2058
+ /** Human-readable list of every key this call can order by (for E003). */
2059
+ const validKeys = () => {
2060
+ const keys = [...byOrderExprs.keys()];
2061
+ for (const k of aggOrderExprs.keys()) {
2062
+ keys.push(k.includes(':') ? k.replace(':', '.') : k);
2063
+ }
2064
+ return keys.join(', ') || '(none)';
2065
+ };
2066
+ const parts = [];
2067
+ for (const [key, value] of Object.entries(orderBy)) {
2068
+ if (value === undefined)
2069
+ continue;
2070
+ // Aggregate ordering blocks.
2071
+ if (aggBlocks.has(key)) {
2072
+ if (key === '_count') {
2073
+ const expr = aggOrderExprs.get('_count');
2074
+ if (!expr) {
2075
+ throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
2076
+ `Orderable keys: ${validKeys()}.`);
2077
+ }
2078
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
2079
+ parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
2080
+ continue;
2081
+ }
2082
+ // `_sum` / `_avg` / `_min` / `_max`: an object of field → direction/spec.
2083
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
2084
+ throw new errors_js_1.ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
2085
+ `expected a field map like { ${key}: { amount: 'desc' } }.`);
2086
+ }
2087
+ for (const [field, dirSpec] of Object.entries(value)) {
2088
+ if (dirSpec === undefined)
2089
+ continue;
2090
+ const expr = aggOrderExprs.get(`${key}:${field}`);
2091
+ if (!expr) {
2092
+ throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
2093
+ `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
2094
+ }
2095
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirSpec);
2096
+ parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
2097
+ }
2098
+ continue;
2099
+ }
2100
+ // Plain by-field name or JSON group-key alias.
2101
+ const expr = byOrderExprs.get(key);
2102
+ if (!expr) {
2103
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". ` +
2104
+ `Orderable keys: ${validKeys()}.`);
2105
+ }
2106
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
2107
+ parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
2108
+ }
2109
+ return parts.join(', ');
2110
+ }
1825
2111
  /**
1826
2112
  * Validate a JSON-path target (group key or aggregate target) in groupBy:
1827
2113
  * the field must resolve to a real json/jsonb column and the path must be a
@@ -2939,9 +3225,11 @@ class QueryInterface {
2939
3225
  const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
2940
3226
  subParts.push(`o=${oEntries.join(',')}`);
2941
3227
  }
2942
- // limit presence
3228
+ // limit presence, but on inline-pagination engines (MySQL) the literal
3229
+ // value is baked into the subquery SQL, so fingerprint the value there or
3230
+ // `{limit:3}` and `{limit:5}` would share one cached statement.
2943
3231
  if (opts.limit !== undefined) {
2944
- subParts.push('l=1');
3232
+ subParts.push(this.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
2945
3233
  }
2946
3234
  // nested with (recurse)
2947
3235
  if (opts.with) {