turbine-orm 0.50.0 → 0.51.0

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.
Files changed (186) hide show
  1. package/README.md +66 -66
  2. package/dist/adapters/cockroachdb.d.ts +5 -5
  3. package/dist/adapters/cockroachdb.js +10 -10
  4. package/dist/adapters/index.d.ts +5 -5
  5. package/dist/adapters/index.js +7 -7
  6. package/dist/adapters/yugabytedb.d.ts +7 -7
  7. package/dist/adapters/yugabytedb.js +10 -10
  8. package/dist/cjs/adapters/cockroachdb.d.ts +5 -5
  9. package/dist/cjs/adapters/cockroachdb.js +10 -10
  10. package/dist/cjs/adapters/index.d.ts +5 -5
  11. package/dist/cjs/adapters/index.js +7 -7
  12. package/dist/cjs/adapters/yugabytedb.d.ts +7 -7
  13. package/dist/cjs/adapters/yugabytedb.js +10 -10
  14. package/dist/cjs/cli/config.d.ts +13 -2
  15. package/dist/cjs/cli/config.js +3 -2
  16. package/dist/cjs/cli/destructive.d.ts +1 -1
  17. package/dist/cjs/cli/destructive.js +1 -1
  18. package/dist/cjs/cli/index.d.ts +10 -10
  19. package/dist/cjs/cli/index.js +49 -45
  20. package/dist/cjs/cli/loader.d.ts +7 -7
  21. package/dist/cjs/cli/loader.js +9 -9
  22. package/dist/cjs/cli/mcp.js +4 -4
  23. package/dist/cjs/cli/migrate.d.ts +5 -5
  24. package/dist/cjs/cli/migrate.js +11 -11
  25. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  26. package/dist/cjs/cli/ui.d.ts +2 -2
  27. package/dist/cjs/cli/ui.js +2 -2
  28. package/dist/cjs/client.d.ts +49 -38
  29. package/dist/cjs/client.js +57 -56
  30. package/dist/cjs/dialect.d.ts +62 -18
  31. package/dist/cjs/dialect.js +40 -2
  32. package/dist/cjs/errors.d.ts +5 -5
  33. package/dist/cjs/errors.js +11 -11
  34. package/dist/cjs/generate.d.ts +6 -6
  35. package/dist/cjs/generate.js +31 -29
  36. package/dist/cjs/index-advisor.d.ts +5 -5
  37. package/dist/cjs/index-advisor.js +0 -0
  38. package/dist/cjs/index.d.ts +1 -1
  39. package/dist/cjs/index.js +7 -7
  40. package/dist/cjs/introspect.d.ts +35 -9
  41. package/dist/cjs/introspect.js +83 -32
  42. package/dist/cjs/mssql.d.ts +11 -11
  43. package/dist/cjs/mssql.js +64 -29
  44. package/dist/cjs/mysql.d.ts +8 -8
  45. package/dist/cjs/mysql.js +61 -23
  46. package/dist/cjs/nested-write.d.ts +21 -2
  47. package/dist/cjs/nested-write.js +51 -14
  48. package/dist/cjs/optional-peer-import.cjs +7 -7
  49. package/dist/cjs/optional-peer-import.d.cts +7 -7
  50. package/dist/cjs/pipeline-submittable.d.ts +2 -2
  51. package/dist/cjs/pipeline-submittable.js +6 -6
  52. package/dist/cjs/pipeline.d.ts +1 -1
  53. package/dist/cjs/pipeline.js +4 -4
  54. package/dist/cjs/powdb-introspect.d.ts +1 -1
  55. package/dist/cjs/powdb-introspect.js +1 -1
  56. package/dist/cjs/powdb.d.ts +28 -28
  57. package/dist/cjs/powdb.js +66 -66
  58. package/dist/cjs/powql.d.ts +27 -27
  59. package/dist/cjs/powql.js +73 -52
  60. package/dist/cjs/query/aggregates.d.ts +1 -1
  61. package/dist/cjs/query/aggregates.js +5 -5
  62. package/dist/cjs/query/batched-loader.d.ts +11 -11
  63. package/dist/cjs/query/batched-loader.js +24 -24
  64. package/dist/cjs/query/builder.d.ts +39 -21
  65. package/dist/cjs/query/builder.js +99 -57
  66. package/dist/cjs/query/compound-unique.d.ts +1 -1
  67. package/dist/cjs/query/compound-unique.js +0 -0
  68. package/dist/cjs/query/deferred.d.ts +12 -6
  69. package/dist/cjs/query/deferred.js +1 -1
  70. package/dist/cjs/query/filters.d.ts +31 -11
  71. package/dist/cjs/query/filters.js +67 -14
  72. package/dist/cjs/query/index.d.ts +1 -1
  73. package/dist/cjs/query/index.js +1 -1
  74. package/dist/cjs/query/relations.d.ts +9 -9
  75. package/dist/cjs/query/relations.js +164 -57
  76. package/dist/cjs/query/types.d.ts +86 -35
  77. package/dist/cjs/query/types.js +1 -1
  78. package/dist/cjs/query/utils.d.ts +27 -10
  79. package/dist/cjs/query/utils.js +86 -14
  80. package/dist/cjs/query/where.d.ts +47 -28
  81. package/dist/cjs/query/where.js +130 -31
  82. package/dist/cjs/query/writes.d.ts +24 -5
  83. package/dist/cjs/query/writes.js +102 -13
  84. package/dist/cjs/realtime.d.ts +7 -7
  85. package/dist/cjs/realtime.js +9 -9
  86. package/dist/cjs/schema-builder.d.ts +18 -7
  87. package/dist/cjs/schema-builder.js +17 -10
  88. package/dist/cjs/schema-metadata.d.ts +3 -3
  89. package/dist/cjs/schema-metadata.js +9 -9
  90. package/dist/cjs/schema-sql.d.ts +9 -9
  91. package/dist/cjs/schema-sql.js +20 -20
  92. package/dist/cjs/schema.d.ts +19 -9
  93. package/dist/cjs/schema.js +6 -6
  94. package/dist/cjs/serverless.d.ts +15 -15
  95. package/dist/cjs/serverless.js +16 -16
  96. package/dist/cjs/sqlite.d.ts +8 -8
  97. package/dist/cjs/sqlite.js +53 -22
  98. package/dist/cjs/typed-sql.d.ts +4 -4
  99. package/dist/cjs/typed-sql.js +5 -5
  100. package/dist/cli/config.d.ts +13 -2
  101. package/dist/cli/config.js +3 -2
  102. package/dist/cli/destructive.d.ts +1 -1
  103. package/dist/cli/destructive.js +1 -1
  104. package/dist/cli/index.d.ts +10 -10
  105. package/dist/cli/index.js +49 -45
  106. package/dist/cli/loader.d.ts +7 -7
  107. package/dist/cli/loader.js +9 -9
  108. package/dist/cli/mcp.js +4 -4
  109. package/dist/cli/migrate.d.ts +5 -5
  110. package/dist/cli/migrate.js +11 -11
  111. package/dist/cli/studio-ui.generated.js +1 -1
  112. package/dist/cli/ui.d.ts +2 -2
  113. package/dist/cli/ui.js +2 -2
  114. package/dist/client.d.ts +49 -38
  115. package/dist/client.js +57 -56
  116. package/dist/dialect.d.ts +62 -18
  117. package/dist/dialect.js +40 -2
  118. package/dist/errors.d.ts +5 -5
  119. package/dist/errors.js +11 -11
  120. package/dist/generate.d.ts +6 -6
  121. package/dist/generate.js +31 -29
  122. package/dist/index-advisor.d.ts +5 -5
  123. package/dist/index-advisor.js +0 -0
  124. package/dist/index.d.ts +1 -1
  125. package/dist/index.js +7 -7
  126. package/dist/introspect.d.ts +35 -9
  127. package/dist/introspect.js +82 -32
  128. package/dist/mssql.d.ts +11 -11
  129. package/dist/mssql.js +64 -29
  130. package/dist/mysql.d.ts +8 -8
  131. package/dist/mysql.js +61 -23
  132. package/dist/nested-write.d.ts +21 -2
  133. package/dist/nested-write.js +51 -14
  134. package/dist/optional-peer-import.cjs +7 -7
  135. package/dist/optional-peer-import.d.cts +7 -7
  136. package/dist/pipeline-submittable.d.ts +2 -2
  137. package/dist/pipeline-submittable.js +6 -6
  138. package/dist/pipeline.d.ts +1 -1
  139. package/dist/pipeline.js +4 -4
  140. package/dist/powdb-introspect.d.ts +1 -1
  141. package/dist/powdb-introspect.js +1 -1
  142. package/dist/powdb.d.ts +28 -28
  143. package/dist/powdb.js +66 -66
  144. package/dist/powql.d.ts +27 -27
  145. package/dist/powql.js +73 -52
  146. package/dist/query/aggregates.d.ts +1 -1
  147. package/dist/query/aggregates.js +5 -5
  148. package/dist/query/batched-loader.d.ts +11 -11
  149. package/dist/query/batched-loader.js +24 -24
  150. package/dist/query/builder.d.ts +39 -21
  151. package/dist/query/builder.js +100 -58
  152. package/dist/query/compound-unique.d.ts +1 -1
  153. package/dist/query/compound-unique.js +0 -0
  154. package/dist/query/deferred.d.ts +12 -6
  155. package/dist/query/deferred.js +1 -1
  156. package/dist/query/filters.d.ts +31 -11
  157. package/dist/query/filters.js +66 -13
  158. package/dist/query/index.d.ts +1 -1
  159. package/dist/query/index.js +1 -1
  160. package/dist/query/relations.d.ts +9 -9
  161. package/dist/query/relations.js +165 -58
  162. package/dist/query/types.d.ts +86 -35
  163. package/dist/query/types.js +1 -1
  164. package/dist/query/utils.d.ts +27 -10
  165. package/dist/query/utils.js +84 -14
  166. package/dist/query/where.d.ts +47 -28
  167. package/dist/query/where.js +129 -32
  168. package/dist/query/writes.d.ts +24 -5
  169. package/dist/query/writes.js +101 -13
  170. package/dist/realtime.d.ts +7 -7
  171. package/dist/realtime.js +9 -9
  172. package/dist/schema-builder.d.ts +18 -7
  173. package/dist/schema-builder.js +17 -10
  174. package/dist/schema-metadata.d.ts +3 -3
  175. package/dist/schema-metadata.js +9 -9
  176. package/dist/schema-sql.d.ts +9 -9
  177. package/dist/schema-sql.js +20 -20
  178. package/dist/schema.d.ts +19 -9
  179. package/dist/schema.js +6 -6
  180. package/dist/serverless.d.ts +15 -15
  181. package/dist/serverless.js +16 -16
  182. package/dist/sqlite.d.ts +8 -8
  183. package/dist/sqlite.js +53 -22
  184. package/dist/typed-sql.d.ts +4 -4
  185. package/dist/typed-sql.js +5 -5
  186. package/package.json +2 -2
@@ -17,7 +17,7 @@ import { missingIndexForRelation } from '../index-advisor.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import { resolveCountRelations } from './batched-loader.js';
19
19
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
20
- import { coerceJsonWireValue, jsonWireCoercionOid, ownLookup } from './utils.js';
20
+ import { ownLookup } from './utils.js';
21
21
  import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
22
22
  import * as whereMod from './where.js';
23
23
  import * as writesMod from './writes.js';
@@ -29,7 +29,7 @@ export function resolveColumns(qi, select, omit, includePii) {
29
29
  if (select) {
30
30
  // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
31
31
  // style) instead of the object shape. Object.entries() would iterate the
32
- // numeric indices and throw a cryptic `Unknown field "0"` — catch it early
32
+ // numeric indices and throw a cryptic `Unknown field "0"`, catch it early
33
33
  // with an actionable message.
34
34
  if (Array.isArray(select)) {
35
35
  throw new ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
@@ -79,7 +79,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
79
79
  const spec = withClause[relName];
80
80
  if (!spec)
81
81
  continue;
82
- // Reserved `_count` key — fingerprint by the selected relation set so
82
+ // Reserved `_count` key, fingerprint by the selected relation set so
83
83
  // `_count: true` and `_count: { posts: true }` never share a cache entry.
84
84
  if (relName === '_count') {
85
85
  const c = spec;
@@ -165,7 +165,7 @@ export function collectWithParams(qi, withClause, params, table, flattenPlan) {
165
165
  }
166
166
  collectRelationSubqueryParams(qi, relDef, relSpec, params, table ?? qi.table);
167
167
  }
168
- // `_count` global-filter params — mirror buildSelectWithRelations, which
168
+ // `_count` global-filter params, mirror buildSelectWithRelations, which
169
169
  // appends the count subqueries (and any target-filter params) AFTER every
170
170
  // relation subquery, in resolveCountRelations order.
171
171
  const countSpec = withClause._count;
@@ -235,14 +235,14 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
235
235
  if (nativeOrderPath && hasOrder) {
236
236
  collectRelationOrderParams(qi, targetTable, targetMeta, relOrderEntries, params);
237
237
  }
238
- // where params — mirrors buildAliasWhere push order
238
+ // where params, mirrors buildAliasWhere push order
239
239
  if (spec.where) {
240
240
  whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, spec.where, params);
241
241
  }
242
- // Global filter on the target — mirrors targetGlobalFilterAlias in
242
+ // Global filter on the target, mirrors targetGlobalFilterAlias in
243
243
  // buildRelationSubquery (pushed after spec.where, before limit).
244
244
  whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
245
- // limit param — only hasMany parameterizes its limit (mirrors
245
+ // limit param, only hasMany parameterizes its limit (mirrors
246
246
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
247
247
  // pushing one here would orphan a param and desync the collect path.
248
248
  // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
@@ -263,7 +263,7 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
263
263
  * Value-shape fingerprint for a single orderBy entry, so two queries whose
264
264
  * ORDER BY differs only in nulls placement, vector metric, or relation-count
265
265
  * vs relation-column never collide on one cached SQL string. Captures the
266
- * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
266
+ * SQL-shaping bits (direction, nulls, metric, relation keys), never values.
267
267
  */
268
268
  export function orderByEntryFingerprint(qi, d, targetTable) {
269
269
  // Vector KNN ordering changes the emitted operator by metric and adds a
@@ -309,7 +309,7 @@ export function orderByEntryFingerprint(qi, d, targetTable) {
309
309
  // emits one ORDER BY term per entry in Object.entries order, so entry
310
310
  // order is SQL-shaping precedence. A sorted fingerprint made
311
311
  // `{ name: 'asc', email: 'desc' }` and the swapped literal share one
312
- // cached SQL string — silently mis-ordered results on a warm cache.
312
+ // cached SQL string, silently mis-ordered results on a warm cache.
313
313
  return `rel(${Object.entries(d)
314
314
  .map(([k, v]) => `${k}=${orderByEntryFingerprint(qi, v)}`)
315
315
  .join(',')})`;
@@ -361,7 +361,7 @@ export function buildOrderBy(qi, orderBy, params, lateralSink) {
361
361
  if (isRelationOrderByValue(qi, value)) {
362
362
  return buildRelationOrderBy(qi, key, value, `ord${relOrdCounter++}`, params, undefined, lateralSink);
363
363
  }
364
- // Scalar column ordering — a plain direction or an OrderBySpec (nulls).
364
+ // Scalar column ordering, a plain direction or an OrderBySpec (nulls).
365
365
  if (meta && !(key in meta.columnMap)) {
366
366
  throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${qi.table}". ` +
367
367
  `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
@@ -386,7 +386,7 @@ export function isRelationOrderByValue(_qi, value) {
386
386
  }
387
387
  /**
388
388
  * Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
389
- * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
389
+ * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar, on any
390
390
  * other engine a caller asking for explicit nulls placement gets a clear
391
391
  * {@link UnsupportedFeatureError} (E017) instead of broken SQL.
392
392
  */
@@ -466,6 +466,100 @@ export function buildJsonPathOrderEntry(qi, table, meta, field, spec, prefix, pa
466
466
  : '';
467
467
  return `${lhs} ${dir}${nullsSql}`;
468
468
  }
469
+ /**
470
+ * Order by a column reached through TWO OR MORE to-one relation hops, e.g.
471
+ * `orderBy: { model: { category: { name: 'asc' } } }`.
472
+ *
473
+ * One hop already compiled to a correlated scalar subquery; each additional
474
+ * hop is added to that same subquery as an INNER JOIN, so the whole chain is
475
+ * one subquery with one `LIMIT 1` regardless of depth:
476
+ *
477
+ * (SELECT t1."name"
478
+ * FROM "models" t0
479
+ * JOIN "categories" t1 ON t1."id" = t0."category_id"
480
+ * WHERE t0."id" = "versions"."model_id"
481
+ * LIMIT 1) ASC
482
+ *
483
+ * Every hop must be to-one. A to-many hop has no single value to order by, so
484
+ * it is refused rather than silently picking an arbitrary row (`{ pick, by }`
485
+ * exists for that, deliberately, because it forces the caller to say WHICH
486
+ * row). Each hop's target global filter is applied to its join condition, so
487
+ * ordering never keys off a soft-deleted or other-tenant row.
488
+ */
489
+ function buildChainedToOneOrderBy(qi, head, nextRelName, nextValue, params) {
490
+ const joins = [];
491
+ let currentMeta = qi.schema.tables[head.relDef.to];
492
+ let currentAlias = head.alias;
493
+ let hop = 0;
494
+ let relName = nextRelName;
495
+ let value = nextValue;
496
+ const path = [head.relName];
497
+ // Walk the chain, emitting one JOIN per hop, until the value stops being a
498
+ // relation object. Bounded by the same depth cap as nested `with`.
499
+ for (;;) {
500
+ if (!currentMeta)
501
+ throw new RelationError(`[turbine] Unknown relation target in orderBy chain "${path.join('.')}"`);
502
+ const relDef = ownLookup(currentMeta.relations, relName);
503
+ if (!relDef) {
504
+ throw new ValidationError(`[turbine] Unknown relation "${relName}" in orderBy on relation "${path.join('.')}" ` +
505
+ `(table "${currentMeta.name}"). Available: ${Object.keys(currentMeta.relations).join(', ') || '(none)'}.`);
506
+ }
507
+ if (relDef.type !== 'belongsTo' && relDef.type !== 'hasOne') {
508
+ throw new ValidationError(`[turbine] orderBy cannot traverse the to-many relation "${relName}" on "${currentMeta.name}" ` +
509
+ `(path "${path.concat(relName).join('.')}"): a to-many relation has no single value to order by. ` +
510
+ `Use a pick-row ordering ({ pick, by }) at the top level, or order by "_count".`);
511
+ }
512
+ if (++hop > MAX_ORDER_BY_RELATION_HOPS) {
513
+ throw new CircularRelationError(path.concat(relName));
514
+ }
515
+ const nextMeta = qi.schema.tables[relDef.to];
516
+ if (!nextMeta)
517
+ throw new RelationError(`[turbine] Unknown relation target "${relDef.to}" in orderBy`);
518
+ const nextAlias = `${head.alias}c${hop}`;
519
+ const on = relDef.type === 'belongsTo'
520
+ ? qi.dialect.buildCorrelation(nextAlias, relDef.referenceKey, currentAlias, relDef.foreignKey)
521
+ : qi.dialect.buildCorrelation(nextAlias, relDef.foreignKey, currentAlias, relDef.referenceKey);
522
+ let onSql = on;
523
+ if (params) {
524
+ const gf = whereMod.targetGlobalFilterAlias(qi, relDef.to, nextAlias, params);
525
+ if (gf)
526
+ onSql += ` AND ${gf}`;
527
+ }
528
+ joins.push(`JOIN ${qi.q(relDef.to)} ${nextAlias} ON ${onSql}`);
529
+ path.push(relName);
530
+ currentMeta = nextMeta;
531
+ currentAlias = nextAlias;
532
+ // Exactly one key per level, as with the single-hop form.
533
+ const entries = Object.entries(value);
534
+ if (entries.length !== 1) {
535
+ throw new ValidationError(`[turbine] orderBy on relation "${path.join('.')}" needs exactly one key per level ` +
536
+ `(got: ${entries.map(([k]) => k).join(', ') || '(empty)'}).`);
537
+ }
538
+ const [key, entryValue] = entries[0];
539
+ if (ownLookup(currentMeta.relations, key) && isRelationOrderByValue(qi, entryValue)) {
540
+ relName = key;
541
+ value = entryValue;
542
+ continue;
543
+ }
544
+ // Terminal: a column on the last table in the chain.
545
+ const snakeCol = ownLookup(currentMeta.columnMap, key) ?? camelToSnake(key);
546
+ if (!currentMeta.allColumns.includes(snakeCol)) {
547
+ throw new ValidationError(`[turbine] Unknown column "${key}" in orderBy on relation "${path.join('.')}" (table "${currentMeta.name}").`);
548
+ }
549
+ const { dir, nulls } = normalizeOrderBy(entryValue);
550
+ let where = head.correlation;
551
+ if (params) {
552
+ const gf = whereMod.targetGlobalFilterAlias(qi, head.relDef.to, head.alias, params);
553
+ if (gf)
554
+ where += ` AND ${gf}`;
555
+ }
556
+ const from = `${qi.q(head.relDef.to)} ${head.alias} ${joins.join(' ')}`;
557
+ return (`(SELECT ${currentAlias}.${qi.q(snakeCol)} FROM ${from} WHERE ${where}${qi.limitOneClause()}) ` +
558
+ `${dir}${nullsSuffix(qi, nulls)}`);
559
+ }
560
+ }
561
+ /** Depth cap for a chained relation orderBy, mirroring the nested-`with` cap. */
562
+ const MAX_ORDER_BY_RELATION_HOPS = 10;
469
563
  /**
470
564
  * Compile a relation ordering term. For a to-many relation the only allowed
471
565
  * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
@@ -533,14 +627,26 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
533
627
  }
534
628
  return entries
535
629
  .map(([col, dirValue]) => {
630
+ // A nested relation: `orderBy: { model: { category: { name: 'asc' } } }`.
631
+ // Each further to-one hop becomes a JOIN inside the SAME correlated
632
+ // subquery rather than another level of nesting, so an N-hop chain still
633
+ // costs one subquery.
634
+ const nestedRel = ownLookup(targetMeta.relations, col);
635
+ if (nestedRel && isRelationOrderByValue(qi, dirValue)) {
636
+ return buildChainedToOneOrderBy(qi, { relName, relDef, alias, correlation }, col, dirValue, params);
637
+ }
536
638
  // columnMap-first resolution (camelToSnake fallback): mirrors the
537
639
  // scalar orderBy path so camelCase-named DB columns resolve here too.
538
640
  const snakeCol = ownLookup(targetMeta.columnMap, col) ?? camelToSnake(col);
539
641
  if (!targetMeta.allColumns.includes(snakeCol)) {
540
- throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
642
+ const relationHint = ownLookup(targetMeta.relations, col)
643
+ ? ` "${col}" is a relation on "${relDef.to}": order by one of ITS columns, e.g. ` +
644
+ `{ ${relName}: { ${col}: { <column>: 'asc' } } }.`
645
+ : '';
646
+ throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").${relationHint}`);
541
647
  }
542
648
  const { dir, nulls } = normalizeOrderBy(dirValue);
543
- // Target's global filter applies here too — otherwise ordering keys off
649
+ // Target's global filter applies here too, otherwise ordering keys off
544
650
  // a soft-deleted / other-tenant related row's value (matches the with
545
651
  // subquery semantics for belongsTo/hasOne).
546
652
  let where = correlation;
@@ -897,7 +1003,7 @@ export function collectManyToManyTargetGlobalFilter(qi, relDef, params) {
897
1003
  /**
898
1004
  * Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
899
1005
  * params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
900
- * when a filter applies — no-op otherwise.
1006
+ * when a filter applies, no-op otherwise.
901
1007
  */
902
1008
  export function collectRelationCountParams(qi, relDef, params) {
903
1009
  if (relDef.type === 'manyToMany') {
@@ -1051,7 +1157,7 @@ export function buildJsonRow(qi, jsonPairs) {
1051
1157
  // `json_build_object` renders a handful of Postgres types as something other
1052
1158
  // than the value the driver hands back for the same column, so the 'join'
1053
1159
  // strategy used to disagree with a top-level read, with 'batched', and with
1054
- // 'flatten' — losing precision outright on numeric and int8. The two halves of
1160
+ // 'flatten', losing precision outright on numeric and int8. The two halves of
1055
1161
  // the fix live here and must stay in lockstep:
1056
1162
  //
1057
1163
  // SQL side jsonScalarPairs() emits `alias."col"::text` for a divergent
@@ -1064,9 +1170,17 @@ export function buildJsonRow(qi, jsonPairs) {
1064
1170
  // decode (or vice versa). Postgres-only: other engines neither use
1065
1171
  // json_build_object nor share this divergence set.
1066
1172
  // ---------------------------------------------------------------------------
1067
- /** True when this query's engine gets the JSON-wire cast/decode treatment. */
1068
- function usesJsonWireCoercion(qi) {
1069
- return qi.dialect.name === 'postgresql';
1173
+ /**
1174
+ * The JSON-wire rule for one column, or undefined when its JSON rendering
1175
+ * already matches the driver. Routed entirely through the dialect, so each
1176
+ * engine states its own divergence set (see {@link Dialect.jsonWireRule}); a
1177
+ * dialect that omits the hook keeps the pre-0.51 no-cast behavior.
1178
+ */
1179
+ function jsonWireRuleFor(qi, meta, col) {
1180
+ const type = meta.pgTypes?.[col];
1181
+ if (!type)
1182
+ return undefined;
1183
+ return qi.dialect.jsonWireRule?.(type);
1070
1184
  }
1071
1185
  /**
1072
1186
  * The JSON value expression for one scalar relation column: the plain column
@@ -1075,9 +1189,7 @@ function usesJsonWireCoercion(qi) {
1075
1189
  */
1076
1190
  function jsonScalarExpr(qi, targetMeta, col, ref) {
1077
1191
  const expr = `${ref}.${qi.q(col)}`;
1078
- if (!usesJsonWireCoercion(qi))
1079
- return expr;
1080
- return jsonWireCoercionOid(targetMeta.pgTypes?.[col]) === undefined ? expr : `${expr}::text`;
1192
+ return jsonWireRuleFor(qi, targetMeta, col)?.sql(expr) ?? expr;
1081
1193
  }
1082
1194
  /**
1083
1195
  * The `[camelKey, valueExpr]` pairs for a relation's scalar columns. Single
@@ -1111,16 +1223,13 @@ function jsonWireFields(qi, table, meta) {
1111
1223
  if (cached !== undefined)
1112
1224
  return cached;
1113
1225
  let fields = null;
1114
- const pgTypes = meta.pgTypes;
1115
- if (pgTypes) {
1116
- for (const col of meta.allColumns) {
1117
- const oid = jsonWireCoercionOid(pgTypes[col]);
1118
- if (oid === undefined)
1119
- continue;
1120
- if (fields === null)
1121
- fields = new Map();
1122
- fields.set(meta.reverseColumnMap[col] ?? snakeToCamel(col), oid);
1123
- }
1226
+ for (const col of meta.allColumns) {
1227
+ const rule = jsonWireRuleFor(qi, meta, col);
1228
+ if (rule === undefined)
1229
+ continue;
1230
+ if (fields === null)
1231
+ fields = new Map();
1232
+ fields.set(meta.reverseColumnMap[col] ?? snakeToCamel(col), rule);
1124
1233
  }
1125
1234
  byTable.set(table, fields);
1126
1235
  return fields;
@@ -1131,8 +1240,6 @@ function jsonWireFields(qi, table, meta) {
1131
1240
  * so the ordinary relation pays nothing beyond the memo lookup.
1132
1241
  */
1133
1242
  function decodeJsonWireRow(qi, row, table, meta) {
1134
- if (!usesJsonWireCoercion(qi))
1135
- return row;
1136
1243
  const fields = jsonWireFields(qi, table, meta);
1137
1244
  if (fields === null)
1138
1245
  return row;
@@ -1141,13 +1248,13 @@ function decodeJsonWireRow(qi, row, table, meta) {
1141
1248
  // be holding. Measured against an in-place variant on a 20K-child-row join the
1142
1249
  // two were indistinguishable, so the copy is free insurance.
1143
1250
  let decoded;
1144
- for (const [field, oid] of fields) {
1251
+ for (const [field, rule] of fields) {
1145
1252
  const raw = row[field];
1146
1253
  if (typeof raw !== 'string')
1147
1254
  continue;
1148
1255
  if (decoded === undefined)
1149
1256
  decoded = { ...row };
1150
- decoded[field] = coerceJsonWireValue(oid, raw);
1257
+ decoded[field] = rule.decode(raw);
1151
1258
  }
1152
1259
  return decoded ?? row;
1153
1260
  }
@@ -1216,7 +1323,7 @@ export function makeNestedParser(qi, withClause, includePii, flattenPlan) {
1216
1323
  /**
1217
1324
  * Return a shallow copy of a top-level row with each relation column decoded
1218
1325
  * from its positional array(s) into the object representation. Only relation
1219
- * columns are positional — base scalar columns stay object-keyed — so the
1326
+ * columns are positional, base scalar columns stay object-keyed, so the
1220
1327
  * result is exactly what the object encoding would have handed parseNestedRow.
1221
1328
  */
1222
1329
  export function decodePositionalRelations(qi, row, shapes) {
@@ -1266,7 +1373,7 @@ export function decodePositionalObject(qi, arr, shape) {
1266
1373
  return obj;
1267
1374
  }
1268
1375
  // ---------------------------------------------------------------------------
1269
- // relationLoadStrategy: 'flatten' — to-one relations compiled as LEFT JOINs
1376
+ // relationLoadStrategy: 'flatten', to-one relations compiled as LEFT JOINs
1270
1377
  // ---------------------------------------------------------------------------
1271
1378
  /**
1272
1379
  * Alias prefix for a flattened relation's join. Deliberately distinct from the
@@ -1281,7 +1388,7 @@ const FLATTEN_ALIAS_PREFIX = 'f';
1281
1388
  * The top-level `WHERE` and `ORDER BY` reference the parent's columns
1282
1389
  * UNQUALIFIED (`WHERE "name" = $1`). A bare `LEFT JOIN "orgs" f0` puts a second
1283
1390
  * table in scope, so any column name the two tables share ("id", "name",
1284
- * "created_at" — i.e. most of them) turns those references ambiguous and
1391
+ * "created_at", i.e. most of them) turns those references ambiguous and
1285
1392
  * Postgres rejects the statement. Qualifying the parent's references is not an
1286
1393
  * option here: they are compiled by the shared WHERE walk, which is scope-blind
1287
1394
  * by design.
@@ -1384,13 +1491,13 @@ export function provableUniqueTargetKey(relDef, targetMeta) {
1384
1491
  *
1385
1492
  * A relation is ELIGIBLE when all of the following hold:
1386
1493
  * 1. it is `belongsTo` or `hasOne` AND its target-side correlation columns are
1387
- * provably unique ({@link provableUniqueTargetKey}) — the row-multiplication
1494
+ * provably unique ({@link provableUniqueTargetKey}), the row-multiplication
1388
1495
  * guard;
1389
1496
  * 2. its spec declares no `limit` and no `orderBy` (both are no-ops over a
1390
1497
  * single matching row, but refusing them keeps the emitted SQL and the
1391
1498
  * param stream trivially equivalent);
1392
1499
  * 3. its nested `with` names only real relations and no reserved `_count`
1393
- * (nested `_count` is unsupported on every strategy — falling back lets the
1500
+ * (nested `_count` is unsupported on every strategy, falling back lets the
1394
1501
  * subquery path raise the same error);
1395
1502
  * 4. the depth cap is not reached, so a too-deep chain still raises
1396
1503
  * {@link CircularRelationError} from the subquery path instead of silently
@@ -1525,7 +1632,7 @@ function warnFlattenFallback(table, rejects) {
1525
1632
  * it emits today, down to the cache key).
1526
1633
  *
1527
1634
  * The plan is a pure function of the schema, the `with` clause shape and
1528
- * `includePii` — never of any bound value — so the build path, the cache-hit
1635
+ * `includePii`, never of any bound value, so the build path, the cache-hit
1529
1636
  * param-collect path and the row assembler can each recompute it and agree.
1530
1637
  */
1531
1638
  export function planFlattenWith(qi, table, withClause, includePii) {
@@ -1828,8 +1935,8 @@ export function buildSelectWithRelations(qi, table, withClause, params, columnsL
1828
1935
  const meta = qi.schema.tables[table];
1829
1936
  if (!meta)
1830
1937
  throw new ValidationError(`[turbine] Unknown table "${table}"`);
1831
- // Positional JSON encoding is Postgres-only in v1. Gate here — the single
1832
- // entry point for every `with` clause — so no engine ever emits the
1938
+ // Positional JSON encoding is Postgres-only in v1. Gate here, the single
1939
+ // entry point for every `with` clause, so no engine ever emits the
1833
1940
  // json_build_array shape its dialect can't produce (and mssql's FOR JSON
1834
1941
  // override path is never reached with positional active).
1835
1942
  if (qi.jsonEncoding === 'positional' && qi.dialect.name !== 'postgresql') {
@@ -1971,7 +2078,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
1971
2078
  const currentDepth = depth ?? 0;
1972
2079
  const currentPath = path ?? [qi.table];
1973
2080
  const targetTable = relDef.to;
1974
- // Hard depth cap — the `with` clause is a finite JSON structure so users can't
2081
+ // Hard depth cap, the `with` clause is a finite JSON structure so users can't
1975
2082
  // create true infinite recursion, but extremely deep nesting (10+ levels) produces
1976
2083
  // unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
1977
2084
  // since they are legitimate queries (Prisma supports the same pattern).
@@ -1996,7 +2103,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
1996
2103
  const miss = missingIndexForRelation(qi.schema, relDef);
1997
2104
  if (miss && shouldWarnOnce(WARN_NS.unindexedRelation, warnKey)) {
1998
2105
  console.warn(`[turbine] Relation "${relDef.name}" on "${relDef.from}" probes ` +
1999
- `"${miss.table}"(${miss.columns.join(', ')}) which has no covering index — ` +
2106
+ `"${miss.table}"(${miss.columns.join(', ')}) which has no covering index, ` +
2000
2107
  `each parent row scans the full table. Fix: ${miss.createSql}; ` +
2001
2108
  'or run `npx turbine doctor` for a full report.');
2002
2109
  }
@@ -2040,7 +2147,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2040
2147
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
2041
2148
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
2042
2149
  // so we must NOT build them here (they would push orphaned params).
2043
- // An orderBy with no defined entries (`orderBy: {}`) is treated as absent —
2150
+ // An orderBy with no defined entries (`orderBy: {}`) is treated as absent -
2044
2151
  // it must neither trigger the wrap (dropping nested relations) nor render a
2045
2152
  // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
2046
2153
  const relOrderEntries = spec !== true && spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
@@ -2051,7 +2158,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2051
2158
  if (relDef.type === 'manyToMany') {
2052
2159
  return buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns, includePii);
2053
2160
  }
2054
- // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
2161
+ // Nested relations, only in the non-wrapped path (wrapped path builds them separately)
2055
2162
  if (!willWrap && spec !== true && spec.with) {
2056
2163
  for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2057
2164
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
@@ -2067,7 +2174,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2067
2174
  }
2068
2175
  }
2069
2176
  const jsonObj = buildJsonRow(qi, jsonPairs);
2070
- // Quote parent ref — can be a table name or auto-generated alias
2177
+ // Quote parent ref, can be a table name or auto-generated alias
2071
2178
  const qParent = qi.q(parentRef);
2072
2179
  const qTarget = qi.q(targetTable);
2073
2180
  // Build ORDER BY for json_agg: unified with the top-level orderBy surface
@@ -2078,10 +2185,10 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2078
2185
  if (relOrderEntries.length > 0) {
2079
2186
  orderClause = buildRelationOrderClause(qi, targetTable, targetMeta, alias, relOrderEntries, params);
2080
2187
  }
2081
- // Build WHERE — correlate to parent via parentRef (alias or table name).
2188
+ // Build WHERE, correlate to parent via parentRef (alias or table name).
2082
2189
  // For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
2083
2190
  // the child-side column), so alias.fk = parentRef.pk. hasOne is just
2084
- // hasMany with a unique FK — treating it like belongsTo here silently
2191
+ // hasMany with a unique FK, treating it like belongsTo here silently
2085
2192
  // correlated the wrong columns (caught dogfooding: uuid = varchar).
2086
2193
  // For belongsTo: SOURCE has the FK, so alias.pk = parentRef.fk (reversed).
2087
2194
  // Supports composite foreign keys (string[]) via buildCorrelation.
@@ -2092,20 +2199,20 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2092
2199
  else {
2093
2200
  whereClause = qi.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
2094
2201
  }
2095
- // Additional filters — full scalar where surface (equality, null, operator
2202
+ // Additional filters, full scalar where surface (equality, null, operator
2096
2203
  // objects, OR/AND/NOT), properly parameterized against this alias.
2097
2204
  if (spec !== true && spec.where) {
2098
2205
  const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, alias, spec.where, params);
2099
2206
  if (extra)
2100
2207
  whereClause += ` AND ${extra}`;
2101
2208
  }
2102
- // Global filter on the target table (soft-delete / tenancy) — AND-merged so
2209
+ // Global filter on the target table (soft-delete / tenancy), AND-merged so
2103
2210
  // a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
2104
2211
  // mirrored by collectRelationSubqueryParams.
2105
2212
  const gfExtra = whereMod.targetGlobalFilterAlias(qi, targetTable, alias, params);
2106
2213
  if (gfExtra)
2107
2214
  whereClause += ` AND ${gfExtra}`;
2108
- // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
2215
+ // LIMIT, only meaningful for hasMany. A belongsTo / hasOne subquery returns
2109
2216
  // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
2110
2217
  // a parameter: doing so orphans an untyped `$N` that the SQL never references,
2111
2218
  // which Postgres rejects with "could not determine data type of parameter $N"
@@ -2123,7 +2230,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2123
2230
  // Rewrite: SELECT json_agg(json_build_object(...)) FROM (SELECT * FROM table WHERE ... ORDER BY ... LIMIT N) AS alias
2124
2231
  // Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
2125
2232
  const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${qi.q(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
2126
- // For the json_build_object, reference the inner alias — only include resolved columns
2233
+ // For the json_build_object, reference the inner alias, only include resolved columns
2127
2234
  const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
2128
2235
  // Build nested relation subqueries referencing innerAlias
2129
2236
  if (spec !== true && spec.with) {
@@ -2143,7 +2250,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2143
2250
  }
2144
2251
  // Inline ORDER BY only when the dialect's array-agg supports it (PG). For
2145
2252
  // hasMany this path is reached only when there is no orderClause, so the
2146
- // argument is `undefined` either way — keeping PG output byte-identical.
2253
+ // argument is `undefined` either way, keeping PG output byte-identical.
2147
2254
  const inlineOrder = qi.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
2148
2255
  return `SELECT ${qi.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
2149
2256
  }
@@ -2184,7 +2291,7 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
2184
2291
  // JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
2185
2292
  const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
2186
2293
  // The target PK is the column(s) the junction's targetKey references. An empty
2187
- // introspected PK means we cannot know what to JOIN on — fail loudly rather than
2294
+ // introspected PK means we cannot know what to JOIN on, fail loudly rather than
2188
2295
  // silently guessing `id` and generating a wrong JOIN.
2189
2296
  if (targetMeta.primaryKey.length === 0) {
2190
2297
  throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
@@ -2216,7 +2323,7 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
2216
2323
  if (relOrderEntries.length > 0) {
2217
2324
  orderClause = buildRelationOrderClause(qi, targetTable, targetMeta, talias, relOrderEntries, params);
2218
2325
  }
2219
- // Additional WHERE filters on the target — full scalar where surface,
2326
+ // Additional WHERE filters on the target, full scalar where surface,
2220
2327
  // properly parameterized against the target alias.
2221
2328
  if (spec !== true && spec.where) {
2222
2329
  const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, talias, spec.where, params);
@@ -2228,7 +2335,7 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
2228
2335
  const gfExtra = whereMod.targetGlobalFilterAlias(qi, targetTable, talias, params);
2229
2336
  if (gfExtra)
2230
2337
  whereClause += ` AND ${gfExtra}`;
2231
- // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
2338
+ // LIMIT, `limit: 0` is honored (LIMIT 0 → empty array)
2232
2339
  let limitClause = '';
2233
2340
  if (spec !== true && spec.limit !== undefined) {
2234
2341
  limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;