turbine-orm 0.31.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.
@@ -15,11 +15,110 @@ import { CircularRelationError, NotFoundError, OptimisticLockError, RelationErro
15
15
  import { missingIndexForRelation } from '../index-advisor.js';
16
16
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
- import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, resolveCountRelations, stripFields, } from './batched-loader.js';
19
- import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isColumnRef, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
18
+ import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
19
+ import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isColumnRef, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
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;
@@ -291,6 +399,10 @@ export class QueryInterface {
291
399
  */
292
400
  async runFindManyBatched(args) {
293
401
  const withClause = args.with;
402
+ // Scope-rule parity with the join strategy (which throws at SQL build):
403
+ // reject nested pick-row ordering BEFORE the base query so acceptance
404
+ // never depends on how many rows come back.
405
+ rejectNestedPickOrder(withClause);
294
406
  // Capture the opt-out from the ARGS before any await: this.currentSkip is
295
407
  // instance state on a cached accessor, so a concurrent build during the
296
408
  // base-query await would overwrite it (tenant query loading relations with
@@ -342,24 +454,84 @@ export class QueryInterface {
342
454
  * On hit, increments counters and returns the cached entry.
343
455
  *
344
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.
345
467
  */
346
468
  acquireSql(cacheKey, build) {
347
469
  if (!this.sqlCacheEnabled) {
348
- const sql = build();
470
+ this.lastCacheHit = false;
471
+ const sql = build([]);
349
472
  this.cacheMisses++;
350
473
  return { sql, name: sqlToPreparedName(sql) };
351
474
  }
352
475
  const cached = this.sqlTemplateCache.get(cacheKey);
353
476
  if (cached) {
354
477
  this.cacheHits++;
478
+ this.lastCacheHit = true;
355
479
  return cached;
356
480
  }
357
- const sql = build();
481
+ this.lastCacheHit = false;
482
+ const sql = build([]);
358
483
  const entry = { sql, name: sqlToPreparedName(sql) };
359
484
  this.sqlTemplateCache.set(cacheKey, entry);
360
485
  this.cacheMisses++;
361
486
  return entry;
362
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
+ }
363
535
  /**
364
536
  * Reset the per-instance unlimited-query warning dedupe set.
365
537
  * Exposed for tests so a single test process can verify the warning fires
@@ -516,6 +688,8 @@ export class QueryInterface {
516
688
  */
517
689
  async runFindUniqueBatched(args) {
518
690
  const withClause = args.with;
691
+ // Same scope-rule parity as runFindManyBatched: reject before querying.
692
+ rejectNestedPickOrder(withClause);
519
693
  const needed = neededParentKeyFields(this.tableMeta, withClause);
520
694
  const proj = includeKeysForBatching(args.select, args.omit, needed);
521
695
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
@@ -556,19 +730,22 @@ export class QueryInterface {
556
730
  });
557
731
  // Simple path: plain equality, no operators/null/OR
558
732
  if (!args.with && isSimpleWhere) {
559
- const entry = this.acquireSql(ck, () => {
733
+ const buildSql = (freshParams) => {
560
734
  const qt = this.q(this.table);
561
- const tempParams = whereKeys.map((k) => whereObj[k]);
562
- 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
+ });
563
739
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
564
740
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
565
- void tempParams; // params are positional, SQL is value-invariant
566
741
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
567
- });
742
+ };
743
+ const entry = this.acquireSql(ck, buildSql);
568
744
  // Collect params (same order as build)
569
745
  for (const k of whereKeys) {
570
746
  params.push(whereObj[k]);
571
747
  }
748
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
572
749
  return {
573
750
  sql: entry.sql,
574
751
  params,
@@ -582,16 +759,17 @@ export class QueryInterface {
582
759
  }
583
760
  // General path (with operators, null, OR, with clause)
584
761
  if (!args.with) {
585
- const entry = this.acquireSql(ck, () => {
586
- const freshParams = [];
762
+ const buildSql = (freshParams) => {
587
763
  const clause = this.buildWhereClause(whereObj, freshParams);
588
764
  const whereSql = clause ? ` WHERE ${clause}` : '';
589
765
  const qt = this.q(this.table);
590
766
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
591
767
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
592
- });
768
+ };
769
+ const entry = this.acquireSql(ck, buildSql);
593
770
  // Collect params
594
771
  this.collectWhereParams(whereObj, params);
772
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
595
773
  return {
596
774
  sql: entry.sql,
597
775
  params,
@@ -608,16 +786,17 @@ export class QueryInterface {
608
786
  // 1. buildWhere pushes where params
609
787
  // 2. buildSelectWithRelations pushes relation params to same array
610
788
  // We must preserve this exact order.
611
- const entry = this.acquireSql(ck, () => {
612
- const freshParams = [];
789
+ const buildSql = (freshParams) => {
613
790
  const clause = this.buildWhereClause(whereObj, freshParams);
614
791
  const whereSql = clause ? ` WHERE ${clause}` : '';
615
792
  const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
616
793
  return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
617
- });
794
+ };
795
+ const entry = this.acquireSql(ck, buildSql);
618
796
  // Collect params in exact build order: where first, then with-clause relations
619
797
  this.collectWhereParams(whereObj, params);
620
798
  this.collectWithParams(args.with, params);
799
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
621
800
  const parseWith = this.makeNestedParser(args.with);
622
801
  return {
623
802
  sql: entry.sql,
@@ -682,7 +861,7 @@ export class QueryInterface {
682
861
  if (this.warnedTables.has(this.table))
683
862
  return;
684
863
  this.warnedTables.add(this.table);
685
- 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. ` +
686
865
  'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
687
866
  }
688
867
  /**
@@ -702,6 +881,21 @@ export class QueryInterface {
702
881
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
703
882
  buildFindMany(args) {
704
883
  this.currentSkip = args?.skipGlobalFilters;
884
+ // `distinct` + relation orderBy is refused up front (E003): the distinct
885
+ // path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
886
+ // <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
887
+ // to-one relation ordering) would reference the parent table name out of
888
+ // scope — a guaranteed "missing FROM-clause entry" crash on Postgres.
889
+ // Checked BEFORE the SQL cache so build and warm-cache paths throw
890
+ // identically (same rule as the vector guard inside the distinct branch).
891
+ if (args?.distinct && args.distinct.length > 0 && args.orderBy) {
892
+ for (const d of Object.values(args.orderBy)) {
893
+ if (this.isRelationOrderByValue(d)) {
894
+ throw new ValidationError('[turbine] `distinct` cannot be combined with relation orderBy (pick-row, `_count`, or ' +
895
+ 'to-one relation ordering): the outer re-order cannot reference the parent table.');
896
+ }
897
+ }
898
+ }
705
899
  const columnsList = this.resolveColumns(args?.select, args?.omit);
706
900
  const colKey = columnsList ? columnsList.join(',') : '*';
707
901
  // AND-merge this table's global filter into the user where; `hasWhere` gates
@@ -715,7 +909,7 @@ export class QueryInterface {
715
909
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
716
910
  const orderFp = args?.orderBy
717
911
  ? Object.entries(args.orderBy)
718
- .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
912
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations[k]?.to)}`)
719
913
  .join(',')
720
914
  : '';
721
915
  const cursorFp = args?.cursor
@@ -724,15 +918,24 @@ export class QueryInterface {
724
918
  .sort()
725
919
  .join(',')
726
920
  : '';
727
- 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(',') : '';
728
925
  const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
729
- const limitFp = effectiveLimit !== undefined ? '1' : '0';
730
- 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';
731
935
  const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
732
936
  const params = [];
733
- const entry = this.acquireSql(ck, () => {
734
- // Fresh build generates SQL and populates freshParams
735
- const freshParams = [];
937
+ const buildSql = (freshParams) => {
938
+ // Fresh build: generates SQL and populates freshParams
736
939
  const { sql: freshWhereSql } = hasWhere
737
940
  ? (() => {
738
941
  const clause = this.buildWhereClause(whereObj, freshParams);
@@ -812,7 +1015,8 @@ export class QueryInterface {
812
1015
  }
813
1016
  sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
814
1017
  return sql;
815
- });
1018
+ };
1019
+ const entry = this.acquireSql(ck, buildSql);
816
1020
  // Collect params in exact build order:
817
1021
  // 1. WHERE params (includes the AND-merged global filter, if any)
818
1022
  if (hasWhere) {
@@ -843,6 +1047,7 @@ export class QueryInterface {
843
1047
  if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
844
1048
  params.push(Number(args.offset));
845
1049
  }
1050
+ this.crossCheckCache('findMany', ck, entry, buildSql, params);
846
1051
  // Build the row parser once (positional shapes are computed here, not per row).
847
1052
  const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
848
1053
  return {
@@ -1168,8 +1373,7 @@ export class QueryInterface {
1168
1373
  const whereFp = this.fingerprintWhere(whereObj);
1169
1374
  const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1170
1375
  const params = [];
1171
- const buildSql = () => {
1172
- const freshParams = [];
1376
+ const buildSql = (freshParams) => {
1173
1377
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1174
1378
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1175
1379
  if (lock) {
@@ -1193,13 +1397,15 @@ export class QueryInterface {
1193
1397
  };
1194
1398
  let sql;
1195
1399
  let preparedName;
1400
+ let cacheEntry;
1196
1401
  if (ck) {
1197
- const entry = this.acquireSql(ck, buildSql);
1198
- sql = entry.sql;
1199
- preparedName = entry.name;
1402
+ cacheEntry = this.acquireSql(ck, buildSql);
1403
+ sql = cacheEntry.sql;
1404
+ preparedName = cacheEntry.name;
1200
1405
  }
1201
1406
  else {
1202
- sql = buildSql();
1407
+ // optimisticLock path: value-variant version check → uncacheable, no cross-check.
1408
+ sql = buildSql([]);
1203
1409
  }
1204
1410
  // Collect params: SET first, then WHERE, then version check (same order as fresh build)
1205
1411
  this.collectSetParams(dataObj, params);
@@ -1207,6 +1413,9 @@ export class QueryInterface {
1207
1413
  if (lock) {
1208
1414
  params.push(lock.expected);
1209
1415
  }
1416
+ if (ck && cacheEntry) {
1417
+ this.crossCheckCache('update', ck, cacheEntry, buildSql, params);
1418
+ }
1210
1419
  return {
1211
1420
  sql,
1212
1421
  params,
@@ -1338,8 +1547,7 @@ export class QueryInterface {
1338
1547
  const whereFp = this.fingerprintWhere(whereObj);
1339
1548
  const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
1340
1549
  const params = [];
1341
- const entry = this.acquireSql(ck, () => {
1342
- const freshParams = [];
1550
+ const buildSql = (freshParams) => {
1343
1551
  const clause = this.buildWhereClause(whereObj, freshParams);
1344
1552
  const whereSql = clause ? ` WHERE ${clause}` : '';
1345
1553
  // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
@@ -1347,8 +1555,10 @@ export class QueryInterface {
1347
1555
  return this.dialect.buildDeleteStatement
1348
1556
  ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1349
1557
  : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1350
- });
1558
+ };
1559
+ const entry = this.acquireSql(ck, buildSql);
1351
1560
  this.collectWhereParams(whereObj, params);
1561
+ this.crossCheckCache('delete', ck, entry, buildSql, params);
1352
1562
  return {
1353
1563
  sql: entry.sql,
1354
1564
  params,
@@ -1476,16 +1686,17 @@ export class QueryInterface {
1476
1686
  const whereFp = this.fingerprintWhere(whereObj);
1477
1687
  const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1478
1688
  const params = [];
1479
- const entry = this.acquireSql(ck, () => {
1480
- const freshParams = [];
1689
+ const buildSql = (freshParams) => {
1481
1690
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1482
1691
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1483
1692
  const whereClause = this.buildWhereClause(whereObj, freshParams);
1484
1693
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1485
1694
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1486
- });
1695
+ };
1696
+ const entry = this.acquireSql(ck, buildSql);
1487
1697
  this.collectSetParams(dataObj, params);
1488
1698
  this.collectWhereParams(whereObj, params);
1699
+ this.crossCheckCache('updateMany', ck, entry, buildSql, params);
1489
1700
  return {
1490
1701
  sql: entry.sql,
1491
1702
  params,
@@ -1512,13 +1723,14 @@ export class QueryInterface {
1512
1723
  const whereFp = this.fingerprintWhere(whereObj);
1513
1724
  const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
1514
1725
  const params = [];
1515
- const entry = this.acquireSql(ck, () => {
1516
- const freshParams = [];
1726
+ const buildSql = (freshParams) => {
1517
1727
  const clause = this.buildWhereClause(whereObj, freshParams);
1518
1728
  const whereSql = clause ? ` WHERE ${clause}` : '';
1519
1729
  return `DELETE FROM ${this.q(this.table)}${whereSql}`;
1520
- });
1730
+ };
1731
+ const entry = this.acquireSql(ck, buildSql);
1521
1732
  this.collectWhereParams(whereObj, params);
1733
+ this.crossCheckCache('deleteMany', ck, entry, buildSql, params);
1522
1734
  return {
1523
1735
  sql: entry.sql,
1524
1736
  params,
@@ -1545,15 +1757,16 @@ export class QueryInterface {
1545
1757
  const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
1546
1758
  const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
1547
1759
  const params = [];
1548
- const entry = this.acquireSql(ck, () => {
1549
- const freshParams = [];
1760
+ const buildSql = (freshParams) => {
1550
1761
  const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
1551
1762
  const whereSql = clause ? ` WHERE ${clause}` : '';
1552
1763
  return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1553
- });
1764
+ };
1765
+ const entry = this.acquireSql(ck, buildSql);
1554
1766
  if (hasWhere) {
1555
1767
  this.collectWhereParams(whereObj, params);
1556
1768
  }
1769
+ this.crossCheckCache('count', ck, entry, buildSql, params);
1557
1770
  return {
1558
1771
  sql: entry.sql,
1559
1772
  params,
@@ -1576,67 +1789,124 @@ export class QueryInterface {
1576
1789
  const meta = this.schema.tables[this.table];
1577
1790
  if (meta) {
1578
1791
  for (const key of args.by) {
1579
- if (!(key in meta.columnMap)) {
1792
+ if (typeof key === 'string' && !(key in meta.columnMap)) {
1580
1793
  throw new ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
1581
1794
  }
1582
1795
  }
1583
1796
  }
1584
1797
  this.currentSkip = args.skipGlobalFilters;
1585
- const groupColsRaw = args.by.map((k) => this.toColumn(k));
1586
- const groupCols = groupColsRaw.map((c) => this.q(c));
1587
1798
  const gbWhere = this.mergeGlobalFilter(args.where);
1588
1799
  const { sql: whereSql, params } = gbWhere
1589
1800
  ? this.buildWhere(gbWhere)
1590
1801
  : { sql: '', params: [] };
1591
- // Build SELECT expressions: group-by columns + aggregate functions
1592
- const selectExprs = [...groupCols];
1802
+ // Row source. Plain: `"table"<WHERE>`. With `distinctOn` (PostgreSQL
1803
+ // only), the groupBy runs over one representative row per column
1804
+ // combination: the wrapper carries args.where INSIDE it (filter before
1805
+ // picking) and is aliased as the table name so every outer expression is
1806
+ // byte-identical either way.
1807
+ const fromSql = args.distinctOn
1808
+ ? this.buildDistinctOnSource(args.distinctOn, whereSql, params)
1809
+ : `${this.q(this.table)}${whereSql}`;
1810
+ // Group keys: plain columns and/or JSON-path keys. Output-name collisions
1811
+ // are rejected up front — and the check runs over the EMITTED SQL output
1812
+ // column names (snake_case column / JSON alias / `_agg_key` aggregate
1813
+ // alias), not just the given arg keys: the driver keeps only the LAST
1814
+ // duplicate field per row object, so a JSON alias equal to another key's
1815
+ // snake_case column (or an aggregate output alias) would silently clobber
1816
+ // that value in the results.
1817
+ const groupExprs = [];
1818
+ const selectExprs = [];
1819
+ /** by entries in order: how to read each group key off the result row. */
1820
+ const byReaders = [];
1821
+ const usedResultKeys = new Set();
1822
+ const claimResultKey = (key, what) => {
1823
+ if (key === '_count' || usedResultKeys.has(key)) {
1824
+ throw new ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
1825
+ `"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
1826
+ }
1827
+ usedResultKeys.add(key);
1828
+ };
1829
+ for (const entry of args.by) {
1830
+ if (typeof entry === 'string') {
1831
+ const col = this.toColumn(entry);
1832
+ claimResultKey(entry, `column "${col}"`);
1833
+ // The emitted output column is the snake_case name; claim it too (when
1834
+ // it differs from the result key) so a JSON alias like 'created_at'
1835
+ // cannot silently shadow the 'createdAt' group key on the wire.
1836
+ if (col !== entry)
1837
+ claimResultKey(col, `column "${col}"`);
1838
+ groupExprs.push(this.q(col));
1839
+ selectExprs.push(this.q(col));
1840
+ byReaders.push({ resultKey: entry, rowKey: col, raw: false });
1841
+ }
1842
+ else {
1843
+ const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
1844
+ params.push(this.jsonPathParam(entry.path));
1845
+ const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
1846
+ const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
1847
+ claimResultKey(alias, `JSON path on "${entry.field}"`);
1848
+ // Same expression (and the same $n placeholder) in SELECT and GROUP BY.
1849
+ selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
1850
+ groupExprs.push(extract);
1851
+ byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
1852
+ }
1853
+ }
1593
1854
  // _count
1594
1855
  if (args._count === true || args._count === undefined) {
1595
1856
  // default: always include count
1596
1857
  selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1597
1858
  }
1598
- // _sum
1599
- if (args._sum) {
1600
- for (const [field, enabled] of Object.entries(args._sum)) {
1601
- if (enabled) {
1602
- const col = this.toColumn(field);
1603
- selectExprs.push(`SUM(${this.q(col)}) AS ${this.q(`_sum_${col}`)}`);
1604
- }
1605
- }
1606
- }
1607
- // _avg
1608
- if (args._avg) {
1609
- for (const [field, enabled] of Object.entries(args._avg)) {
1610
- if (enabled) {
1611
- const col = this.toColumn(field);
1612
- selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
1613
- }
1614
- }
1615
- }
1616
- // _min
1617
- if (args._min) {
1618
- for (const [field, enabled] of Object.entries(args._min)) {
1619
- if (enabled) {
1620
- const col = this.toColumn(field);
1621
- selectExprs.push(`MIN(${this.q(col)}) AS ${this.q(`_min_${col}`)}`);
1859
+ // _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
1860
+ // {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
1861
+ // as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
1862
+ // alias (and coercion kind) in the transform; `jsonAggExprs` lets HAVING
1863
+ // reuse the exact aggregate expression (same placeholders) by alias.
1864
+ const jsonAggFields = new Map();
1865
+ const jsonAggExprs = new Map();
1866
+ const buildAggregates = (aggKey, sqlFn, spec) => {
1867
+ if (!spec)
1868
+ return;
1869
+ for (const [key, target] of Object.entries(spec)) {
1870
+ if (!target)
1871
+ continue;
1872
+ if (target === true) {
1873
+ const col = this.toColumn(key);
1874
+ // Aggregate output aliases share the same output-name namespace as
1875
+ // the group keys: `_sum: { totalPrice: true, total_price: {json} }`
1876
+ // would emit two "_sum_total_price" columns and silently drop one.
1877
+ claimResultKey(`${aggKey}_${col}`, `${aggKey} of column "${col}"`);
1878
+ const inner = `${sqlFn}(${this.q(col)})`;
1879
+ const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1880
+ selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
1881
+ continue;
1622
1882
  }
1623
- }
1624
- }
1625
- // _max
1626
- if (args._max) {
1627
- for (const [field, enabled] of Object.entries(args._max)) {
1628
- if (enabled) {
1629
- const col = this.toColumn(field);
1630
- selectExprs.push(`MAX(${this.q(col)}) AS ${this.q(`_max_${col}`)}`);
1883
+ const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
1884
+ const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
1885
+ if (alwaysNumeric && target.type === 'text') {
1886
+ throw new ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${this.table}": ` +
1887
+ `${aggKey} over a JSON path is always numeric: remove \`type: 'text'\`.`);
1631
1888
  }
1889
+ const numeric = alwaysNumeric || target.type === 'numeric';
1890
+ claimResultKey(`${aggKey}_${key}`, `${aggKey} JSON target "${key}"`);
1891
+ params.push(this.jsonPathParam(target.path));
1892
+ const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
1893
+ const inner = `${sqlFn}(${numeric ? this.castJsonNumeric(extract) : extract})`;
1894
+ const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1895
+ selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
1896
+ jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
1897
+ jsonAggExprs.set(`${key}:${aggKey}`, expr);
1632
1898
  }
1633
- }
1634
- let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
1899
+ };
1900
+ buildAggregates('_sum', 'SUM', args._sum);
1901
+ buildAggregates('_avg', 'AVG', args._avg);
1902
+ buildAggregates('_min', 'MIN', args._min);
1903
+ buildAggregates('_max', 'MAX', args._max);
1904
+ let sql = `SELECT ${selectExprs.join(', ')} FROM ${fromSql} GROUP BY ${groupExprs.join(', ')}`;
1635
1905
  // HAVING — filter whole groups by their aggregate values.
1636
1906
  // Appends to the same `params` array, so placeholders continue from the
1637
1907
  // WHERE clause's parameter positions (this.p(params.length) below).
1638
1908
  if (args.having) {
1639
- const havingClauses = this.buildHavingClauses(args.having, params);
1909
+ const havingClauses = this.buildHavingClauses(args.having, params, jsonAggExprs);
1640
1910
  if (havingClauses.length > 0) {
1641
1911
  sql += ` HAVING ${havingClauses.join(' AND ')}`;
1642
1912
  }
@@ -1652,9 +1922,11 @@ export class QueryInterface {
1652
1922
  const parsed = this.parseRow(row, this.table);
1653
1923
  // Restructure aggregate results into nested objects (Prisma-style)
1654
1924
  const restructured = {};
1655
- // Copy group-by fields
1656
- for (const field of args.by) {
1657
- restructured[field] = parsed[field];
1925
+ // Copy group-by fields. JSON-path keys read their alias off the raw
1926
+ // row (the alias is not a table column, so parseRow's snake→camel
1927
+ // mapping must not touch it).
1928
+ for (const reader of byReaders) {
1929
+ restructured[reader.resultKey] = reader.raw ? row[reader.rowKey] : parsed[reader.resultKey];
1658
1930
  }
1659
1931
  // _count
1660
1932
  if ('_count' in row) {
@@ -1669,29 +1941,27 @@ export class QueryInterface {
1669
1941
  const minObj = {};
1670
1942
  const maxObj = {};
1671
1943
  let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
1944
+ // JSON-path aggregates keep their arg key verbatim; plain-column
1945
+ // aggregates keep the snake→camel field mapping.
1946
+ const jsonAgg = (rawKey) => jsonAggFields.get(rawKey);
1947
+ const fieldFor = (rawKey, col) => jsonAgg(rawKey)?.field ?? this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1672
1948
  for (const [rawKey, rawValue] of Object.entries(row)) {
1673
1949
  if (rawKey.startsWith('_sum_')) {
1674
- const col = rawKey.slice(5);
1675
- const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1676
- sumObj[field] = rawValue !== null ? Number(rawValue) : null;
1950
+ sumObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
1677
1951
  hasSums = true;
1678
1952
  }
1679
1953
  else if (rawKey.startsWith('_avg_')) {
1680
- const col = rawKey.slice(5);
1681
- const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1682
- avgObj[field] = rawValue !== null ? Number(rawValue) : null;
1954
+ avgObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
1683
1955
  hasAvgs = true;
1684
1956
  }
1685
1957
  else if (rawKey.startsWith('_min_')) {
1686
- const col = rawKey.slice(5);
1687
- const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1688
- minObj[field] = rawValue;
1958
+ const j = jsonAgg(rawKey);
1959
+ minObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
1689
1960
  hasMins = true;
1690
1961
  }
1691
1962
  else if (rawKey.startsWith('_max_')) {
1692
- const col = rawKey.slice(5);
1693
- const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1694
- maxObj[field] = rawValue;
1963
+ const j = jsonAgg(rawKey);
1964
+ maxObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
1695
1965
  hasMaxs = true;
1696
1966
  }
1697
1967
  }
@@ -1708,6 +1978,75 @@ export class QueryInterface {
1708
1978
  tag: `${this.table}.groupBy`,
1709
1979
  };
1710
1980
  }
1981
+ /**
1982
+ * Validate a JSON-path target (group key or aggregate target) in groupBy:
1983
+ * the field must resolve to a real json/jsonb column and the path must be a
1984
+ * non-empty array of keys/indexes. Returns the resolved snake_case column.
1985
+ */
1986
+ resolveJsonPathTarget(context, field, path) {
1987
+ if (typeof field !== 'string') {
1988
+ throw new ValidationError(`[turbine] groupBy ${context} on table "${this.table}" requires a string \`field\`.`);
1989
+ }
1990
+ const col = this.toColumn(field);
1991
+ if (!Array.isArray(path) ||
1992
+ path.length === 0 ||
1993
+ path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
1994
+ throw new ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
1995
+ `array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
1996
+ }
1997
+ const colType = this.pgTypeForColumn(this.tableMeta, col);
1998
+ if (!this.isJsonColumnType(colType)) {
1999
+ throw new ValidationError(`[turbine] groupBy ${context} on "${field}": column "${col}" on table "${this.table}" is not a JSON ` +
2000
+ `column (actual type: ${colType}).`);
2001
+ }
2002
+ return col;
2003
+ }
2004
+ /**
2005
+ * Build the `distinctOn` row source for groupBy (PostgreSQL only: other
2006
+ * engines throw {@link UnsupportedFeatureError} E017):
2007
+ *
2008
+ * ```sql
2009
+ * (SELECT DISTINCT ON ("c1") * FROM "table"<WHERE> ORDER BY "c1", <orderBy>) AS "table"
2010
+ * ```
2011
+ *
2012
+ * The wrapper is aliased as the table name so every outer expression (group
2013
+ * keys, aggregates, HAVING, ORDER BY) is byte-identical to the plain path.
2014
+ * `distinctOn.orderBy` is required (it decides which row survives) and
2015
+ * supports plain columns, {@link OrderBySpec} nulls, and JSON-path specs;
2016
+ * JSON paths push their text[] param here, after the WHERE params.
2017
+ */
2018
+ buildDistinctOnSource(distinctOn, whereSql, params) {
2019
+ if (this.dialect.name !== 'postgresql') {
2020
+ throw new UnsupportedFeatureError('DISTINCT ON row source (groupBy distinctOn)', this.dialect.name, 'groupBy({ distinctOn }) requires PostgreSQL: SELECT DISTINCT ON is not portable.');
2021
+ }
2022
+ if (!Array.isArray(distinctOn.columns) || distinctOn.columns.length === 0) {
2023
+ throw new ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires a non-empty \`columns\` array.`);
2024
+ }
2025
+ const orderEntries = Object.entries(distinctOn.orderBy ?? {});
2026
+ if (orderEntries.length === 0) {
2027
+ throw new ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires \`orderBy\` to pick ONE row per ` +
2028
+ "column combination deterministically (e.g. orderBy: { createdAt: 'desc' }).");
2029
+ }
2030
+ const distinctCols = distinctOn.columns.map((c) => this.q(this.toColumn(c)));
2031
+ // DISTINCT ON expressions must lead the ORDER BY; the user's orderBy then
2032
+ // decides which row survives per combination.
2033
+ const orderParts = [...distinctCols];
2034
+ for (const [key, value] of orderEntries) {
2035
+ if (isJsonPathOrderBy(value)) {
2036
+ orderParts.push(this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params));
2037
+ continue;
2038
+ }
2039
+ if (isVectorOrderBy(value) || this.isRelationOrderByValue(value)) {
2040
+ throw new ValidationError(`[turbine] groupBy distinctOn.orderBy on "${key}" (table "${this.table}") supports plain columns, ` +
2041
+ 'sort specs, and JSON-path orderings only.');
2042
+ }
2043
+ const col = this.resolveOrderByColumn(this.table, this.tableMeta, key);
2044
+ const { dir, nulls } = normalizeOrderBy(value);
2045
+ orderParts.push(`${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`);
2046
+ }
2047
+ return (`(SELECT DISTINCT ON (${distinctCols.join(', ')}) * FROM ${this.q(this.table)}${whereSql} ` +
2048
+ `ORDER BY ${orderParts.join(', ')}) AS ${this.q(this.table)}`);
2049
+ }
1711
2050
  /**
1712
2051
  * Build the SQL fragments for a {@link HavingClause}.
1713
2052
  *
@@ -1718,8 +2057,14 @@ export class QueryInterface {
1718
2057
  * comparison value is pushed onto the shared `params` array and referenced by
1719
2058
  * a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
1720
2059
  * interpolation of user values.
2060
+ *
2061
+ * `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
2062
+ * exact aggregate expression a JSON-path aggregate emitted in SELECT
2063
+ * (including its already-bound path placeholder), so HAVING on a JSON-path
2064
+ * aggregate alias reuses the same expression instead of resolving the alias
2065
+ * as a column.
1721
2066
  */
1722
- buildHavingClauses(having, params) {
2067
+ buildHavingClauses(having, params, jsonAggExprs) {
1723
2068
  const clauses = [];
1724
2069
  // Maps the per-field aggregate key to its SQL function name. The set of
1725
2070
  // allowed keys is fixed here — any other key on a field's filter object is
@@ -1746,8 +2091,14 @@ export class QueryInterface {
1746
2091
  }
1747
2092
  // toColumn validates the field against schema metadata (throws
1748
2093
  // ValidationError on unknown columns) and q() quotes the identifier — no
1749
- // unvalidated identifier ever reaches the SQL string.
1750
- const quotedCol = this.q(this.toColumn(key));
2094
+ // unvalidated identifier ever reaches the SQL string. Resolution is lazy:
2095
+ // a JSON-path aggregate alias is not a column, so it must not hit
2096
+ // toColumn when every aggregate under it resolves via `jsonAggExprs`.
2097
+ let quotedCol = null;
2098
+ const columnExpr = () => {
2099
+ quotedCol ??= this.q(this.toColumn(key));
2100
+ return quotedCol;
2101
+ };
1751
2102
  for (const [aggKey, filter] of Object.entries(value)) {
1752
2103
  if (filter === undefined)
1753
2104
  continue;
@@ -1756,7 +2107,7 @@ export class QueryInterface {
1756
2107
  throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
1757
2108
  `Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
1758
2109
  }
1759
- const expr = `${fn}(${quotedCol})`;
2110
+ const expr = jsonAggExprs?.get(`${key}:${aggKey}`) ?? `${fn}(${columnExpr()})`;
1760
2111
  clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
1761
2112
  }
1762
2113
  }
@@ -2403,7 +2754,7 @@ export class QueryInterface {
2403
2754
  // JSONB filter
2404
2755
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2405
2756
  const colType = this.getColumnPgType(rawColumn);
2406
- if (colType === 'json' || colType === 'jsonb') {
2757
+ if (this.isJsonColumnType(colType)) {
2407
2758
  this.collectJsonFilterParams(value, params, this.q(rawColumn));
2408
2759
  continue;
2409
2760
  }
@@ -2514,7 +2865,7 @@ export class QueryInterface {
2514
2865
  // the target column is json/jsonb.
2515
2866
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2516
2867
  const colType = this.pgTypeForColumn(meta, col);
2517
- if (colType === 'json' || colType === 'jsonb') {
2868
+ if (this.isJsonColumnType(colType)) {
2518
2869
  this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
2519
2870
  continue;
2520
2871
  }
@@ -2584,7 +2935,8 @@ export class QueryInterface {
2584
2935
  let pathPushed = false;
2585
2936
  const pushPathOnce = () => {
2586
2937
  if (!pathPushed) {
2587
- params.push(filter.path);
2938
+ // Only reached when a path-requiring clause validated filter.path.
2939
+ params.push(this.jsonPathParam(filter.path, filter.path));
2588
2940
  pathPushed = true;
2589
2941
  }
2590
2942
  };
@@ -2636,15 +2988,20 @@ export class QueryInterface {
2636
2988
  // then the path bound as one text[] param.
2637
2989
  if (isJsonPathOrderBy(dir)) {
2638
2990
  this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
2639
- params.push(dir.path.map(String));
2991
+ params.push(this.jsonPathParam(dir.path));
2640
2992
  continue;
2641
2993
  }
2642
2994
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2643
- // subquery as `_count` mirror its global-filter params. To-one relation
2644
- // orderBy carries the target's global filter once per ordered column.
2995
+ // subquery as `_count`: mirror its global-filter params. Pick-row
2996
+ // ordering mirrors its full param chain (by-path / global filter /
2997
+ // pick.where / pick.orderBy paths). To-one relation orderBy carries the
2998
+ // target's global filter once per ordered column.
2645
2999
  if (this.isRelationOrderByValue(dir)) {
2646
3000
  const relDef = this.tableMeta.relations[key];
2647
- if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
3001
+ if (relDef && isRelationPickOrderBy(dir)) {
3002
+ this.collectRelationPickOrderParams(key, relDef, dir, params);
3003
+ }
3004
+ else if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2648
3005
  this.collectRelationCountParams(relDef, params);
2649
3006
  }
2650
3007
  else if (relDef) {
@@ -2734,12 +3091,15 @@ export class QueryInterface {
2734
3091
  }
2735
3092
  // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
2736
3093
  if (opts.orderBy) {
2737
- const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
3094
+ const targetRels = this.schema.tables[relDef.to]?.relations;
3095
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
2738
3096
  subParts.push(`o=${oEntries.join(',')}`);
2739
3097
  }
2740
- // 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.
2741
3101
  if (opts.limit !== undefined) {
2742
- subParts.push('l=1');
3102
+ subParts.push(this.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
2743
3103
  }
2744
3104
  // nested with (recurse)
2745
3105
  if (opts.with) {
@@ -3144,7 +3504,7 @@ export class QueryInterface {
3144
3504
  // Handle JSONB filter operators (for json/jsonb columns)
3145
3505
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3146
3506
  const colType = this.getColumnPgType(rawColumn);
3147
- if (colType === 'json' || colType === 'jsonb') {
3507
+ if (this.isJsonColumnType(colType)) {
3148
3508
  const jsonClauses = this.buildJsonFilterClauses(column, value, params);
3149
3509
  andClauses.push(...jsonClauses);
3150
3510
  continue;
@@ -3359,7 +3719,7 @@ export class QueryInterface {
3359
3719
  // jsonb value, silently matching nothing.
3360
3720
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3361
3721
  const colType = this.pgTypeForColumn(meta, col);
3362
- if (colType === 'json' || colType === 'jsonb') {
3722
+ if (this.isJsonColumnType(colType)) {
3363
3723
  conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
3364
3724
  continue;
3365
3725
  }
@@ -3459,7 +3819,7 @@ export class QueryInterface {
3459
3819
  assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
3460
3820
  if (!isUnmatchedPlainObject(value))
3461
3821
  return;
3462
- if (columnPgType === 'json' || columnPgType === 'jsonb')
3822
+ if (this.isJsonColumnType(columnPgType))
3463
3823
  return;
3464
3824
  const badKeys = Object.keys(value);
3465
3825
  throw new ValidationError(badKeys.length === 0
@@ -3530,7 +3890,7 @@ export class QueryInterface {
3530
3890
  // bound as a plain equality value.
3531
3891
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3532
3892
  const colType = this.pgTypeForColumn(targetMeta, col);
3533
- if (colType === 'json' || colType === 'jsonb') {
3893
+ if (this.isJsonColumnType(colType)) {
3534
3894
  clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
3535
3895
  continue;
3536
3896
  }
@@ -3603,7 +3963,7 @@ export class QueryInterface {
3603
3963
  // JSONB filter — mirrors buildAliasWhere.
3604
3964
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3605
3965
  const colType = this.pgTypeForColumn(targetMeta, col);
3606
- if (colType === 'json' || colType === 'jsonb') {
3966
+ if (this.isJsonColumnType(colType)) {
3607
3967
  this.collectJsonFilterParams(value, params, this.q(col));
3608
3968
  continue;
3609
3969
  }
@@ -3836,7 +4196,7 @@ export class QueryInterface {
3836
4196
  * vs relation-column never collide on one cached SQL string. Captures the
3837
4197
  * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
3838
4198
  */
3839
- orderByEntryFingerprint(d) {
4199
+ orderByEntryFingerprint(d, targetTable) {
3840
4200
  // Vector KNN ordering changes the emitted operator by metric and adds a
3841
4201
  // `::vector` param, so metric + direction must be part of the cache key.
3842
4202
  if (isVectorOrderBy(d)) {
@@ -3847,13 +4207,36 @@ export class QueryInterface {
3847
4207
  if (isJsonPathOrderBy(d)) {
3848
4208
  return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
3849
4209
  }
4210
+ // Pick-row relation ordering: the by-shape (column vs JSON path vs cast),
4211
+ // direction, nulls, pick.orderBy shape, and pick.where SHAPE are all SQL
4212
+ // text; the JSON paths and pick.where values are bound params and stay OUT
4213
+ // of the key. `targetTable` (the relation's target, resolved by the
4214
+ // caller) lets the pick.where fingerprint distinguish relation-filter
4215
+ // shapes inside it: two pick.wheres that differ only in shape must never
4216
+ // share one cached SQL string.
4217
+ if (isRelationPickOrderBy(d)) {
4218
+ const by = typeof d.by === 'string'
4219
+ ? `col=${JSON.stringify(d.by)}`
4220
+ : `jp(${JSON.stringify(d.by?.field)},${d.by?.type === 'numeric' ? 'num' : 'text'})`;
4221
+ const pickOrder = Object.entries(d.pick?.orderBy ?? {})
4222
+ .map(([k, v]) => `${k}:${this.orderByEntryFingerprint(v)}`)
4223
+ .join(',');
4224
+ const pickWhere = d.pick?.where
4225
+ ? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
4226
+ : '';
4227
+ return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere})`;
4228
+ }
3850
4229
  if (isOrderBySpec(d))
3851
4230
  return `spec(${d.sort},${d.nulls ?? ''})`;
3852
4231
  if (d && typeof d === 'object') {
3853
4232
  // Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
4233
+ // INSERTION order, never sorted: the compile side (buildRelationOrderBy)
4234
+ // emits one ORDER BY term per entry in Object.entries order, so entry
4235
+ // order is SQL-shaping precedence. A sorted fingerprint made
4236
+ // `{ name: 'asc', email: 'desc' }` and the swapped literal share one
4237
+ // cached SQL string — silently mis-ordered results on a warm cache.
3854
4238
  return `rel(${Object.entries(d)
3855
4239
  .map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
3856
- .sort()
3857
4240
  .join(',')})`;
3858
4241
  }
3859
4242
  return String(d);
@@ -3971,7 +4354,7 @@ export class QueryInterface {
3971
4354
  `of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
3972
4355
  }
3973
4356
  const colType = this.pgTypeForColumn(meta, col);
3974
- if (colType !== 'json' && colType !== 'jsonb') {
4357
+ if (!this.isJsonColumnType(colType)) {
3975
4358
  throw new ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
3976
4359
  `(actual type: ${colType}).`);
3977
4360
  }
@@ -3991,7 +4374,7 @@ export class QueryInterface {
3991
4374
  if (!params) {
3992
4375
  throw new ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
3993
4376
  }
3994
- params.push(spec.path.map(String));
4377
+ params.push(this.jsonPathParam(spec.path));
3995
4378
  const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
3996
4379
  const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
3997
4380
  const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
@@ -4019,12 +4402,21 @@ export class QueryInterface {
4019
4402
  throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
4020
4403
  `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
4021
4404
  }
4405
+ // Pick-row ordering (`{ pick, by }`): order by a value from ONE related
4406
+ // row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
4407
+ // Top-level findMany only (`ctx` present means we are inside a relation
4408
+ // subquery's orderBy) and hasMany only: validatePickOrderBy throws the
4409
+ // scope errors, shared with the cache-hit collect mirror.
4410
+ if (isRelationPickOrderBy(value)) {
4411
+ this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
4412
+ return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
4413
+ }
4022
4414
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
4023
4415
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
4024
4416
  const keys = Object.keys(value);
4025
4417
  if (keys.length !== 1 || keys[0] !== '_count') {
4026
4418
  throw new ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
4027
- `(got: ${keys.join(', ') || '(empty)'}).`);
4419
+ `or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
4028
4420
  }
4029
4421
  const { dir } = normalizeOrderBy(value._count);
4030
4422
  return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
@@ -4065,6 +4457,145 @@ export class QueryInterface {
4065
4457
  })
4066
4458
  .join(', ');
4067
4459
  }
4460
+ /**
4461
+ * Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
4462
+ * the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
4463
+ * param-collect mirror ({@link collectRelationPickOrderParams}) so both
4464
+ * always throw identically:
4465
+ *
4466
+ * - `nested` (inside a relation subquery's orderBy or a pick.orderBy):
4467
+ * top-level findMany only in this release (E003),
4468
+ * - manyToMany: not supported (E003 naming the limitation),
4469
+ * - to-one: order by the target column directly instead (E003),
4470
+ * - `pick.orderBy` is REQUIRED (deterministic row choice),
4471
+ * - `by` must be a target column name or a `{ field, path }` JSON-path spec.
4472
+ */
4473
+ pickOrderNestedError(relName) {
4474
+ return new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level ` +
4475
+ 'findMany orderBy: nested `with` orderBy does not support it.');
4476
+ }
4477
+ validatePickOrderBy(relName, relDef, spec, nested) {
4478
+ if (nested) {
4479
+ throw this.pickOrderNestedError(relName);
4480
+ }
4481
+ if (relDef.type === 'manyToMany') {
4482
+ throw new ValidationError(`[turbine] Pick-row ordering is not supported on manyToMany relation "${relName}": ` +
4483
+ 'hasMany relations only.');
4484
+ }
4485
+ if (relDef.type !== 'hasMany') {
4486
+ throw new ValidationError(`[turbine] Pick-row ordering is only for to-many (hasMany) relations; "${relName}" is ${relDef.type}. ` +
4487
+ `Order by the target column directly instead ({ ${relName}: { <column>: 'asc' } }).`);
4488
+ }
4489
+ const pickOrder = spec.pick?.orderBy;
4490
+ if (typeof spec.pick !== 'object' ||
4491
+ spec.pick === null ||
4492
+ typeof pickOrder !== 'object' ||
4493
+ pickOrder === null ||
4494
+ Object.keys(pickOrder).length === 0) {
4495
+ throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`pick.orderBy\` to choose ONE ` +
4496
+ "related row deterministically (e.g. pick: { orderBy: { createdAt: 'desc' } }).");
4497
+ }
4498
+ const by = spec.by;
4499
+ const validJsonBy = typeof by === 'object' && by !== null && typeof by.field === 'string' && Array.isArray(by.path);
4500
+ if (typeof by !== 'string' && !validJsonBy) {
4501
+ throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
4502
+ "or a JSON-path spec ({ field: 'data', path: ['title'] }).");
4503
+ }
4504
+ }
4505
+ /**
4506
+ * Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
4507
+ * that picks ONE related row (`ORDER BY <pick.orderBy> LIMIT 1`, optionally
4508
+ * filtered by `pick.where` and the target's global filter) and surfaces one
4509
+ * value from it (a plain target column or a JSON-path extraction) as the
4510
+ * parent ORDER BY key:
4511
+ *
4512
+ * ```sql
4513
+ * (SELECT ord0."data" #>> $1::text[] FROM "versions" ord0
4514
+ * WHERE ord0."instance_id" = "instances"."id" AND ord0."is_current" = $2
4515
+ * ORDER BY ord0."created_at" DESC LIMIT 1) ASC NULLS LAST
4516
+ * ```
4517
+ *
4518
+ * Param-push order (mirrored EXACTLY by
4519
+ * {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
4520
+ * target global filter → `pick.where` → `pick.orderBy` JSON paths.
4521
+ */
4522
+ buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
4523
+ if (!params) {
4524
+ throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
4525
+ }
4526
+ const targetMeta = this.schema.tables[relDef.to];
4527
+ if (!targetMeta)
4528
+ throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4529
+ // The value surfaced from the picked row (SELECT list: its param binds first).
4530
+ let byExpr;
4531
+ if (typeof spec.by === 'string') {
4532
+ const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4533
+ byExpr = `${alias}.${this.q(col)}`;
4534
+ }
4535
+ else {
4536
+ const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
4537
+ path: spec.by.path,
4538
+ });
4539
+ params.push(this.jsonPathParam(spec.by.path));
4540
+ const extract = this.dialect.buildJsonPathExtract(`${alias}.${this.q(col)}`, this.p(params.length));
4541
+ byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4542
+ }
4543
+ // Correlation to the parent row, then the target's global filter (a
4544
+ // soft-deleted / other-tenant row must never be picked: matches the
4545
+ // `with` subquery and to-one relation-orderBy semantics), then pick.where.
4546
+ let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4547
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
4548
+ if (gf)
4549
+ where += ` AND ${gf}`;
4550
+ if (spec.pick.where) {
4551
+ const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, alias, spec.pick.where, params);
4552
+ if (pickWhere)
4553
+ where += ` AND ${pickWhere}`;
4554
+ }
4555
+ // pick.orderBy: same surface as a relation `with` orderBy on the target
4556
+ // (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
4557
+ // here routes back through buildRelationOrderBy with ctx set and throws
4558
+ // the top-level-only E003.
4559
+ const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, alias, Object.entries(spec.pick.orderBy), params);
4560
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4561
+ const limitOne = this.buildPagination('1', undefined, true);
4562
+ // Parents with ZERO surviving related rows make the correlated subquery
4563
+ // yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
4564
+ // FIRST — every childless parent would top a "highest first" sort. Default
4565
+ // to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
4566
+ // NULL-is-smallest default diverges from Postgres) unless the caller set
4567
+ // `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
4568
+ const nullsSql = spec.nulls
4569
+ ? this.nullsSuffix(spec.nulls)
4570
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4571
+ ? ' NULLS LAST'
4572
+ : '';
4573
+ return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
4574
+ }
4575
+ /**
4576
+ * Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
4577
+ * validation (a warmed cache can never skip it), then pushes in the same
4578
+ * order: `by` JSON path → target global filter → `pick.where` →
4579
+ * `pick.orderBy` JSON paths.
4580
+ */
4581
+ collectRelationPickOrderParams(relName, relDef, spec, params) {
4582
+ this.validatePickOrderBy(relName, relDef, spec, false);
4583
+ const targetMeta = this.schema.tables[relDef.to];
4584
+ if (!targetMeta)
4585
+ throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4586
+ if (typeof spec.by === 'string') {
4587
+ this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4588
+ }
4589
+ else {
4590
+ this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, { path: spec.by.path });
4591
+ params.push(this.jsonPathParam(spec.by.path));
4592
+ }
4593
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
4594
+ if (spec.pick.where) {
4595
+ this.collectAliasWhereParams(relDef.to, targetMeta, spec.pick.where, params);
4596
+ }
4597
+ this.collectRelationOrderParams(relDef.to, targetMeta, Object.entries(spec.pick.orderBy), params);
4598
+ }
4068
4599
  /**
4069
4600
  * Compile the ORDER BY terms of a relation `with` clause against the
4070
4601
  * relation's table alias. One unified path for every relation shape
@@ -4115,10 +4646,15 @@ export class QueryInterface {
4115
4646
  }
4116
4647
  if (isJsonPathOrderBy(dirValue)) {
4117
4648
  this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
4118
- params.push(dirValue.path.map(String));
4649
+ params.push(this.jsonPathParam(dirValue.path));
4119
4650
  continue;
4120
4651
  }
4121
4652
  if (this.isRelationOrderByValue(dirValue)) {
4653
+ // Pick-row ordering is top-level-only: the build path throws the same
4654
+ // E003 (buildRelationOrderBy with ctx set), so the mirror must too.
4655
+ if (isRelationPickOrderBy(dirValue)) {
4656
+ throw this.pickOrderNestedError(key);
4657
+ }
4122
4658
  const relDef = targetMeta.relations[key];
4123
4659
  if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
4124
4660
  this.collectRelationCountParams(relDef, params);
@@ -5077,6 +5613,16 @@ export class QueryInterface {
5077
5613
  * Used to detect JSONB/array columns for specialized operators.
5078
5614
  * Uses pre-computed Map for O(1) lookup instead of linear scan.
5079
5615
  */
5616
+ /**
5617
+ * Case-insensitive json/jsonb column-type check. Postgres reports lowercase
5618
+ * udt_names, but SQLite/MySQL introspection surfaces the DECLARED type
5619
+ * (e.g. `JSON`), so every JSON-feature gate compares through this predicate
5620
+ * — build and collect sides alike, keeping the SQL-cache lockstep.
5621
+ */
5622
+ isJsonColumnType(colType) {
5623
+ const t = colType.toLowerCase();
5624
+ return t === 'json' || t === 'jsonb';
5625
+ }
5080
5626
  getColumnPgType(column) {
5081
5627
  return this.columnPgTypeMap.get(column) ?? 'text';
5082
5628
  }
@@ -5146,7 +5692,8 @@ export class QueryInterface {
5146
5692
  let pathParamIdx = null;
5147
5693
  const pathExtract = () => {
5148
5694
  if (pathParamIdx === null) {
5149
- params.push(filter.path);
5695
+ // Only reached when a path-requiring clause validated filter.path.
5696
+ params.push(this.jsonPathParam(filter.path, filter.path));
5150
5697
  pathParamIdx = params.length;
5151
5698
  }
5152
5699
  return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
@@ -5182,6 +5729,24 @@ export class QueryInterface {
5182
5729
  }
5183
5730
  return clauses;
5184
5731
  }
5732
+ /**
5733
+ * Bind value for a JSON path parameter, encoded per dialect. PostgreSQL's
5734
+ * `#>>` takes a `text[]` (the segments as strings — or `nativeForm` when the
5735
+ * caller has a specific native binding, e.g. JsonFilter's raw path array).
5736
+ * Every other engine's JSON function (`json_extract` / `JSON_EXTRACT` /
5737
+ * `JSON_VALUE`) takes a `'$'`-rooted JSONPath STRING: binding the raw array
5738
+ * would arrive as `'["a"]'` (the driver shims JSON.stringify non-primitive
5739
+ * params) and fail at runtime with the engine's bad-JSON-path error. The
5740
+ * encoded path stays a bound parameter — never spliced into SQL text — so
5741
+ * the build/collect param mirrors stay in lockstep and injection-safe.
5742
+ */
5743
+ jsonPathParam(path, nativeForm) {
5744
+ if (this.dialect.jsonPathSupport === 'native')
5745
+ return nativeForm ?? path.map(String);
5746
+ return `$${path
5747
+ .map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `."${String(seg).replace(/"/g, '\\"')}"`)
5748
+ .join('')}`;
5749
+ }
5185
5750
  /**
5186
5751
  * Cast an extracted JSON path text value to a numeric type for range
5187
5752
  * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to