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.
@@ -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;
@@ -327,6 +435,10 @@ class QueryInterface {
327
435
  */
328
436
  async runFindManyBatched(args) {
329
437
  const withClause = args.with;
438
+ // Scope-rule parity with the join strategy (which throws at SQL build):
439
+ // reject nested pick-row ordering BEFORE the base query so acceptance
440
+ // never depends on how many rows come back.
441
+ (0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
330
442
  // Capture the opt-out from the ARGS before any await: this.currentSkip is
331
443
  // instance state on a cached accessor, so a concurrent build during the
332
444
  // base-query await would overwrite it (tenant query loading relations with
@@ -378,24 +490,84 @@ class QueryInterface {
378
490
  * On hit, increments counters and returns the cached entry.
379
491
  *
380
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.
381
503
  */
382
504
  acquireSql(cacheKey, build) {
383
505
  if (!this.sqlCacheEnabled) {
384
- const sql = build();
506
+ this.lastCacheHit = false;
507
+ const sql = build([]);
385
508
  this.cacheMisses++;
386
509
  return { sql, name: (0, utils_js_1.sqlToPreparedName)(sql) };
387
510
  }
388
511
  const cached = this.sqlTemplateCache.get(cacheKey);
389
512
  if (cached) {
390
513
  this.cacheHits++;
514
+ this.lastCacheHit = true;
391
515
  return cached;
392
516
  }
393
- const sql = build();
517
+ this.lastCacheHit = false;
518
+ const sql = build([]);
394
519
  const entry = { sql, name: (0, utils_js_1.sqlToPreparedName)(sql) };
395
520
  this.sqlTemplateCache.set(cacheKey, entry);
396
521
  this.cacheMisses++;
397
522
  return entry;
398
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
+ }
399
571
  /**
400
572
  * Reset the per-instance unlimited-query warning dedupe set.
401
573
  * Exposed for tests so a single test process can verify the warning fires
@@ -552,6 +724,8 @@ class QueryInterface {
552
724
  */
553
725
  async runFindUniqueBatched(args) {
554
726
  const withClause = args.with;
727
+ // Same scope-rule parity as runFindManyBatched: reject before querying.
728
+ (0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
555
729
  const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
556
730
  const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
557
731
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
@@ -592,19 +766,22 @@ class QueryInterface {
592
766
  });
593
767
  // Simple path: plain equality, no operators/null/OR
594
768
  if (!args.with && isSimpleWhere) {
595
- const entry = this.acquireSql(ck, () => {
769
+ const buildSql = (freshParams) => {
596
770
  const qt = this.q(this.table);
597
- const tempParams = whereKeys.map((k) => whereObj[k]);
598
- 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
+ });
599
775
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
600
776
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
601
- void tempParams; // params are positional, SQL is value-invariant
602
777
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
603
- });
778
+ };
779
+ const entry = this.acquireSql(ck, buildSql);
604
780
  // Collect params (same order as build)
605
781
  for (const k of whereKeys) {
606
782
  params.push(whereObj[k]);
607
783
  }
784
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
608
785
  return {
609
786
  sql: entry.sql,
610
787
  params,
@@ -618,16 +795,17 @@ class QueryInterface {
618
795
  }
619
796
  // General path (with operators, null, OR, with clause)
620
797
  if (!args.with) {
621
- const entry = this.acquireSql(ck, () => {
622
- const freshParams = [];
798
+ const buildSql = (freshParams) => {
623
799
  const clause = this.buildWhereClause(whereObj, freshParams);
624
800
  const whereSql = clause ? ` WHERE ${clause}` : '';
625
801
  const qt = this.q(this.table);
626
802
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
627
803
  return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
628
- });
804
+ };
805
+ const entry = this.acquireSql(ck, buildSql);
629
806
  // Collect params
630
807
  this.collectWhereParams(whereObj, params);
808
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
631
809
  return {
632
810
  sql: entry.sql,
633
811
  params,
@@ -644,16 +822,17 @@ class QueryInterface {
644
822
  // 1. buildWhere pushes where params
645
823
  // 2. buildSelectWithRelations pushes relation params to same array
646
824
  // We must preserve this exact order.
647
- const entry = this.acquireSql(ck, () => {
648
- const freshParams = [];
825
+ const buildSql = (freshParams) => {
649
826
  const clause = this.buildWhereClause(whereObj, freshParams);
650
827
  const whereSql = clause ? ` WHERE ${clause}` : '';
651
828
  const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
652
829
  return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
653
- });
830
+ };
831
+ const entry = this.acquireSql(ck, buildSql);
654
832
  // Collect params in exact build order: where first, then with-clause relations
655
833
  this.collectWhereParams(whereObj, params);
656
834
  this.collectWithParams(args.with, params);
835
+ this.crossCheckCache('findUnique', ck, entry, buildSql, params);
657
836
  const parseWith = this.makeNestedParser(args.with);
658
837
  return {
659
838
  sql: entry.sql,
@@ -718,7 +897,7 @@ class QueryInterface {
718
897
  if (this.warnedTables.has(this.table))
719
898
  return;
720
899
  this.warnedTables.add(this.table);
721
- 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. ` +
722
901
  'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
723
902
  }
724
903
  /**
@@ -738,6 +917,21 @@ class QueryInterface {
738
917
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
739
918
  buildFindMany(args) {
740
919
  this.currentSkip = args?.skipGlobalFilters;
920
+ // `distinct` + relation orderBy is refused up front (E003): the distinct
921
+ // path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
922
+ // <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
923
+ // to-one relation ordering) would reference the parent table name out of
924
+ // scope — a guaranteed "missing FROM-clause entry" crash on Postgres.
925
+ // Checked BEFORE the SQL cache so build and warm-cache paths throw
926
+ // identically (same rule as the vector guard inside the distinct branch).
927
+ if (args?.distinct && args.distinct.length > 0 && args.orderBy) {
928
+ for (const d of Object.values(args.orderBy)) {
929
+ if (this.isRelationOrderByValue(d)) {
930
+ throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with relation orderBy (pick-row, `_count`, or ' +
931
+ 'to-one relation ordering): the outer re-order cannot reference the parent table.');
932
+ }
933
+ }
934
+ }
741
935
  const columnsList = this.resolveColumns(args?.select, args?.omit);
742
936
  const colKey = columnsList ? columnsList.join(',') : '*';
743
937
  // AND-merge this table's global filter into the user where; `hasWhere` gates
@@ -751,7 +945,7 @@ class QueryInterface {
751
945
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
752
946
  const orderFp = args?.orderBy
753
947
  ? Object.entries(args.orderBy)
754
- .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
948
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations[k]?.to)}`)
755
949
  .join(',')
756
950
  : '';
757
951
  const cursorFp = args?.cursor
@@ -760,15 +954,24 @@ class QueryInterface {
760
954
  .sort()
761
955
  .join(',')
762
956
  : '';
763
- 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(',') : '';
764
961
  const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
765
- const limitFp = effectiveLimit !== undefined ? '1' : '0';
766
- 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';
767
971
  const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
768
972
  const params = [];
769
- const entry = this.acquireSql(ck, () => {
770
- // Fresh build generates SQL and populates freshParams
771
- const freshParams = [];
973
+ const buildSql = (freshParams) => {
974
+ // Fresh build: generates SQL and populates freshParams
772
975
  const { sql: freshWhereSql } = hasWhere
773
976
  ? (() => {
774
977
  const clause = this.buildWhereClause(whereObj, freshParams);
@@ -848,7 +1051,8 @@ class QueryInterface {
848
1051
  }
849
1052
  sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
850
1053
  return sql;
851
- });
1054
+ };
1055
+ const entry = this.acquireSql(ck, buildSql);
852
1056
  // Collect params in exact build order:
853
1057
  // 1. WHERE params (includes the AND-merged global filter, if any)
854
1058
  if (hasWhere) {
@@ -879,6 +1083,7 @@ class QueryInterface {
879
1083
  if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
880
1084
  params.push(Number(args.offset));
881
1085
  }
1086
+ this.crossCheckCache('findMany', ck, entry, buildSql, params);
882
1087
  // Build the row parser once (positional shapes are computed here, not per row).
883
1088
  const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
884
1089
  return {
@@ -1204,8 +1409,7 @@ class QueryInterface {
1204
1409
  const whereFp = this.fingerprintWhere(whereObj);
1205
1410
  const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1206
1411
  const params = [];
1207
- const buildSql = () => {
1208
- const freshParams = [];
1412
+ const buildSql = (freshParams) => {
1209
1413
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1210
1414
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1211
1415
  if (lock) {
@@ -1229,13 +1433,15 @@ class QueryInterface {
1229
1433
  };
1230
1434
  let sql;
1231
1435
  let preparedName;
1436
+ let cacheEntry;
1232
1437
  if (ck) {
1233
- const entry = this.acquireSql(ck, buildSql);
1234
- sql = entry.sql;
1235
- preparedName = entry.name;
1438
+ cacheEntry = this.acquireSql(ck, buildSql);
1439
+ sql = cacheEntry.sql;
1440
+ preparedName = cacheEntry.name;
1236
1441
  }
1237
1442
  else {
1238
- sql = buildSql();
1443
+ // optimisticLock path: value-variant version check → uncacheable, no cross-check.
1444
+ sql = buildSql([]);
1239
1445
  }
1240
1446
  // Collect params: SET first, then WHERE, then version check (same order as fresh build)
1241
1447
  this.collectSetParams(dataObj, params);
@@ -1243,6 +1449,9 @@ class QueryInterface {
1243
1449
  if (lock) {
1244
1450
  params.push(lock.expected);
1245
1451
  }
1452
+ if (ck && cacheEntry) {
1453
+ this.crossCheckCache('update', ck, cacheEntry, buildSql, params);
1454
+ }
1246
1455
  return {
1247
1456
  sql,
1248
1457
  params,
@@ -1374,8 +1583,7 @@ class QueryInterface {
1374
1583
  const whereFp = this.fingerprintWhere(whereObj);
1375
1584
  const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
1376
1585
  const params = [];
1377
- const entry = this.acquireSql(ck, () => {
1378
- const freshParams = [];
1586
+ const buildSql = (freshParams) => {
1379
1587
  const clause = this.buildWhereClause(whereObj, freshParams);
1380
1588
  const whereSql = clause ? ` WHERE ${clause}` : '';
1381
1589
  // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
@@ -1383,8 +1591,10 @@ class QueryInterface {
1383
1591
  return this.dialect.buildDeleteStatement
1384
1592
  ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1385
1593
  : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1386
- });
1594
+ };
1595
+ const entry = this.acquireSql(ck, buildSql);
1387
1596
  this.collectWhereParams(whereObj, params);
1597
+ this.crossCheckCache('delete', ck, entry, buildSql, params);
1388
1598
  return {
1389
1599
  sql: entry.sql,
1390
1600
  params,
@@ -1512,16 +1722,17 @@ class QueryInterface {
1512
1722
  const whereFp = this.fingerprintWhere(whereObj);
1513
1723
  const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1514
1724
  const params = [];
1515
- const entry = this.acquireSql(ck, () => {
1516
- const freshParams = [];
1725
+ const buildSql = (freshParams) => {
1517
1726
  const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
1518
1727
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1519
1728
  const whereClause = this.buildWhereClause(whereObj, freshParams);
1520
1729
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1521
1730
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1522
- });
1731
+ };
1732
+ const entry = this.acquireSql(ck, buildSql);
1523
1733
  this.collectSetParams(dataObj, params);
1524
1734
  this.collectWhereParams(whereObj, params);
1735
+ this.crossCheckCache('updateMany', ck, entry, buildSql, params);
1525
1736
  return {
1526
1737
  sql: entry.sql,
1527
1738
  params,
@@ -1548,13 +1759,14 @@ class QueryInterface {
1548
1759
  const whereFp = this.fingerprintWhere(whereObj);
1549
1760
  const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
1550
1761
  const params = [];
1551
- const entry = this.acquireSql(ck, () => {
1552
- const freshParams = [];
1762
+ const buildSql = (freshParams) => {
1553
1763
  const clause = this.buildWhereClause(whereObj, freshParams);
1554
1764
  const whereSql = clause ? ` WHERE ${clause}` : '';
1555
1765
  return `DELETE FROM ${this.q(this.table)}${whereSql}`;
1556
- });
1766
+ };
1767
+ const entry = this.acquireSql(ck, buildSql);
1557
1768
  this.collectWhereParams(whereObj, params);
1769
+ this.crossCheckCache('deleteMany', ck, entry, buildSql, params);
1558
1770
  return {
1559
1771
  sql: entry.sql,
1560
1772
  params,
@@ -1581,15 +1793,16 @@ class QueryInterface {
1581
1793
  const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
1582
1794
  const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
1583
1795
  const params = [];
1584
- const entry = this.acquireSql(ck, () => {
1585
- const freshParams = [];
1796
+ const buildSql = (freshParams) => {
1586
1797
  const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
1587
1798
  const whereSql = clause ? ` WHERE ${clause}` : '';
1588
1799
  return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1589
- });
1800
+ };
1801
+ const entry = this.acquireSql(ck, buildSql);
1590
1802
  if (hasWhere) {
1591
1803
  this.collectWhereParams(whereObj, params);
1592
1804
  }
1805
+ this.crossCheckCache('count', ck, entry, buildSql, params);
1593
1806
  return {
1594
1807
  sql: entry.sql,
1595
1808
  params,
@@ -1612,67 +1825,124 @@ class QueryInterface {
1612
1825
  const meta = this.schema.tables[this.table];
1613
1826
  if (meta) {
1614
1827
  for (const key of args.by) {
1615
- if (!(key in meta.columnMap)) {
1828
+ if (typeof key === 'string' && !(key in meta.columnMap)) {
1616
1829
  throw new errors_js_1.ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
1617
1830
  }
1618
1831
  }
1619
1832
  }
1620
1833
  this.currentSkip = args.skipGlobalFilters;
1621
- const groupColsRaw = args.by.map((k) => this.toColumn(k));
1622
- const groupCols = groupColsRaw.map((c) => this.q(c));
1623
1834
  const gbWhere = this.mergeGlobalFilter(args.where);
1624
1835
  const { sql: whereSql, params } = gbWhere
1625
1836
  ? this.buildWhere(gbWhere)
1626
1837
  : { sql: '', params: [] };
1627
- // Build SELECT expressions: group-by columns + aggregate functions
1628
- const selectExprs = [...groupCols];
1838
+ // Row source. Plain: `"table"<WHERE>`. With `distinctOn` (PostgreSQL
1839
+ // only), the groupBy runs over one representative row per column
1840
+ // combination: the wrapper carries args.where INSIDE it (filter before
1841
+ // picking) and is aliased as the table name so every outer expression is
1842
+ // byte-identical either way.
1843
+ const fromSql = args.distinctOn
1844
+ ? this.buildDistinctOnSource(args.distinctOn, whereSql, params)
1845
+ : `${this.q(this.table)}${whereSql}`;
1846
+ // Group keys: plain columns and/or JSON-path keys. Output-name collisions
1847
+ // are rejected up front — and the check runs over the EMITTED SQL output
1848
+ // column names (snake_case column / JSON alias / `_agg_key` aggregate
1849
+ // alias), not just the given arg keys: the driver keeps only the LAST
1850
+ // duplicate field per row object, so a JSON alias equal to another key's
1851
+ // snake_case column (or an aggregate output alias) would silently clobber
1852
+ // that value in the results.
1853
+ const groupExprs = [];
1854
+ const selectExprs = [];
1855
+ /** by entries in order: how to read each group key off the result row. */
1856
+ const byReaders = [];
1857
+ const usedResultKeys = new Set();
1858
+ const claimResultKey = (key, what) => {
1859
+ if (key === '_count' || usedResultKeys.has(key)) {
1860
+ throw new errors_js_1.ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
1861
+ `"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
1862
+ }
1863
+ usedResultKeys.add(key);
1864
+ };
1865
+ for (const entry of args.by) {
1866
+ if (typeof entry === 'string') {
1867
+ const col = this.toColumn(entry);
1868
+ claimResultKey(entry, `column "${col}"`);
1869
+ // The emitted output column is the snake_case name; claim it too (when
1870
+ // it differs from the result key) so a JSON alias like 'created_at'
1871
+ // cannot silently shadow the 'createdAt' group key on the wire.
1872
+ if (col !== entry)
1873
+ claimResultKey(col, `column "${col}"`);
1874
+ groupExprs.push(this.q(col));
1875
+ selectExprs.push(this.q(col));
1876
+ byReaders.push({ resultKey: entry, rowKey: col, raw: false });
1877
+ }
1878
+ else {
1879
+ const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
1880
+ params.push(this.jsonPathParam(entry.path));
1881
+ const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
1882
+ const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
1883
+ claimResultKey(alias, `JSON path on "${entry.field}"`);
1884
+ // Same expression (and the same $n placeholder) in SELECT and GROUP BY.
1885
+ selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
1886
+ groupExprs.push(extract);
1887
+ byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
1888
+ }
1889
+ }
1629
1890
  // _count
1630
1891
  if (args._count === true || args._count === undefined) {
1631
1892
  // default: always include count
1632
1893
  selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1633
1894
  }
1634
- // _sum
1635
- if (args._sum) {
1636
- for (const [field, enabled] of Object.entries(args._sum)) {
1637
- if (enabled) {
1638
- const col = this.toColumn(field);
1639
- selectExprs.push(`SUM(${this.q(col)}) AS ${this.q(`_sum_${col}`)}`);
1640
- }
1641
- }
1642
- }
1643
- // _avg
1644
- if (args._avg) {
1645
- for (const [field, enabled] of Object.entries(args._avg)) {
1646
- if (enabled) {
1647
- const col = this.toColumn(field);
1648
- selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
1649
- }
1650
- }
1651
- }
1652
- // _min
1653
- if (args._min) {
1654
- for (const [field, enabled] of Object.entries(args._min)) {
1655
- if (enabled) {
1656
- const col = this.toColumn(field);
1657
- selectExprs.push(`MIN(${this.q(col)}) AS ${this.q(`_min_${col}`)}`);
1895
+ // _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
1896
+ // {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
1897
+ // as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
1898
+ // alias (and coercion kind) in the transform; `jsonAggExprs` lets HAVING
1899
+ // reuse the exact aggregate expression (same placeholders) by alias.
1900
+ const jsonAggFields = new Map();
1901
+ const jsonAggExprs = new Map();
1902
+ const buildAggregates = (aggKey, sqlFn, spec) => {
1903
+ if (!spec)
1904
+ return;
1905
+ for (const [key, target] of Object.entries(spec)) {
1906
+ if (!target)
1907
+ continue;
1908
+ if (target === true) {
1909
+ const col = this.toColumn(key);
1910
+ // Aggregate output aliases share the same output-name namespace as
1911
+ // the group keys: `_sum: { totalPrice: true, total_price: {json} }`
1912
+ // would emit two "_sum_total_price" columns and silently drop one.
1913
+ claimResultKey(`${aggKey}_${col}`, `${aggKey} of column "${col}"`);
1914
+ const inner = `${sqlFn}(${this.q(col)})`;
1915
+ const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1916
+ selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
1917
+ continue;
1658
1918
  }
1659
- }
1660
- }
1661
- // _max
1662
- if (args._max) {
1663
- for (const [field, enabled] of Object.entries(args._max)) {
1664
- if (enabled) {
1665
- const col = this.toColumn(field);
1666
- selectExprs.push(`MAX(${this.q(col)}) AS ${this.q(`_max_${col}`)}`);
1919
+ const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
1920
+ const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
1921
+ if (alwaysNumeric && target.type === 'text') {
1922
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${this.table}": ` +
1923
+ `${aggKey} over a JSON path is always numeric: remove \`type: 'text'\`.`);
1667
1924
  }
1925
+ const numeric = alwaysNumeric || target.type === 'numeric';
1926
+ claimResultKey(`${aggKey}_${key}`, `${aggKey} JSON target "${key}"`);
1927
+ params.push(this.jsonPathParam(target.path));
1928
+ const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
1929
+ const inner = `${sqlFn}(${numeric ? this.castJsonNumeric(extract) : extract})`;
1930
+ const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1931
+ selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
1932
+ jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
1933
+ jsonAggExprs.set(`${key}:${aggKey}`, expr);
1668
1934
  }
1669
- }
1670
- let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
1935
+ };
1936
+ buildAggregates('_sum', 'SUM', args._sum);
1937
+ buildAggregates('_avg', 'AVG', args._avg);
1938
+ buildAggregates('_min', 'MIN', args._min);
1939
+ buildAggregates('_max', 'MAX', args._max);
1940
+ let sql = `SELECT ${selectExprs.join(', ')} FROM ${fromSql} GROUP BY ${groupExprs.join(', ')}`;
1671
1941
  // HAVING — filter whole groups by their aggregate values.
1672
1942
  // Appends to the same `params` array, so placeholders continue from the
1673
1943
  // WHERE clause's parameter positions (this.p(params.length) below).
1674
1944
  if (args.having) {
1675
- const havingClauses = this.buildHavingClauses(args.having, params);
1945
+ const havingClauses = this.buildHavingClauses(args.having, params, jsonAggExprs);
1676
1946
  if (havingClauses.length > 0) {
1677
1947
  sql += ` HAVING ${havingClauses.join(' AND ')}`;
1678
1948
  }
@@ -1688,9 +1958,11 @@ class QueryInterface {
1688
1958
  const parsed = this.parseRow(row, this.table);
1689
1959
  // Restructure aggregate results into nested objects (Prisma-style)
1690
1960
  const restructured = {};
1691
- // Copy group-by fields
1692
- for (const field of args.by) {
1693
- restructured[field] = parsed[field];
1961
+ // Copy group-by fields. JSON-path keys read their alias off the raw
1962
+ // row (the alias is not a table column, so parseRow's snake→camel
1963
+ // mapping must not touch it).
1964
+ for (const reader of byReaders) {
1965
+ restructured[reader.resultKey] = reader.raw ? row[reader.rowKey] : parsed[reader.resultKey];
1694
1966
  }
1695
1967
  // _count
1696
1968
  if ('_count' in row) {
@@ -1705,29 +1977,27 @@ class QueryInterface {
1705
1977
  const minObj = {};
1706
1978
  const maxObj = {};
1707
1979
  let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
1980
+ // JSON-path aggregates keep their arg key verbatim; plain-column
1981
+ // aggregates keep the snake→camel field mapping.
1982
+ const jsonAgg = (rawKey) => jsonAggFields.get(rawKey);
1983
+ const fieldFor = (rawKey, col) => jsonAgg(rawKey)?.field ?? this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1708
1984
  for (const [rawKey, rawValue] of Object.entries(row)) {
1709
1985
  if (rawKey.startsWith('_sum_')) {
1710
- const col = rawKey.slice(5);
1711
- const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1712
- sumObj[field] = rawValue !== null ? Number(rawValue) : null;
1986
+ sumObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
1713
1987
  hasSums = true;
1714
1988
  }
1715
1989
  else if (rawKey.startsWith('_avg_')) {
1716
- const col = rawKey.slice(5);
1717
- const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1718
- avgObj[field] = rawValue !== null ? Number(rawValue) : null;
1990
+ avgObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
1719
1991
  hasAvgs = true;
1720
1992
  }
1721
1993
  else if (rawKey.startsWith('_min_')) {
1722
- const col = rawKey.slice(5);
1723
- const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1724
- minObj[field] = rawValue;
1994
+ const j = jsonAgg(rawKey);
1995
+ minObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
1725
1996
  hasMins = true;
1726
1997
  }
1727
1998
  else if (rawKey.startsWith('_max_')) {
1728
- const col = rawKey.slice(5);
1729
- const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1730
- maxObj[field] = rawValue;
1999
+ const j = jsonAgg(rawKey);
2000
+ maxObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
1731
2001
  hasMaxs = true;
1732
2002
  }
1733
2003
  }
@@ -1744,6 +2014,75 @@ class QueryInterface {
1744
2014
  tag: `${this.table}.groupBy`,
1745
2015
  };
1746
2016
  }
2017
+ /**
2018
+ * Validate a JSON-path target (group key or aggregate target) in groupBy:
2019
+ * the field must resolve to a real json/jsonb column and the path must be a
2020
+ * non-empty array of keys/indexes. Returns the resolved snake_case column.
2021
+ */
2022
+ resolveJsonPathTarget(context, field, path) {
2023
+ if (typeof field !== 'string') {
2024
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on table "${this.table}" requires a string \`field\`.`);
2025
+ }
2026
+ const col = this.toColumn(field);
2027
+ if (!Array.isArray(path) ||
2028
+ path.length === 0 ||
2029
+ path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
2030
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
2031
+ `array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
2032
+ }
2033
+ const colType = this.pgTypeForColumn(this.tableMeta, col);
2034
+ if (!this.isJsonColumnType(colType)) {
2035
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}": column "${col}" on table "${this.table}" is not a JSON ` +
2036
+ `column (actual type: ${colType}).`);
2037
+ }
2038
+ return col;
2039
+ }
2040
+ /**
2041
+ * Build the `distinctOn` row source for groupBy (PostgreSQL only: other
2042
+ * engines throw {@link UnsupportedFeatureError} E017):
2043
+ *
2044
+ * ```sql
2045
+ * (SELECT DISTINCT ON ("c1") * FROM "table"<WHERE> ORDER BY "c1", <orderBy>) AS "table"
2046
+ * ```
2047
+ *
2048
+ * The wrapper is aliased as the table name so every outer expression (group
2049
+ * keys, aggregates, HAVING, ORDER BY) is byte-identical to the plain path.
2050
+ * `distinctOn.orderBy` is required (it decides which row survives) and
2051
+ * supports plain columns, {@link OrderBySpec} nulls, and JSON-path specs;
2052
+ * JSON paths push their text[] param here, after the WHERE params.
2053
+ */
2054
+ buildDistinctOnSource(distinctOn, whereSql, params) {
2055
+ if (this.dialect.name !== 'postgresql') {
2056
+ throw new errors_js_1.UnsupportedFeatureError('DISTINCT ON row source (groupBy distinctOn)', this.dialect.name, 'groupBy({ distinctOn }) requires PostgreSQL: SELECT DISTINCT ON is not portable.');
2057
+ }
2058
+ if (!Array.isArray(distinctOn.columns) || distinctOn.columns.length === 0) {
2059
+ throw new errors_js_1.ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires a non-empty \`columns\` array.`);
2060
+ }
2061
+ const orderEntries = Object.entries(distinctOn.orderBy ?? {});
2062
+ if (orderEntries.length === 0) {
2063
+ throw new errors_js_1.ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires \`orderBy\` to pick ONE row per ` +
2064
+ "column combination deterministically (e.g. orderBy: { createdAt: 'desc' }).");
2065
+ }
2066
+ const distinctCols = distinctOn.columns.map((c) => this.q(this.toColumn(c)));
2067
+ // DISTINCT ON expressions must lead the ORDER BY; the user's orderBy then
2068
+ // decides which row survives per combination.
2069
+ const orderParts = [...distinctCols];
2070
+ for (const [key, value] of orderEntries) {
2071
+ if ((0, filters_js_1.isJsonPathOrderBy)(value)) {
2072
+ orderParts.push(this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params));
2073
+ continue;
2074
+ }
2075
+ if ((0, filters_js_1.isVectorOrderBy)(value) || this.isRelationOrderByValue(value)) {
2076
+ throw new errors_js_1.ValidationError(`[turbine] groupBy distinctOn.orderBy on "${key}" (table "${this.table}") supports plain columns, ` +
2077
+ 'sort specs, and JSON-path orderings only.');
2078
+ }
2079
+ const col = this.resolveOrderByColumn(this.table, this.tableMeta, key);
2080
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
2081
+ orderParts.push(`${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`);
2082
+ }
2083
+ return (`(SELECT DISTINCT ON (${distinctCols.join(', ')}) * FROM ${this.q(this.table)}${whereSql} ` +
2084
+ `ORDER BY ${orderParts.join(', ')}) AS ${this.q(this.table)}`);
2085
+ }
1747
2086
  /**
1748
2087
  * Build the SQL fragments for a {@link HavingClause}.
1749
2088
  *
@@ -1754,8 +2093,14 @@ class QueryInterface {
1754
2093
  * comparison value is pushed onto the shared `params` array and referenced by
1755
2094
  * a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
1756
2095
  * interpolation of user values.
2096
+ *
2097
+ * `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
2098
+ * exact aggregate expression a JSON-path aggregate emitted in SELECT
2099
+ * (including its already-bound path placeholder), so HAVING on a JSON-path
2100
+ * aggregate alias reuses the same expression instead of resolving the alias
2101
+ * as a column.
1757
2102
  */
1758
- buildHavingClauses(having, params) {
2103
+ buildHavingClauses(having, params, jsonAggExprs) {
1759
2104
  const clauses = [];
1760
2105
  // Maps the per-field aggregate key to its SQL function name. The set of
1761
2106
  // allowed keys is fixed here — any other key on a field's filter object is
@@ -1782,8 +2127,14 @@ class QueryInterface {
1782
2127
  }
1783
2128
  // toColumn validates the field against schema metadata (throws
1784
2129
  // ValidationError on unknown columns) and q() quotes the identifier — no
1785
- // unvalidated identifier ever reaches the SQL string.
1786
- const quotedCol = this.q(this.toColumn(key));
2130
+ // unvalidated identifier ever reaches the SQL string. Resolution is lazy:
2131
+ // a JSON-path aggregate alias is not a column, so it must not hit
2132
+ // toColumn when every aggregate under it resolves via `jsonAggExprs`.
2133
+ let quotedCol = null;
2134
+ const columnExpr = () => {
2135
+ quotedCol ??= this.q(this.toColumn(key));
2136
+ return quotedCol;
2137
+ };
1787
2138
  for (const [aggKey, filter] of Object.entries(value)) {
1788
2139
  if (filter === undefined)
1789
2140
  continue;
@@ -1792,7 +2143,7 @@ class QueryInterface {
1792
2143
  throw new errors_js_1.ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
1793
2144
  `Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
1794
2145
  }
1795
- const expr = `${fn}(${quotedCol})`;
2146
+ const expr = jsonAggExprs?.get(`${key}:${aggKey}`) ?? `${fn}(${columnExpr()})`;
1796
2147
  clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
1797
2148
  }
1798
2149
  }
@@ -2439,7 +2790,7 @@ class QueryInterface {
2439
2790
  // JSONB filter
2440
2791
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2441
2792
  const colType = this.getColumnPgType(rawColumn);
2442
- if (colType === 'json' || colType === 'jsonb') {
2793
+ if (this.isJsonColumnType(colType)) {
2443
2794
  this.collectJsonFilterParams(value, params, this.q(rawColumn));
2444
2795
  continue;
2445
2796
  }
@@ -2550,7 +2901,7 @@ class QueryInterface {
2550
2901
  // the target column is json/jsonb.
2551
2902
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2552
2903
  const colType = this.pgTypeForColumn(meta, col);
2553
- if (colType === 'json' || colType === 'jsonb') {
2904
+ if (this.isJsonColumnType(colType)) {
2554
2905
  this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
2555
2906
  continue;
2556
2907
  }
@@ -2620,7 +2971,8 @@ class QueryInterface {
2620
2971
  let pathPushed = false;
2621
2972
  const pushPathOnce = () => {
2622
2973
  if (!pathPushed) {
2623
- params.push(filter.path);
2974
+ // Only reached when a path-requiring clause validated filter.path.
2975
+ params.push(this.jsonPathParam(filter.path, filter.path));
2624
2976
  pathPushed = true;
2625
2977
  }
2626
2978
  };
@@ -2672,15 +3024,20 @@ class QueryInterface {
2672
3024
  // then the path bound as one text[] param.
2673
3025
  if ((0, filters_js_1.isJsonPathOrderBy)(dir)) {
2674
3026
  this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
2675
- params.push(dir.path.map(String));
3027
+ params.push(this.jsonPathParam(dir.path));
2676
3028
  continue;
2677
3029
  }
2678
3030
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2679
- // subquery as `_count` mirror its global-filter params. To-one relation
2680
- // orderBy carries the target's global filter once per ordered column.
3031
+ // subquery as `_count`: mirror its global-filter params. Pick-row
3032
+ // ordering mirrors its full param chain (by-path / global filter /
3033
+ // pick.where / pick.orderBy paths). To-one relation orderBy carries the
3034
+ // target's global filter once per ordered column.
2681
3035
  if (this.isRelationOrderByValue(dir)) {
2682
3036
  const relDef = this.tableMeta.relations[key];
2683
- if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
3037
+ if (relDef && (0, filters_js_1.isRelationPickOrderBy)(dir)) {
3038
+ this.collectRelationPickOrderParams(key, relDef, dir, params);
3039
+ }
3040
+ else if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2684
3041
  this.collectRelationCountParams(relDef, params);
2685
3042
  }
2686
3043
  else if (relDef) {
@@ -2770,12 +3127,15 @@ class QueryInterface {
2770
3127
  }
2771
3128
  // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
2772
3129
  if (opts.orderBy) {
2773
- const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
3130
+ const targetRels = this.schema.tables[relDef.to]?.relations;
3131
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
2774
3132
  subParts.push(`o=${oEntries.join(',')}`);
2775
3133
  }
2776
- // limit presence
3134
+ // limit presence, but on inline-pagination engines (MySQL) the literal
3135
+ // value is baked into the subquery SQL, so fingerprint the value there or
3136
+ // `{limit:3}` and `{limit:5}` would share one cached statement.
2777
3137
  if (opts.limit !== undefined) {
2778
- subParts.push('l=1');
3138
+ subParts.push(this.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
2779
3139
  }
2780
3140
  // nested with (recurse)
2781
3141
  if (opts.with) {
@@ -3180,7 +3540,7 @@ class QueryInterface {
3180
3540
  // Handle JSONB filter operators (for json/jsonb columns)
3181
3541
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3182
3542
  const colType = this.getColumnPgType(rawColumn);
3183
- if (colType === 'json' || colType === 'jsonb') {
3543
+ if (this.isJsonColumnType(colType)) {
3184
3544
  const jsonClauses = this.buildJsonFilterClauses(column, value, params);
3185
3545
  andClauses.push(...jsonClauses);
3186
3546
  continue;
@@ -3395,7 +3755,7 @@ class QueryInterface {
3395
3755
  // jsonb value, silently matching nothing.
3396
3756
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3397
3757
  const colType = this.pgTypeForColumn(meta, col);
3398
- if (colType === 'json' || colType === 'jsonb') {
3758
+ if (this.isJsonColumnType(colType)) {
3399
3759
  conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
3400
3760
  continue;
3401
3761
  }
@@ -3495,7 +3855,7 @@ class QueryInterface {
3495
3855
  assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
3496
3856
  if (!(0, filters_js_1.isUnmatchedPlainObject)(value))
3497
3857
  return;
3498
- if (columnPgType === 'json' || columnPgType === 'jsonb')
3858
+ if (this.isJsonColumnType(columnPgType))
3499
3859
  return;
3500
3860
  const badKeys = Object.keys(value);
3501
3861
  throw new errors_js_1.ValidationError(badKeys.length === 0
@@ -3566,7 +3926,7 @@ class QueryInterface {
3566
3926
  // bound as a plain equality value.
3567
3927
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3568
3928
  const colType = this.pgTypeForColumn(targetMeta, col);
3569
- if (colType === 'json' || colType === 'jsonb') {
3929
+ if (this.isJsonColumnType(colType)) {
3570
3930
  clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
3571
3931
  continue;
3572
3932
  }
@@ -3639,7 +3999,7 @@ class QueryInterface {
3639
3999
  // JSONB filter — mirrors buildAliasWhere.
3640
4000
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3641
4001
  const colType = this.pgTypeForColumn(targetMeta, col);
3642
- if (colType === 'json' || colType === 'jsonb') {
4002
+ if (this.isJsonColumnType(colType)) {
3643
4003
  this.collectJsonFilterParams(value, params, this.q(col));
3644
4004
  continue;
3645
4005
  }
@@ -3872,7 +4232,7 @@ class QueryInterface {
3872
4232
  * vs relation-column never collide on one cached SQL string. Captures the
3873
4233
  * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
3874
4234
  */
3875
- orderByEntryFingerprint(d) {
4235
+ orderByEntryFingerprint(d, targetTable) {
3876
4236
  // Vector KNN ordering changes the emitted operator by metric and adds a
3877
4237
  // `::vector` param, so metric + direction must be part of the cache key.
3878
4238
  if ((0, filters_js_1.isVectorOrderBy)(d)) {
@@ -3883,13 +4243,36 @@ class QueryInterface {
3883
4243
  if ((0, filters_js_1.isJsonPathOrderBy)(d)) {
3884
4244
  return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
3885
4245
  }
4246
+ // Pick-row relation ordering: the by-shape (column vs JSON path vs cast),
4247
+ // direction, nulls, pick.orderBy shape, and pick.where SHAPE are all SQL
4248
+ // text; the JSON paths and pick.where values are bound params and stay OUT
4249
+ // of the key. `targetTable` (the relation's target, resolved by the
4250
+ // caller) lets the pick.where fingerprint distinguish relation-filter
4251
+ // shapes inside it: two pick.wheres that differ only in shape must never
4252
+ // share one cached SQL string.
4253
+ if ((0, filters_js_1.isRelationPickOrderBy)(d)) {
4254
+ const by = typeof d.by === 'string'
4255
+ ? `col=${JSON.stringify(d.by)}`
4256
+ : `jp(${JSON.stringify(d.by?.field)},${d.by?.type === 'numeric' ? 'num' : 'text'})`;
4257
+ const pickOrder = Object.entries(d.pick?.orderBy ?? {})
4258
+ .map(([k, v]) => `${k}:${this.orderByEntryFingerprint(v)}`)
4259
+ .join(',');
4260
+ const pickWhere = d.pick?.where
4261
+ ? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
4262
+ : '';
4263
+ return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere})`;
4264
+ }
3886
4265
  if ((0, filters_js_1.isOrderBySpec)(d))
3887
4266
  return `spec(${d.sort},${d.nulls ?? ''})`;
3888
4267
  if (d && typeof d === 'object') {
3889
4268
  // Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
4269
+ // INSERTION order, never sorted: the compile side (buildRelationOrderBy)
4270
+ // emits one ORDER BY term per entry in Object.entries order, so entry
4271
+ // order is SQL-shaping precedence. A sorted fingerprint made
4272
+ // `{ name: 'asc', email: 'desc' }` and the swapped literal share one
4273
+ // cached SQL string — silently mis-ordered results on a warm cache.
3890
4274
  return `rel(${Object.entries(d)
3891
4275
  .map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
3892
- .sort()
3893
4276
  .join(',')})`;
3894
4277
  }
3895
4278
  return String(d);
@@ -4007,7 +4390,7 @@ class QueryInterface {
4007
4390
  `of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
4008
4391
  }
4009
4392
  const colType = this.pgTypeForColumn(meta, col);
4010
- if (colType !== 'json' && colType !== 'jsonb') {
4393
+ if (!this.isJsonColumnType(colType)) {
4011
4394
  throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
4012
4395
  `(actual type: ${colType}).`);
4013
4396
  }
@@ -4027,7 +4410,7 @@ class QueryInterface {
4027
4410
  if (!params) {
4028
4411
  throw new errors_js_1.ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
4029
4412
  }
4030
- params.push(spec.path.map(String));
4413
+ params.push(this.jsonPathParam(spec.path));
4031
4414
  const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
4032
4415
  const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4033
4416
  const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
@@ -4055,12 +4438,21 @@ class QueryInterface {
4055
4438
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
4056
4439
  `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
4057
4440
  }
4441
+ // Pick-row ordering (`{ pick, by }`): order by a value from ONE related
4442
+ // row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
4443
+ // Top-level findMany only (`ctx` present means we are inside a relation
4444
+ // subquery's orderBy) and hasMany only: validatePickOrderBy throws the
4445
+ // scope errors, shared with the cache-hit collect mirror.
4446
+ if ((0, filters_js_1.isRelationPickOrderBy)(value)) {
4447
+ this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
4448
+ return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
4449
+ }
4058
4450
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
4059
4451
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
4060
4452
  const keys = Object.keys(value);
4061
4453
  if (keys.length !== 1 || keys[0] !== '_count') {
4062
4454
  throw new errors_js_1.ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
4063
- `(got: ${keys.join(', ') || '(empty)'}).`);
4455
+ `or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
4064
4456
  }
4065
4457
  const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
4066
4458
  return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
@@ -4101,6 +4493,145 @@ class QueryInterface {
4101
4493
  })
4102
4494
  .join(', ');
4103
4495
  }
4496
+ /**
4497
+ * Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
4498
+ * the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
4499
+ * param-collect mirror ({@link collectRelationPickOrderParams}) so both
4500
+ * always throw identically:
4501
+ *
4502
+ * - `nested` (inside a relation subquery's orderBy or a pick.orderBy):
4503
+ * top-level findMany only in this release (E003),
4504
+ * - manyToMany: not supported (E003 naming the limitation),
4505
+ * - to-one: order by the target column directly instead (E003),
4506
+ * - `pick.orderBy` is REQUIRED (deterministic row choice),
4507
+ * - `by` must be a target column name or a `{ field, path }` JSON-path spec.
4508
+ */
4509
+ pickOrderNestedError(relName) {
4510
+ return new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level ` +
4511
+ 'findMany orderBy: nested `with` orderBy does not support it.');
4512
+ }
4513
+ validatePickOrderBy(relName, relDef, spec, nested) {
4514
+ if (nested) {
4515
+ throw this.pickOrderNestedError(relName);
4516
+ }
4517
+ if (relDef.type === 'manyToMany') {
4518
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering is not supported on manyToMany relation "${relName}": ` +
4519
+ 'hasMany relations only.');
4520
+ }
4521
+ if (relDef.type !== 'hasMany') {
4522
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering is only for to-many (hasMany) relations; "${relName}" is ${relDef.type}. ` +
4523
+ `Order by the target column directly instead ({ ${relName}: { <column>: 'asc' } }).`);
4524
+ }
4525
+ const pickOrder = spec.pick?.orderBy;
4526
+ if (typeof spec.pick !== 'object' ||
4527
+ spec.pick === null ||
4528
+ typeof pickOrder !== 'object' ||
4529
+ pickOrder === null ||
4530
+ Object.keys(pickOrder).length === 0) {
4531
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`pick.orderBy\` to choose ONE ` +
4532
+ "related row deterministically (e.g. pick: { orderBy: { createdAt: 'desc' } }).");
4533
+ }
4534
+ const by = spec.by;
4535
+ const validJsonBy = typeof by === 'object' && by !== null && typeof by.field === 'string' && Array.isArray(by.path);
4536
+ if (typeof by !== 'string' && !validJsonBy) {
4537
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
4538
+ "or a JSON-path spec ({ field: 'data', path: ['title'] }).");
4539
+ }
4540
+ }
4541
+ /**
4542
+ * Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
4543
+ * that picks ONE related row (`ORDER BY <pick.orderBy> LIMIT 1`, optionally
4544
+ * filtered by `pick.where` and the target's global filter) and surfaces one
4545
+ * value from it (a plain target column or a JSON-path extraction) as the
4546
+ * parent ORDER BY key:
4547
+ *
4548
+ * ```sql
4549
+ * (SELECT ord0."data" #>> $1::text[] FROM "versions" ord0
4550
+ * WHERE ord0."instance_id" = "instances"."id" AND ord0."is_current" = $2
4551
+ * ORDER BY ord0."created_at" DESC LIMIT 1) ASC NULLS LAST
4552
+ * ```
4553
+ *
4554
+ * Param-push order (mirrored EXACTLY by
4555
+ * {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
4556
+ * target global filter → `pick.where` → `pick.orderBy` JSON paths.
4557
+ */
4558
+ buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
4559
+ if (!params) {
4560
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
4561
+ }
4562
+ const targetMeta = this.schema.tables[relDef.to];
4563
+ if (!targetMeta)
4564
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4565
+ // The value surfaced from the picked row (SELECT list: its param binds first).
4566
+ let byExpr;
4567
+ if (typeof spec.by === 'string') {
4568
+ const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4569
+ byExpr = `${alias}.${this.q(col)}`;
4570
+ }
4571
+ else {
4572
+ const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
4573
+ path: spec.by.path,
4574
+ });
4575
+ params.push(this.jsonPathParam(spec.by.path));
4576
+ const extract = this.dialect.buildJsonPathExtract(`${alias}.${this.q(col)}`, this.p(params.length));
4577
+ byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4578
+ }
4579
+ // Correlation to the parent row, then the target's global filter (a
4580
+ // soft-deleted / other-tenant row must never be picked: matches the
4581
+ // `with` subquery and to-one relation-orderBy semantics), then pick.where.
4582
+ let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4583
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
4584
+ if (gf)
4585
+ where += ` AND ${gf}`;
4586
+ if (spec.pick.where) {
4587
+ const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, alias, spec.pick.where, params);
4588
+ if (pickWhere)
4589
+ where += ` AND ${pickWhere}`;
4590
+ }
4591
+ // pick.orderBy: same surface as a relation `with` orderBy on the target
4592
+ // (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
4593
+ // here routes back through buildRelationOrderBy with ctx set and throws
4594
+ // the top-level-only E003.
4595
+ const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, alias, Object.entries(spec.pick.orderBy), params);
4596
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4597
+ const limitOne = this.buildPagination('1', undefined, true);
4598
+ // Parents with ZERO surviving related rows make the correlated subquery
4599
+ // yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
4600
+ // FIRST — every childless parent would top a "highest first" sort. Default
4601
+ // to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
4602
+ // NULL-is-smallest default diverges from Postgres) unless the caller set
4603
+ // `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
4604
+ const nullsSql = spec.nulls
4605
+ ? this.nullsSuffix(spec.nulls)
4606
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4607
+ ? ' NULLS LAST'
4608
+ : '';
4609
+ return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
4610
+ }
4611
+ /**
4612
+ * Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
4613
+ * validation (a warmed cache can never skip it), then pushes in the same
4614
+ * order: `by` JSON path → target global filter → `pick.where` →
4615
+ * `pick.orderBy` JSON paths.
4616
+ */
4617
+ collectRelationPickOrderParams(relName, relDef, spec, params) {
4618
+ this.validatePickOrderBy(relName, relDef, spec, false);
4619
+ const targetMeta = this.schema.tables[relDef.to];
4620
+ if (!targetMeta)
4621
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4622
+ if (typeof spec.by === 'string') {
4623
+ this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4624
+ }
4625
+ else {
4626
+ this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, { path: spec.by.path });
4627
+ params.push(this.jsonPathParam(spec.by.path));
4628
+ }
4629
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
4630
+ if (spec.pick.where) {
4631
+ this.collectAliasWhereParams(relDef.to, targetMeta, spec.pick.where, params);
4632
+ }
4633
+ this.collectRelationOrderParams(relDef.to, targetMeta, Object.entries(spec.pick.orderBy), params);
4634
+ }
4104
4635
  /**
4105
4636
  * Compile the ORDER BY terms of a relation `with` clause against the
4106
4637
  * relation's table alias. One unified path for every relation shape
@@ -4151,10 +4682,15 @@ class QueryInterface {
4151
4682
  }
4152
4683
  if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
4153
4684
  this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
4154
- params.push(dirValue.path.map(String));
4685
+ params.push(this.jsonPathParam(dirValue.path));
4155
4686
  continue;
4156
4687
  }
4157
4688
  if (this.isRelationOrderByValue(dirValue)) {
4689
+ // Pick-row ordering is top-level-only: the build path throws the same
4690
+ // E003 (buildRelationOrderBy with ctx set), so the mirror must too.
4691
+ if ((0, filters_js_1.isRelationPickOrderBy)(dirValue)) {
4692
+ throw this.pickOrderNestedError(key);
4693
+ }
4158
4694
  const relDef = targetMeta.relations[key];
4159
4695
  if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
4160
4696
  this.collectRelationCountParams(relDef, params);
@@ -5113,6 +5649,16 @@ class QueryInterface {
5113
5649
  * Used to detect JSONB/array columns for specialized operators.
5114
5650
  * Uses pre-computed Map for O(1) lookup instead of linear scan.
5115
5651
  */
5652
+ /**
5653
+ * Case-insensitive json/jsonb column-type check. Postgres reports lowercase
5654
+ * udt_names, but SQLite/MySQL introspection surfaces the DECLARED type
5655
+ * (e.g. `JSON`), so every JSON-feature gate compares through this predicate
5656
+ * — build and collect sides alike, keeping the SQL-cache lockstep.
5657
+ */
5658
+ isJsonColumnType(colType) {
5659
+ const t = colType.toLowerCase();
5660
+ return t === 'json' || t === 'jsonb';
5661
+ }
5116
5662
  getColumnPgType(column) {
5117
5663
  return this.columnPgTypeMap.get(column) ?? 'text';
5118
5664
  }
@@ -5182,7 +5728,8 @@ class QueryInterface {
5182
5728
  let pathParamIdx = null;
5183
5729
  const pathExtract = () => {
5184
5730
  if (pathParamIdx === null) {
5185
- params.push(filter.path);
5731
+ // Only reached when a path-requiring clause validated filter.path.
5732
+ params.push(this.jsonPathParam(filter.path, filter.path));
5186
5733
  pathParamIdx = params.length;
5187
5734
  }
5188
5735
  return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
@@ -5218,6 +5765,24 @@ class QueryInterface {
5218
5765
  }
5219
5766
  return clauses;
5220
5767
  }
5768
+ /**
5769
+ * Bind value for a JSON path parameter, encoded per dialect. PostgreSQL's
5770
+ * `#>>` takes a `text[]` (the segments as strings — or `nativeForm` when the
5771
+ * caller has a specific native binding, e.g. JsonFilter's raw path array).
5772
+ * Every other engine's JSON function (`json_extract` / `JSON_EXTRACT` /
5773
+ * `JSON_VALUE`) takes a `'$'`-rooted JSONPath STRING: binding the raw array
5774
+ * would arrive as `'["a"]'` (the driver shims JSON.stringify non-primitive
5775
+ * params) and fail at runtime with the engine's bad-JSON-path error. The
5776
+ * encoded path stays a bound parameter — never spliced into SQL text — so
5777
+ * the build/collect param mirrors stay in lockstep and injection-safe.
5778
+ */
5779
+ jsonPathParam(path, nativeForm) {
5780
+ if (this.dialect.jsonPathSupport === 'native')
5781
+ return nativeForm ?? path.map(String);
5782
+ return `$${path
5783
+ .map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `."${String(seg).replace(/"/g, '\\"')}"`)
5784
+ .join('')}`;
5785
+ }
5221
5786
  /**
5222
5787
  * Cast an extracted JSON path text value to a numeric type for range
5223
5788
  * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to