turbine-orm 0.49.0 → 0.50.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 (157) hide show
  1. package/README.md +122 -39
  2. package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
  3. package/dist/cjs/adapters/index.d.ts +110 -0
  4. package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
  5. package/dist/cjs/cli/config.d.ts +181 -0
  6. package/dist/cjs/cli/config.js +32 -6
  7. package/dist/cjs/cli/destructive.d.ts +38 -0
  8. package/dist/cjs/cli/index.d.ts +359 -0
  9. package/dist/cjs/cli/index.js +228 -56
  10. package/dist/cjs/cli/loader.d.ts +61 -0
  11. package/dist/cjs/cli/mcp.d.ts +42 -0
  12. package/dist/cjs/cli/migrate.d.ts +356 -0
  13. package/dist/cjs/cli/migrate.js +131 -40
  14. package/dist/cjs/cli/observe-ui.d.ts +1 -0
  15. package/dist/cjs/cli/observe-ui.js +14 -5
  16. package/dist/cjs/cli/observe.d.ts +25 -0
  17. package/dist/cjs/cli/observe.js +49 -12
  18. package/dist/cjs/cli/pii-tags.d.ts +53 -0
  19. package/dist/cjs/cli/prisma-report.d.ts +33 -0
  20. package/dist/cjs/cli/prisma-report.js +73 -0
  21. package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
  22. package/dist/cjs/cli/prisma-resolve.js +1 -0
  23. package/dist/cjs/cli/prisma-schema.d.ts +176 -0
  24. package/dist/cjs/cli/prisma-schema.js +82 -4
  25. package/dist/cjs/cli/rate-limit.d.ts +32 -0
  26. package/dist/cjs/cli/rate-limit.js +45 -0
  27. package/dist/cjs/cli/studio-demo.d.ts +43 -0
  28. package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
  29. package/dist/cjs/cli/studio.d.ts +207 -0
  30. package/dist/cjs/cli/studio.js +136 -71
  31. package/dist/cjs/cli/ui.d.ts +73 -0
  32. package/dist/cjs/cli/ui.js +51 -9
  33. package/dist/cjs/client.d.ts +837 -0
  34. package/dist/cjs/client.js +3 -0
  35. package/dist/cjs/dialect.d.ts +516 -0
  36. package/dist/cjs/dialect.js +37 -12
  37. package/dist/cjs/errors.d.ts +370 -0
  38. package/dist/cjs/generate.d.ts +137 -0
  39. package/dist/cjs/generate.js +39 -6
  40. package/dist/cjs/index-advisor.d.ts +153 -0
  41. package/dist/cjs/index-stats.d.ts +384 -0
  42. package/dist/cjs/index.d.ts +55 -0
  43. package/dist/cjs/index.js +7 -2
  44. package/dist/cjs/introspect.d.ts +269 -0
  45. package/dist/cjs/mssql.d.ts +232 -0
  46. package/dist/cjs/mssql.js +6 -0
  47. package/dist/cjs/mysql.d.ts +173 -0
  48. package/dist/cjs/mysql.js +16 -0
  49. package/dist/cjs/nested-write.d.ts +96 -0
  50. package/dist/cjs/nested-write.js +414 -24
  51. package/dist/cjs/observe.d.ts +115 -0
  52. package/dist/cjs/optional-peer-import.d.cts +72 -0
  53. package/dist/cjs/pipeline-submittable.d.ts +93 -0
  54. package/dist/cjs/pipeline.d.ts +71 -0
  55. package/dist/cjs/powdb-introspect.d.ts +84 -0
  56. package/dist/cjs/powdb.d.ts +931 -0
  57. package/dist/cjs/powdb.js +106 -21
  58. package/dist/cjs/powql.d.ts +592 -0
  59. package/dist/cjs/powql.js +42 -6
  60. package/dist/cjs/prisma-compat.d.ts +283 -0
  61. package/dist/cjs/prisma-compat.js +167 -9
  62. package/dist/cjs/query/aggregates.d.ts +92 -0
  63. package/dist/cjs/query/aggregates.js +7 -3
  64. package/dist/cjs/query/batched-loader.d.ts +193 -0
  65. package/dist/cjs/query/builder.d.ts +849 -0
  66. package/dist/cjs/query/builder.js +571 -65
  67. package/dist/cjs/query/compound-unique.d.ts +51 -0
  68. package/dist/cjs/query/deferred.d.ts +223 -0
  69. package/dist/cjs/query/filters.d.ts +201 -0
  70. package/dist/cjs/query/index.d.ts +14 -0
  71. package/dist/cjs/query/index.js +6 -1
  72. package/dist/cjs/query/relations.d.ts +609 -0
  73. package/dist/cjs/query/relations.js +693 -46
  74. package/dist/cjs/query/types.d.ts +1300 -0
  75. package/dist/cjs/query/utils.d.ts +209 -0
  76. package/dist/cjs/query/utils.js +208 -1
  77. package/dist/cjs/query/warn-registry.d.ts +68 -0
  78. package/dist/cjs/query/warn-registry.js +9 -0
  79. package/dist/cjs/query/where-compile.d.ts +139 -0
  80. package/dist/cjs/query/where.d.ts +548 -0
  81. package/dist/cjs/query/where.js +58 -22
  82. package/dist/cjs/query/writes.d.ts +172 -0
  83. package/dist/cjs/query/writes.js +105 -12
  84. package/dist/cjs/realtime.d.ts +70 -0
  85. package/dist/cjs/schema-builder.d.ts +354 -0
  86. package/dist/cjs/schema-metadata.d.ts +83 -0
  87. package/dist/cjs/schema-sql.d.ts +217 -0
  88. package/dist/cjs/schema-sql.js +23 -5
  89. package/dist/cjs/schema.d.ts +356 -0
  90. package/dist/cjs/schema.js +125 -0
  91. package/dist/cjs/seed.d.ts +15 -0
  92. package/dist/cjs/serverless.d.ts +142 -0
  93. package/dist/cjs/sqlite.d.ts +143 -0
  94. package/dist/cjs/sqlite.js +4 -0
  95. package/dist/cjs/typed-sql.d.ts +102 -0
  96. package/dist/cli/config.d.ts +18 -4
  97. package/dist/cli/config.js +31 -6
  98. package/dist/cli/index.d.ts +123 -0
  99. package/dist/cli/index.js +223 -58
  100. package/dist/cli/migrate.d.ts +59 -10
  101. package/dist/cli/migrate.js +128 -41
  102. package/dist/cli/observe-ui.d.ts +1 -1
  103. package/dist/cli/observe-ui.js +14 -5
  104. package/dist/cli/observe.d.ts +7 -1
  105. package/dist/cli/observe.js +48 -12
  106. package/dist/cli/prisma-report.d.ts +14 -0
  107. package/dist/cli/prisma-report.js +72 -0
  108. package/dist/cli/prisma-resolve.d.ts +6 -0
  109. package/dist/cli/prisma-resolve.js +1 -0
  110. package/dist/cli/prisma-schema.d.ts +62 -2
  111. package/dist/cli/prisma-schema.js +81 -4
  112. package/dist/cli/rate-limit.d.ts +32 -0
  113. package/dist/cli/rate-limit.js +40 -0
  114. package/dist/cli/studio.d.ts +5 -5
  115. package/dist/cli/studio.js +135 -70
  116. package/dist/cli/ui.d.ts +1 -1
  117. package/dist/cli/ui.js +51 -9
  118. package/dist/client.d.ts +40 -0
  119. package/dist/client.js +3 -0
  120. package/dist/dialect.d.ts +17 -1
  121. package/dist/dialect.js +37 -12
  122. package/dist/generate.js +40 -7
  123. package/dist/index.d.ts +1 -1
  124. package/dist/index.js +1 -1
  125. package/dist/mssql.js +6 -0
  126. package/dist/mysql.js +16 -0
  127. package/dist/nested-write.d.ts +2 -0
  128. package/dist/nested-write.js +415 -25
  129. package/dist/powdb.d.ts +4 -2
  130. package/dist/powdb.js +106 -21
  131. package/dist/powql.d.ts +5 -0
  132. package/dist/powql.js +42 -6
  133. package/dist/prisma-compat.d.ts +2 -0
  134. package/dist/prisma-compat.js +166 -8
  135. package/dist/query/aggregates.js +7 -3
  136. package/dist/query/builder.d.ts +292 -21
  137. package/dist/query/builder.js +570 -64
  138. package/dist/query/deferred.d.ts +39 -0
  139. package/dist/query/index.d.ts +1 -1
  140. package/dist/query/index.js +1 -1
  141. package/dist/query/relations.d.ts +173 -5
  142. package/dist/query/relations.js +688 -47
  143. package/dist/query/types.d.ts +123 -39
  144. package/dist/query/utils.d.ts +116 -0
  145. package/dist/query/utils.js +198 -0
  146. package/dist/query/warn-registry.d.ts +9 -0
  147. package/dist/query/warn-registry.js +9 -0
  148. package/dist/query/where.d.ts +38 -1
  149. package/dist/query/where.js +58 -23
  150. package/dist/query/writes.d.ts +42 -1
  151. package/dist/query/writes.js +104 -13
  152. package/dist/schema-sql.d.ts +14 -0
  153. package/dist/schema-sql.js +23 -5
  154. package/dist/schema.d.ts +38 -0
  155. package/dist/schema.js +123 -0
  156. package/dist/sqlite.js +4 -0
  157. package/package.json +77 -28
@@ -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 { ownLookup } from './utils.js';
20
+ import { coerceJsonWireValue, jsonWireCoercionOid, 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';
@@ -92,7 +92,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
92
92
  .join(',')})`);
93
93
  continue;
94
94
  }
95
- const relDef = meta.relations[relName];
95
+ const relDef = ownLookup(meta.relations, relName);
96
96
  if (!relDef) {
97
97
  parts.push(`unknown:${relName}`);
98
98
  continue;
@@ -122,7 +122,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
122
122
  // `{title: {contains: 'x'}}` emit different SQL so they must not share
123
123
  // a fingerprint)
124
124
  if (opts.where) {
125
- subParts.push(`w=${whereMod.fingerprintAliasWhere(qi, opts.where, meta.relations[relName]?.to)}`);
125
+ subParts.push(`w=${whereMod.fingerprintAliasWhere(qi, opts.where, relDef.to)}`);
126
126
  }
127
127
  // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
128
128
  if (opts.orderBy) {
@@ -150,14 +150,19 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
150
150
  * Collect params from a `with` clause tree. Mirrors buildSelectWithRelations +
151
151
  * buildRelationSubquery param-push order.
152
152
  */
153
- export function collectWithParams(qi, withClause, params, table) {
153
+ export function collectWithParams(qi, withClause, params, table, flattenPlan) {
154
154
  const meta = qi.schema.tables[table ?? qi.table];
155
155
  if (!meta)
156
156
  return;
157
157
  for (const [relName, relSpec] of sortedEntries(withClause)) {
158
- const relDef = meta.relations[relName];
158
+ const relDef = ownLookup(meta.relations, relName);
159
159
  if (!relDef)
160
160
  continue;
161
+ const flatNode = flattenPlan?.nodes[relName];
162
+ if (flatNode) {
163
+ collectFlattenNodeParams(qi, flatNode, params);
164
+ continue;
165
+ }
161
166
  collectRelationSubqueryParams(qi, relDef, relSpec, params, table ?? qi.table);
162
167
  }
163
168
  // `_count` global-filter params — mirror buildSelectWithRelations, which
@@ -199,11 +204,11 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
199
204
  }
200
205
  whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
201
206
  if (spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
202
- params.push(Number(spec.limit));
207
+ params.push(qi.paginationValue(spec.limit, 'relation limit'));
203
208
  }
204
209
  if (spec.with) {
205
210
  for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
206
- const nestedRelDef = targetMeta.relations[nestedRelName];
211
+ const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
207
212
  if (!nestedRelDef)
208
213
  continue;
209
214
  collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
@@ -218,7 +223,7 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
218
223
  // Non-wrapped path: nested relations BEFORE where/limit
219
224
  if (!willWrap && spec.with) {
220
225
  for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
221
- const nestedRelDef = targetMeta.relations[nestedRelName];
226
+ const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
222
227
  if (!nestedRelDef)
223
228
  continue;
224
229
  collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
@@ -242,12 +247,12 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
242
247
  // pushing one here would orphan a param and desync the collect path.
243
248
  // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
244
249
  if (relDef.type === 'hasMany' && spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
245
- params.push(Number(spec.limit));
250
+ params.push(qi.paginationValue(spec.limit, 'relation limit'));
246
251
  }
247
252
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
248
253
  if (willWrap && spec.with) {
249
254
  for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
250
- const nestedRelDef = targetMeta.relations[nestedRelName];
255
+ const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
251
256
  if (!nestedRelDef)
252
257
  continue;
253
258
  collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'innerAlias', depth + 1);
@@ -478,7 +483,7 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
478
483
  const ownerMeta = ctx?.meta ?? qi.tableMeta;
479
484
  const ownerTable = ctx?.table ?? qi.table;
480
485
  const parentRef = ctx?.parentRef ?? qi.table;
481
- const relDef = ownerMeta.relations[relName];
486
+ const relDef = ownLookup(ownerMeta.relations, relName);
482
487
  if (!relDef) {
483
488
  // A table with no relations at all would otherwise render a dangling
484
489
  // "Available: " and read as a broken message; and the most likely cause of
@@ -798,7 +803,7 @@ export function collectRelationOrderParams(qi, targetTable, targetMeta, orderEnt
798
803
  if (isRelationPickOrderBy(dirValue)) {
799
804
  throw pickOrderNestedError(qi, key);
800
805
  }
801
- const relDef = targetMeta.relations[key];
806
+ const relDef = ownLookup(targetMeta.relations, key);
802
807
  if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
803
808
  collectRelationCountParams(qi, relDef, params);
804
809
  }
@@ -913,12 +918,23 @@ export function getCamelDateFields(qi, table, meta) {
913
918
  }
914
919
  return camel;
915
920
  }
916
- /** Parse a row that may contain JSON nested relation columns */
917
- export function parseNestedRow(qi, row, table) {
918
- const parsed = qi.parseRow(row, table);
921
+ /**
922
+ * Parse a row that may contain JSON nested relation columns.
923
+ *
924
+ * `fromJson` says where THIS row's own scalar columns came from. A root row
925
+ * (join, batched, flatten) is read straight off the driver, so `false`; a row
926
+ * decoded out of a `json_agg`/`json_build_object` column is `true` and gets
927
+ * its divergent scalars decoded back to the driver's representation first (see
928
+ * the JSON-wire section above). Recursion into a relation column is always
929
+ * `true`, which is exactly right on every strategy: `batched` and `flatten`
930
+ * hand this function driver rows, but any relation still nested INSIDE one of
931
+ * those rows arrived as a correlated JSON subquery.
932
+ */
933
+ export function parseNestedRow(qi, row, table, fromJson = false) {
919
934
  const meta = qi.schema.tables[table];
920
935
  if (!meta)
921
- return parsed;
936
+ return qi.parseRow(row, table);
937
+ const parsed = qi.parseRow(fromJson ? decodeJsonWireRow(qi, row, table, meta) : row, table);
922
938
  // Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
923
939
  // parseRow copies these unknown columns through under their raw key.
924
940
  let countObj;
@@ -958,11 +974,11 @@ export function parseNestedRow(qi, row, table) {
958
974
  // coercion AND its own sub-relations parsed at arbitrary depth.
959
975
  if (Array.isArray(jsonVal)) {
960
976
  parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
961
- ? parseNestedRow(qi, item, relDef.to)
977
+ ? parseNestedRow(qi, item, relDef.to, true)
962
978
  : item);
963
979
  }
964
980
  else if (typeof jsonVal === 'object' && jsonVal !== null) {
965
- parsed[relName] = parseNestedRow(qi, jsonVal, relDef.to);
981
+ parsed[relName] = parseNestedRow(qi, jsonVal, relDef.to, true);
966
982
  }
967
983
  else {
968
984
  parsed[relName] = jsonVal;
@@ -975,11 +991,11 @@ export function parseNestedRow(qi, row, table) {
975
991
  }
976
992
  else if (Array.isArray(rawValue)) {
977
993
  parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
978
- ? parseNestedRow(qi, item, relDef.to)
994
+ ? parseNestedRow(qi, item, relDef.to, true)
979
995
  : item);
980
996
  }
981
997
  else if (typeof rawValue === 'object' && rawValue !== null) {
982
- parsed[relName] = parseNestedRow(qi, rawValue, relDef.to);
998
+ parsed[relName] = parseNestedRow(qi, rawValue, relDef.to, true);
983
999
  }
984
1000
  else {
985
1001
  parsed[relName] = rawValue;
@@ -1029,6 +1045,112 @@ export function buildJsonRow(qi, jsonPairs) {
1029
1045
  }
1030
1046
  return qi.dialect.buildJsonObject(jsonPairs);
1031
1047
  }
1048
+ // ---------------------------------------------------------------------------
1049
+ // JSON-wire value fidelity (see JSON_WIRE_COERCION_OIDS in utils.ts)
1050
+ //
1051
+ // `json_build_object` renders a handful of Postgres types as something other
1052
+ // than the value the driver hands back for the same column, so the 'join'
1053
+ // 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
1055
+ // the fix live here and must stay in lockstep:
1056
+ //
1057
+ // SQL side jsonScalarPairs() emits `alias."col"::text` for a divergent
1058
+ // column, so the JSON carries the driver's wire text verbatim.
1059
+ // parse side parseNestedRow(..., fromJson = true) runs that text back
1060
+ // through the driver's own parser for the column's OID.
1061
+ //
1062
+ // Both derive the column set from the SAME jsonWireCoercionOid() call over the
1063
+ // target table's `pgTypes`, so a cast can never be emitted without a matching
1064
+ // decode (or vice versa). Postgres-only: other engines neither use
1065
+ // json_build_object nor share this divergence set.
1066
+ // ---------------------------------------------------------------------------
1067
+ /** True when this query's engine gets the JSON-wire cast/decode treatment. */
1068
+ function usesJsonWireCoercion(qi) {
1069
+ return qi.dialect.name === 'postgresql';
1070
+ }
1071
+ /**
1072
+ * The JSON value expression for one scalar relation column: the plain column
1073
+ * reference, or a `::text` cast when the type's JSON rendering would not match
1074
+ * what the driver produces.
1075
+ */
1076
+ function jsonScalarExpr(qi, targetMeta, col, ref) {
1077
+ const expr = `${ref}.${qi.q(col)}`;
1078
+ if (!usesJsonWireCoercion(qi))
1079
+ return expr;
1080
+ return jsonWireCoercionOid(targetMeta.pgTypes?.[col]) === undefined ? expr : `${expr}::text`;
1081
+ }
1082
+ /**
1083
+ * The `[camelKey, valueExpr]` pairs for a relation's scalar columns. Single
1084
+ * source for all four `json_build_object` emission sites (to-one, hasMany
1085
+ * simple + wrapped, manyToMany) so the `::text` casts can never be applied on
1086
+ * one path and forgotten on another.
1087
+ */
1088
+ function jsonScalarPairs(qi, targetMeta, targetColumns, ref) {
1089
+ return targetColumns.map((col) => [
1090
+ targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
1091
+ jsonScalarExpr(qi, targetMeta, col, ref),
1092
+ ]);
1093
+ }
1094
+ /**
1095
+ * Per-`BuilderCtx`, per-table memo of the camelCase field names that need
1096
+ * JSON-wire decoding, mapped to the OID whose driver parser performs it.
1097
+ * `null` records "this table has none", the overwhelmingly common case, so a
1098
+ * relation of plain text/int columns costs one Map hit per row and no scan.
1099
+ *
1100
+ * Held in a WeakMap rather than on the ctx so the cache dies with the
1101
+ * QueryInterface and no shared interface has to grow a field for it.
1102
+ */
1103
+ const jsonWireFieldCache = new WeakMap();
1104
+ function jsonWireFields(qi, table, meta) {
1105
+ let byTable = jsonWireFieldCache.get(qi);
1106
+ if (!byTable) {
1107
+ byTable = new Map();
1108
+ jsonWireFieldCache.set(qi, byTable);
1109
+ }
1110
+ const cached = byTable.get(table);
1111
+ if (cached !== undefined)
1112
+ return cached;
1113
+ 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
+ }
1124
+ }
1125
+ byTable.set(table, fields);
1126
+ return fields;
1127
+ }
1128
+ /**
1129
+ * Rewrite a JSON-sourced relation row's divergent scalars into the driver's
1130
+ * representation. Returns `row` untouched when the table has no such column,
1131
+ * so the ordinary relation pays nothing beyond the memo lookup.
1132
+ */
1133
+ function decodeJsonWireRow(qi, row, table, meta) {
1134
+ if (!usesJsonWireCoercion(qi))
1135
+ return row;
1136
+ const fields = jsonWireFields(qi, table, meta);
1137
+ if (fields === null)
1138
+ return row;
1139
+ // Copy-on-first-write rather than mutating in place: the row can be a
1140
+ // sub-object of the driver's parsed `json` column, which middleware may still
1141
+ // be holding. Measured against an in-place variant on a 20K-child-row join the
1142
+ // two were indistinguishable, so the copy is free insurance.
1143
+ let decoded;
1144
+ for (const [field, oid] of fields) {
1145
+ const raw = row[field];
1146
+ if (typeof raw !== 'string')
1147
+ continue;
1148
+ if (decoded === undefined)
1149
+ decoded = { ...row };
1150
+ decoded[field] = coerceJsonWireValue(oid, raw);
1151
+ }
1152
+ return decoded ?? row;
1153
+ }
1032
1154
  /**
1033
1155
  * Build the top-level relation shapes for a `with` clause, mirroring
1034
1156
  * {@link buildSelectWithRelations}: same relation iteration order, same
@@ -1040,7 +1162,7 @@ export function buildRelationShapes(qi, table, withClause, includePii) {
1040
1162
  return {};
1041
1163
  const shapes = {};
1042
1164
  for (const [relName, relSpec] of sortedEntries(withClause)) {
1043
- const relDef = meta.relations[relName];
1165
+ const relDef = ownLookup(meta.relations, relName);
1044
1166
  if (!relDef)
1045
1167
  continue; // buildSelectWithRelations already threw for this
1046
1168
  shapes[relName] = buildRelationShape(qi, relDef, relSpec, meta, includePii);
@@ -1063,7 +1185,7 @@ export function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1063
1185
  const nested = {};
1064
1186
  if (spec !== true && spec.with) {
1065
1187
  for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
1066
- const nestedRelDef = targetMeta.relations[nestedRelName];
1188
+ const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
1067
1189
  if (!nestedRelDef)
1068
1190
  continue;
1069
1191
  keys.push(nestedRelName);
@@ -1079,7 +1201,12 @@ export function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1079
1201
  * positional arrays into the object form first (shapes built once, not per
1080
1202
  * row), then delegates to parseNestedRow for date/snake-camel coercion.
1081
1203
  */
1082
- export function makeNestedParser(qi, withClause, includePii) {
1204
+ export function makeNestedParser(qi, withClause, includePii, flattenPlan) {
1205
+ // A flatten plan and the positional encoding are mutually exclusive (the
1206
+ // planner is only consulted for the object encoding), so this branch is safe
1207
+ // ahead of the positional one.
1208
+ if (flattenPlan)
1209
+ return makeFlattenParser(qi, flattenPlan);
1083
1210
  if (qi.jsonEncoding !== 'positional') {
1084
1211
  return (row) => parseNestedRow(qi, row, qi.table);
1085
1212
  }
@@ -1138,6 +1265,524 @@ export function decodePositionalObject(qi, arr, shape) {
1138
1265
  }
1139
1266
  return obj;
1140
1267
  }
1268
+ // ---------------------------------------------------------------------------
1269
+ // relationLoadStrategy: 'flatten' — to-one relations compiled as LEFT JOINs
1270
+ // ---------------------------------------------------------------------------
1271
+ /**
1272
+ * Alias prefix for a flattened relation's join. Deliberately distinct from the
1273
+ * `t<n>` family {@link buildRelationSubquery} (and its `i`/`j`/`t`/`ord`
1274
+ * suffixes) allocates, so the two compilation paths can share one SELECT list
1275
+ * without ever colliding.
1276
+ */
1277
+ const FLATTEN_ALIAS_PREFIX = 'f';
1278
+ /**
1279
+ * WHY EACH FLATTENED SUBTREE IS WRAPPED IN A DERIVED TABLE
1280
+ *
1281
+ * The top-level `WHERE` and `ORDER BY` reference the parent's columns
1282
+ * UNQUALIFIED (`WHERE "name" = $1`). A bare `LEFT JOIN "orgs" f0` puts a second
1283
+ * 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
1285
+ * Postgres rejects the statement. Qualifying the parent's references is not an
1286
+ * option here: they are compiled by the shared WHERE walk, which is scope-blind
1287
+ * by design.
1288
+ *
1289
+ * So a flattened subtree joins as a derived table that exposes ONLY prefixed
1290
+ * names (`f0__id`, `f0__name`, ...). Nothing it contributes can collide with an
1291
+ * unqualified parent reference, by construction rather than by analysis. This is
1292
+ * the same trick the `plan: 'lateral'` pick join uses when it exposes a single
1293
+ * reserved `__turbine_pick` column.
1294
+ *
1295
+ * Inside the derived table every reference is alias-qualified, so the whole
1296
+ * to-one subtree (chained joins, per-relation filters, and any to-many
1297
+ * correlated subqueries hanging off it) lives in one flat, unambiguous scope.
1298
+ * The subquery is a plain SELECT with no LIMIT / DISTINCT / aggregate, so
1299
+ * Postgres pulls it up into the outer join rather than materializing it.
1300
+ */
1301
+ /** Suffix for the inner (real-table) alias inside a flattened derived table. */
1302
+ const FLATTEN_SRC_SUFFIX = 's';
1303
+ /** Alias prefix for a top-level node's exposed correlation columns. */
1304
+ const FLATTEN_CORR = '$c';
1305
+ /**
1306
+ * Maximum SELECT-list alias length. Postgres truncates identifiers at 63 bytes
1307
+ * (MySQL/SQLite are more generous), and a truncated alias could collide with
1308
+ * another projected column, so a relation whose prefixed projection would
1309
+ * exceed it is ineligible and falls back to the correlated subquery.
1310
+ */
1311
+ const FLATTEN_MAX_ALIAS_LEN = 63;
1312
+ /** Nesting depth past which a flattened subtree declines (mirrors the join cap). */
1313
+ const FLATTEN_MAX_DEPTH = 10;
1314
+ /** Suffix of the non-null discriminator column projected for every flattened node. */
1315
+ const FLATTEN_DISCRIMINATOR = '$k';
1316
+ /** Set equality over two column lists (order-insensitive, duplicate-intolerant). */
1317
+ function sameColumnSet(a, b) {
1318
+ if (a.length === 0 || a.length !== b.length)
1319
+ return false;
1320
+ const set = new Set(a);
1321
+ if (set.size !== a.length)
1322
+ return false;
1323
+ for (const col of b)
1324
+ if (!set.has(col))
1325
+ return false;
1326
+ return true;
1327
+ }
1328
+ /**
1329
+ * The target-side correlation columns of a to-one relation, but ONLY when the
1330
+ * schema PROVES they are unique on the target table. This is the row-
1331
+ * multiplication guard: a LEFT JOIN over a non-unique key silently duplicates
1332
+ * parent rows, which would change results rather than just the plan.
1333
+ *
1334
+ * Proof sources, all exact set matches (a unique index on `(a, b)` does NOT
1335
+ * make `a` unique):
1336
+ * - the target's primary key,
1337
+ * - a declared unique constraint (`uniqueColumns`),
1338
+ * - a full, non-expression UNIQUE index. Partial unique indexes are refused
1339
+ * (they only constrain the rows matching their predicate) and PowDB
1340
+ * doc-field expression indexes are refused (they index a JSON path, not the
1341
+ * raw column).
1342
+ *
1343
+ * Returns `null` for to-many / manyToMany relations and for anything it cannot
1344
+ * prove, which routes the relation back to the correlated subquery.
1345
+ *
1346
+ * NULLs need no special handling: a unique constraint permits repeated NULLs,
1347
+ * but `target.key = parent.fk` never matches a NULL key, so a null-keyed target
1348
+ * row can never join.
1349
+ */
1350
+ export function provableUniqueTargetKey(relDef, targetMeta) {
1351
+ let cols;
1352
+ if (relDef.type === 'belongsTo') {
1353
+ // belongsTo: SOURCE holds the FK, so the join is target.referenceKey = parent.foreignKey.
1354
+ cols = normalizeKeyColumns(relDef.referenceKey);
1355
+ }
1356
+ else if (relDef.type === 'hasOne') {
1357
+ // hasOne: TARGET holds the FK, so the join is target.foreignKey = parent.referenceKey.
1358
+ cols = normalizeKeyColumns(relDef.foreignKey);
1359
+ }
1360
+ else {
1361
+ return null;
1362
+ }
1363
+ if (cols.length === 0)
1364
+ return null;
1365
+ if (!cols.every((col) => targetMeta.allColumns.includes(col)))
1366
+ return null;
1367
+ if (sameColumnSet(cols, targetMeta.primaryKey))
1368
+ return cols;
1369
+ for (const unique of targetMeta.uniqueColumns) {
1370
+ if (sameColumnSet(cols, unique))
1371
+ return cols;
1372
+ }
1373
+ for (const idx of targetMeta.indexes) {
1374
+ if (!idx.unique || idx.partial || idx.docPath)
1375
+ continue;
1376
+ if (sameColumnSet(cols, idx.columns))
1377
+ return cols;
1378
+ }
1379
+ return null;
1380
+ }
1381
+ /**
1382
+ * Plan one relation as a flattened LEFT JOIN, or return `null` to leave it on
1383
+ * the correlated-subquery path.
1384
+ *
1385
+ * A relation is ELIGIBLE when all of the following hold:
1386
+ * 1. it is `belongsTo` or `hasOne` AND its target-side correlation columns are
1387
+ * provably unique ({@link provableUniqueTargetKey}) — the row-multiplication
1388
+ * guard;
1389
+ * 2. its spec declares no `limit` and no `orderBy` (both are no-ops over a
1390
+ * single matching row, but refusing them keeps the emitted SQL and the
1391
+ * param stream trivially equivalent);
1392
+ * 3. its nested `with` names only real relations and no reserved `_count`
1393
+ * (nested `_count` is unsupported on every strategy — falling back lets the
1394
+ * subquery path raise the same error);
1395
+ * 4. the depth cap is not reached, so a too-deep chain still raises
1396
+ * {@link CircularRelationError} from the subquery path instead of silently
1397
+ * succeeding;
1398
+ * 5. its projected aliases are unique within the node and within the identifier
1399
+ * length limit.
1400
+ *
1401
+ * Relation `where` IS supported: it moves into the join's `ON` clause, where a
1402
+ * non-matching row null-extends exactly as the subquery's `LIMIT 1` returned
1403
+ * NULL. The target's global filter is applied the same way, so a flattened
1404
+ * relation can never surface soft-deleted / out-of-tenant rows.
1405
+ */
1406
+ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includePii, aliasSink, rejects) {
1407
+ /** Record why this relation stays on the subquery path, then decline it. */
1408
+ const decline = (reason) => {
1409
+ rejects.push({ relation: [...path.slice(1), relName].join('.'), reason });
1410
+ return null;
1411
+ };
1412
+ if (depth >= FLATTEN_MAX_DEPTH)
1413
+ return decline(`it nests more than ${FLATTEN_MAX_DEPTH} levels deep`);
1414
+ const targetMeta = qi.schema.tables[relDef.to];
1415
+ if (!targetMeta)
1416
+ return decline(`its target table "${relDef.to}" is not in the schema metadata`);
1417
+ const key = provableUniqueTargetKey(relDef, targetMeta);
1418
+ if (!key) {
1419
+ return decline(relDef.type !== 'belongsTo' && relDef.type !== 'hasOne'
1420
+ ? `it is ${relDef.type}, and only a to-one relation can be flattened into a join`
1421
+ : `its correlation column(s) on "${relDef.to}" are not provably unique (no primary key, ` +
1422
+ 'unique constraint or non-partial unique index covers them), so a join could multiply parent rows');
1423
+ }
1424
+ const opts = spec === true ? undefined : spec;
1425
+ if (opts) {
1426
+ if (opts.limit !== undefined)
1427
+ return decline('it declares a `limit`');
1428
+ if (opts.orderBy && orderByEntries(opts.orderBy).some(([, dir]) => dir !== undefined)) {
1429
+ return decline('it declares an `orderBy`');
1430
+ }
1431
+ }
1432
+ const nestedEntries = opts?.with ? sortedEntries(opts.with) : [];
1433
+ for (const [nestedRelName] of nestedEntries) {
1434
+ if (nestedRelName === '_count')
1435
+ return decline('its nested `with` uses `_count`');
1436
+ if (!ownLookup(targetMeta.relations, nestedRelName)) {
1437
+ return decline(`its nested \`with\` names "${nestedRelName}", which is not a relation of "${relDef.to}"`);
1438
+ }
1439
+ }
1440
+ const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
1441
+ const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
1442
+ const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
1443
+ const node = {
1444
+ relName,
1445
+ relDef,
1446
+ spec,
1447
+ targetTable: relDef.to,
1448
+ targetMeta,
1449
+ alias,
1450
+ srcAlias: `${alias}${FLATTEN_SRC_SUFFIX}`,
1451
+ keyColumns: key,
1452
+ discAlias,
1453
+ cols: cols.map((col) => [col, `${alias}__${col}`]),
1454
+ slots: [],
1455
+ children: {},
1456
+ jsonAliases: {},
1457
+ corrAliases: depth === 0 ? key.map((_, i) => `${alias}__${FLATTEN_CORR}${i}`) : [],
1458
+ depth,
1459
+ path,
1460
+ };
1461
+ // Local alias uniqueness: a target whose relation name equals one of its own
1462
+ // column names ("owner" the column and "owner" the relation) would otherwise
1463
+ // project two `f0__owner` columns and silently clobber one.
1464
+ const local = [discAlias, ...node.corrAliases, ...node.cols.map(([, a]) => a)];
1465
+ for (const [nestedRelName] of nestedEntries)
1466
+ local.push(`${alias}__${nestedRelName}`);
1467
+ if (new Set(local).size !== local.length) {
1468
+ return decline('a projected column alias would collide (a relation name matches one of its own column names)');
1469
+ }
1470
+ if (local.some((a) => a.length > FLATTEN_MAX_ALIAS_LEN)) {
1471
+ return decline(`a projected column alias would exceed ${FLATTEN_MAX_ALIAS_LEN} characters`);
1472
+ }
1473
+ for (const [nestedRelName, nestedSpec] of nestedEntries) {
1474
+ const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
1475
+ const child = planFlattenNode(qi, counter, nestedRelName, nestedRelDef, nestedSpec, depth + 1, [...path, relName], includePii, aliasSink, rejects);
1476
+ if (child) {
1477
+ node.slots.push({ relName: nestedRelName, kind: 'flat' });
1478
+ node.children[nestedRelName] = child;
1479
+ }
1480
+ else {
1481
+ node.slots.push({ relName: nestedRelName, kind: 'json' });
1482
+ node.jsonAliases[nestedRelName] = `${alias}__${nestedRelName}`;
1483
+ }
1484
+ }
1485
+ aliasSink.push(discAlias, ...node.cols.map(([, a]) => a), ...Object.values(node.jsonAliases));
1486
+ return node;
1487
+ }
1488
+ /** Structural signature of one node, recursively. Feeds the SQL cache key. */
1489
+ function flattenNodeSignature(node) {
1490
+ const slots = node.slots
1491
+ .map((slot) => slot.kind === 'flat' ? `+${flattenNodeSignature(node.children[slot.relName])}` : `~${slot.relName}`)
1492
+ .join('');
1493
+ return `${node.relName}@${node.alias}(${node.cols.map(([col]) => col).join(',')})${slots}`;
1494
+ }
1495
+ /**
1496
+ * Dev-only, once-per-(table, relation, reason) note that an explicitly
1497
+ * requested `relationLoadStrategy: 'flatten'` did NOT engage for a relation.
1498
+ *
1499
+ * Worth its own warning because the fallback is otherwise INVISIBLE: the query
1500
+ * succeeds, the rows are correct (the strategies are value-identical), and the
1501
+ * only symptom is that the plan the caller asked for is not the plan that ran.
1502
+ * Silence there is indistinguishable from the strategy being a no-op, which is
1503
+ * exactly the shape of thing that gets filed as a bug.
1504
+ *
1505
+ * Suppressed in production like the other planner notes, and deduped through
1506
+ * the shared registry so a hot query logs once rather than per call. Note that
1507
+ * a flatten plan is recomputed on the build, param-collect and row-assembly
1508
+ * paths for the same query; the registry is what keeps that to one line.
1509
+ */
1510
+ function warnFlattenFallback(table, rejects) {
1511
+ if (rejects.length === 0)
1512
+ return;
1513
+ if (process.env.NODE_ENV === 'production')
1514
+ return;
1515
+ for (const { relation, reason } of rejects) {
1516
+ if (!shouldWarnOnce(WARN_NS.flattenFallback, `${table}.${relation}|${reason}`))
1517
+ continue;
1518
+ console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage for relation "${relation}" on "${table}": ` +
1519
+ `${reason}. It loads via the correlated subquery instead (same rows, same values, different plan).`);
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Compile the `'flatten'` plan for a top-level `with` clause, or return `null`
1524
+ * when nothing in it is eligible (in which case the caller emits exactly the SQL
1525
+ * it emits today, down to the cache key).
1526
+ *
1527
+ * 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
1529
+ * param-collect path and the row assembler can each recompute it and agree.
1530
+ */
1531
+ export function planFlattenWith(qi, table, withClause, includePii) {
1532
+ const meta = qi.schema.tables[table];
1533
+ if (!meta)
1534
+ return null;
1535
+ const counter = { n: 0 };
1536
+ const aliases = [];
1537
+ const nodes = {};
1538
+ const rejects = [];
1539
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
1540
+ if (relName === '_count')
1541
+ continue;
1542
+ const relDef = ownLookup(meta.relations, relName);
1543
+ // An unknown relation stays on the subquery path so it raises E005 there.
1544
+ if (!relDef)
1545
+ continue;
1546
+ const node = planFlattenNode(qi, counter, relName, relDef, relSpec, 0, [table], includePii, aliases, rejects);
1547
+ if (node)
1548
+ nodes[relName] = node;
1549
+ }
1550
+ warnFlattenFallback(table, rejects);
1551
+ if (Object.keys(nodes).length === 0)
1552
+ return null;
1553
+ // Flat rows carry the root table's own columns, one column per non-flattened
1554
+ // relation (named for the relation) and `_count__<rel>` scalars alongside the
1555
+ // prefixed aliases. A collision would make the assembler read the wrong cell,
1556
+ // so refuse the whole plan rather than plan around it.
1557
+ const reserved = new Set([...meta.allColumns, ...Object.keys(withClause)]);
1558
+ for (const alias of aliases) {
1559
+ if (reserved.has(alias)) {
1560
+ warnFlattenFallback(table, [
1561
+ {
1562
+ relation: Object.keys(nodes).join(', '),
1563
+ reason: `the projected alias "${alias}" collides with a column or relation name on "${table}"`,
1564
+ },
1565
+ ]);
1566
+ return null;
1567
+ }
1568
+ }
1569
+ const signature = Object.keys(nodes)
1570
+ .sort()
1571
+ .map((relName) => flattenNodeSignature(nodes[relName]))
1572
+ .join('|');
1573
+ return { nodes, aliases, signature };
1574
+ }
1575
+ /**
1576
+ * Compile the correlation of a to-one relation as a join condition:
1577
+ * - `belongsTo` → `target.referenceKey = parent.foreignKey` (SOURCE holds the FK)
1578
+ * - `hasOne` → `target.foreignKey = parent.referenceKey` (TARGET holds the FK)
1579
+ *
1580
+ * Same column pairing {@link buildRelationSubquery} correlates on; getting it
1581
+ * backwards silently compares the wrong columns.
1582
+ */
1583
+ function flattenCorrelation(qi, relDef, targetRef, targetColumns, parentRef) {
1584
+ const parentColumns = relDef.type === 'belongsTo' ? relDef.foreignKey : relDef.referenceKey;
1585
+ return qi.dialect.buildCorrelation(targetRef, targetColumns, parentRef, parentColumns);
1586
+ }
1587
+ /**
1588
+ * Emit one TOP-LEVEL flattened relation: a derived table holding its whole
1589
+ * to-one subtree, joined to the parent on the re-exposed correlation columns,
1590
+ * plus the pass-through projection of every name that subtree contributes.
1591
+ *
1592
+ * ```sql
1593
+ * LEFT JOIN (
1594
+ * SELECT 1 AS "f0__$k", f0s."id" AS "f0__$c0",
1595
+ * f0s."id" AS "f0__id", f0s."name" AS "f0__name",
1596
+ * (f1s."id" IS NOT NULL) AS "f1__$k", f1s."code" AS "f1__code"
1597
+ * FROM "orgs" f0s
1598
+ * LEFT JOIN "regions" f1s ON f1s."id" = f0s."region_id"
1599
+ * WHERE f0s."deleted" = $1
1600
+ * ) f0 ON f0."f0__$c0" = "users"."org_id"
1601
+ * ```
1602
+ *
1603
+ * See the derived-table note above for why the join cannot expose the target's
1604
+ * real column names.
1605
+ */
1606
+ export function emitFlattenNode(qi, node, parentRef, params, joinSink, selectSink, aliasCounter, includePii) {
1607
+ const innerSelects = [];
1608
+ const innerJoins = [];
1609
+ const innerWhere = [];
1610
+ emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere, aliasCounter, includePii);
1611
+ const whereSql = innerWhere.length > 0 ? ` WHERE ${innerWhere.join(' AND ')}` : '';
1612
+ const derived = `SELECT ${innerSelects.join(', ')} FROM ${qi.q(node.targetTable)} ${node.srcAlias}${innerJoins.join('')}${whereSql}`;
1613
+ const on = flattenCorrelation(qi, node.relDef, node.alias, node.corrAliases, parentRef);
1614
+ joinSink.push(` LEFT JOIN (${derived}) ${node.alias} ON ${on}`);
1615
+ projectFlattenNode(qi, node, node.alias, selectSink);
1616
+ }
1617
+ /**
1618
+ * Build one node's contribution INSIDE its top-level derived table: its filters,
1619
+ * its prefixed column projection, and its nested slots (another inner LEFT JOIN
1620
+ * for a flattened child, a correlated subquery column for anything else).
1621
+ *
1622
+ * Param push order, mirrored exactly by {@link collectFlattenNodeParams}:
1623
+ * relation `where` → target global filter → each nested slot in `slots` order.
1624
+ */
1625
+ function emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere, aliasCounter, includePii) {
1626
+ const { srcAlias, targetTable, targetMeta } = node;
1627
+ const isRoot = node.depth === 0;
1628
+ // A root node's filters go in the derived table's WHERE; a nested node's go in
1629
+ // its own inner LEFT JOIN's ON. Both null-extend the relation on a miss rather
1630
+ // than dropping the parent row, matching what the correlated subquery's
1631
+ // `LIMIT 1` did when it returned NULL.
1632
+ const filters = innerWhere;
1633
+ if (node.spec !== true && node.spec.where) {
1634
+ const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, srcAlias, node.spec.where, params);
1635
+ if (extra)
1636
+ filters.push(extra);
1637
+ }
1638
+ // Global filter on the target (soft-delete / tenancy): a flattened relation
1639
+ // must never surface rows the join strategy would have filtered out.
1640
+ const gf = whereMod.targetGlobalFilterAlias(qi, targetTable, srcAlias, params);
1641
+ if (gf)
1642
+ filters.push(gf);
1643
+ if (isRoot) {
1644
+ // The derived table's rows exist only where the target matched, so a
1645
+ // constant marks the match; the outer LEFT JOIN nulls it on a miss.
1646
+ innerSelects.push(`1 AS ${qi.q(node.discAlias)}`);
1647
+ node.keyColumns.forEach((col, i) => {
1648
+ innerSelects.push(`${srcAlias}.${qi.q(col)} AS ${qi.q(node.corrAliases[i])}`);
1649
+ });
1650
+ }
1651
+ else {
1652
+ innerSelects.push(`(${srcAlias}.${qi.q(node.keyColumns[0])} IS NOT NULL) AS ${qi.q(node.discAlias)}`);
1653
+ }
1654
+ for (const [col, sqlAlias] of node.cols) {
1655
+ innerSelects.push(`${srcAlias}.${qi.q(col)} AS ${qi.q(sqlAlias)}`);
1656
+ }
1657
+ for (const slot of node.slots) {
1658
+ if (slot.kind === 'flat') {
1659
+ const child = node.children[slot.relName];
1660
+ const on = flattenCorrelation(qi, child.relDef, child.srcAlias, child.keyColumns, srcAlias);
1661
+ // Placeholder: the child's own filters are appended to this ON below, so
1662
+ // reserve the slot now to keep join order matching slot order.
1663
+ const at = innerJoins.length;
1664
+ innerJoins.push('');
1665
+ const childFilters = [];
1666
+ emitFlattenInner(qi, child, params, innerSelects, innerJoins, childFilters, aliasCounter, includePii);
1667
+ const conds = [on, ...childFilters].join(' AND ');
1668
+ innerJoins[at] = ` LEFT JOIN ${qi.q(child.targetTable)} ${child.srcAlias} ON ${conds}`;
1669
+ continue;
1670
+ }
1671
+ const nestedRelDef = ownLookup(targetMeta.relations, slot.relName);
1672
+ const nestedSpec = node.spec.with[slot.relName];
1673
+ const sub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, srcAlias, aliasCounter, node.depth + 1, [...node.path, node.relName], includePii);
1674
+ // Same fallback the subquery path picks for a nested relation slot.
1675
+ const fallback = nestedRelDef.type === 'hasMany' ? qi.dialect.emptyJsonArrayLiteral : qi.dialect.nullJsonLiteral;
1676
+ innerSelects.push(`${qi.dialect.wrapJsonSubresult(sub, fallback)} AS ${qi.q(node.jsonAliases[slot.relName])}`);
1677
+ }
1678
+ }
1679
+ /**
1680
+ * Pass every name a flattened subtree contributes through the outer SELECT, in
1681
+ * the same order {@link emitFlattenInner} produced it (which is the order the
1682
+ * join strategy's `json_build_object` uses, so assembled key order matches).
1683
+ * The internal `$c` correlation columns are deliberately NOT projected: they
1684
+ * exist only for the outer join condition.
1685
+ */
1686
+ function projectFlattenNode(qi, node, outerAlias, selectSink) {
1687
+ const ref = (name) => `${qi.q(outerAlias)}.${qi.q(name)}`;
1688
+ selectSink.push(ref(node.discAlias));
1689
+ for (const [, sqlAlias] of node.cols)
1690
+ selectSink.push(ref(sqlAlias));
1691
+ for (const slot of node.slots) {
1692
+ if (slot.kind === 'flat')
1693
+ projectFlattenNode(qi, node.children[slot.relName], outerAlias, selectSink);
1694
+ else
1695
+ selectSink.push(ref(node.jsonAliases[slot.relName]));
1696
+ }
1697
+ }
1698
+ /** Param-collect mirror of {@link emitFlattenNode}. */
1699
+ export function collectFlattenNodeParams(qi, node, params) {
1700
+ if (node.spec !== true && node.spec.where) {
1701
+ whereMod.collectAliasWhereParams(qi, node.targetTable, node.targetMeta, node.spec.where, params);
1702
+ }
1703
+ whereMod.collectTargetGlobalFilterAlias(qi, node.targetTable, params);
1704
+ for (const slot of node.slots) {
1705
+ if (slot.kind === 'flat') {
1706
+ collectFlattenNodeParams(qi, node.children[slot.relName], params);
1707
+ continue;
1708
+ }
1709
+ const nestedRelDef = ownLookup(node.targetMeta.relations, slot.relName);
1710
+ if (!nestedRelDef)
1711
+ continue;
1712
+ const nestedSpec = node.spec.with[slot.relName];
1713
+ collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, node.alias, node.depth + 1);
1714
+ }
1715
+ }
1716
+ /**
1717
+ * Rebuild one flattened relation's object from a flat row.
1718
+ *
1719
+ * Returns `null` when the discriminator is NULL (the LEFT JOIN matched
1720
+ * nothing), which is the ONLY signal that distinguishes "no related row" from
1721
+ * "a related row whose every projected column is NULL".
1722
+ *
1723
+ * The sub-row is keyed by the target's raw snake_case column names and handed
1724
+ * to {@link parseNestedRow}, so the flattened object goes through the very same
1725
+ * camelCase mapping, Date coercion and nested-JSON parsing the join strategy's
1726
+ * `json_build_object` output does. Nested flattened relations are assigned
1727
+ * after the parse, over placeholder keys inserted in slot order, so the
1728
+ * assembled object's key order matches the join strategy's exactly.
1729
+ */
1730
+ export function assembleFlattenNode(qi, row, node) {
1731
+ // The discriminator is the projected predicate `key IS NOT NULL`, so its
1732
+ // truthy encodings are engine-specific (Postgres booleans, MySQL/SQLite 0/1,
1733
+ // and a text-mode driver's 't'/'1'). Whitelist the matched forms: an
1734
+ // unrecognized encoding reads as "no related row", which the cross-engine
1735
+ // parity suites surface loudly rather than as scattered wrong objects.
1736
+ const disc = row[node.discAlias];
1737
+ const matched = disc === true || disc === 1 || disc === '1' || disc === 't' || disc === 'true';
1738
+ if (!matched)
1739
+ return null;
1740
+ const sub = {};
1741
+ for (const [col, sqlAlias] of node.cols)
1742
+ sub[col] = row[sqlAlias];
1743
+ for (const slot of node.slots) {
1744
+ // A 'flat' slot is a placeholder here: parseNestedRow skips `undefined`
1745
+ // relation values, so the key keeps its position and is filled in below.
1746
+ sub[slot.relName] = slot.kind === 'json' ? row[node.jsonAliases[slot.relName]] : undefined;
1747
+ }
1748
+ const parsed = parseNestedRow(qi, sub, node.targetTable);
1749
+ for (const slot of node.slots) {
1750
+ if (slot.kind === 'flat')
1751
+ parsed[slot.relName] = assembleFlattenNode(qi, row, node.children[slot.relName]);
1752
+ }
1753
+ return parsed;
1754
+ }
1755
+ /**
1756
+ * Row parser for a `'flatten'` plan: strip the prefixed join columns out of the
1757
+ * flat row (inserting a placeholder at the position each relation's block
1758
+ * started, so key order is preserved), parse the remainder exactly as the join
1759
+ * strategy does, then assemble each flattened relation.
1760
+ */
1761
+ export function makeFlattenParser(qi, plan) {
1762
+ const flatAliases = new Set(plan.aliases);
1763
+ // The discriminator is emitted FIRST for each node, so it marks where a
1764
+ // relation's block begins in the flat row's key order.
1765
+ const relationByLeadAlias = new Map();
1766
+ for (const [relName, node] of Object.entries(plan.nodes))
1767
+ relationByLeadAlias.set(node.discAlias, relName);
1768
+ return (row) => {
1769
+ const stripped = {};
1770
+ for (const key of Object.keys(row)) {
1771
+ if (!flatAliases.has(key)) {
1772
+ stripped[key] = row[key];
1773
+ continue;
1774
+ }
1775
+ const relName = relationByLeadAlias.get(key);
1776
+ if (relName !== undefined)
1777
+ stripped[relName] = undefined;
1778
+ }
1779
+ const parsed = parseNestedRow(qi, stripped, qi.table);
1780
+ for (const [relName, node] of Object.entries(plan.nodes)) {
1781
+ parsed[relName] = assembleFlattenNode(qi, row, node);
1782
+ }
1783
+ return parsed;
1784
+ };
1785
+ }
1141
1786
  /**
1142
1787
  * Build a SELECT clause that includes both base columns and nested relation subqueries.
1143
1788
  *
@@ -1179,7 +1824,7 @@ export function decodePositionalObject(qi, arr, shape) {
1179
1824
  * @returns A complete SELECT clause string (without the `SELECT` keyword) containing
1180
1825
  * base columns and relation subqueries.
1181
1826
  */
1182
- export function buildSelectWithRelations(qi, table, withClause, params, columnsList, depth, path, includePii) {
1827
+ export function buildSelectWithRelations(qi, table, withClause, params, columnsList, depth, path, includePii, flatten) {
1183
1828
  const meta = qi.schema.tables[table];
1184
1829
  if (!meta)
1185
1830
  throw new ValidationError(`[turbine] Unknown table "${table}"`);
@@ -1199,11 +1844,19 @@ export function buildSelectWithRelations(qi, table, withClause, params, columnsL
1199
1844
  // `_count` is a reserved key handled after the relation subqueries.
1200
1845
  if (relName === '_count')
1201
1846
  continue;
1202
- const relDef = meta.relations[relName];
1847
+ const relDef = ownLookup(meta.relations, relName);
1203
1848
  if (!relDef) {
1204
1849
  throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
1205
1850
  `Available: ${Object.keys(meta.relations).join(', ')}`);
1206
1851
  }
1852
+ // `relationLoadStrategy: 'flatten'`: an eligible to-one relation becomes a
1853
+ // LEFT JOIN + prefixed scalar projection instead of a per-parent-row
1854
+ // correlated subquery. Every other relation falls through unchanged.
1855
+ const flatNode = flatten?.plan.nodes[relName];
1856
+ if (flatNode) {
1857
+ emitFlattenNode(qi, flatNode, qi.q(table), params, flatten.joinSink, relationSelects, aliasCounter, includePii);
1858
+ continue;
1859
+ }
1207
1860
  // The main table is not aliased, so pass table name as parentRef
1208
1861
  const subquery = buildRelationSubquery(qi, relDef, relSpec, params, table, aliasCounter, depth, path, includePii);
1209
1862
  relationSelects.push(`(${subquery}) AS ${qi.q(relName)}`);
@@ -1383,10 +2036,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
1383
2036
  });
1384
2037
  }
1385
2038
  // Build JSON object pairs for resolved columns
1386
- const jsonPairs = targetColumns.map((col) => [
1387
- targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
1388
- `${alias}.${qi.q(col)}`,
1389
- ]);
2039
+ const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, alias);
1390
2040
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
1391
2041
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
1392
2042
  // so we must NOT build them here (they would push orphaned params).
@@ -1404,7 +2054,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
1404
2054
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
1405
2055
  if (!willWrap && spec !== true && spec.with) {
1406
2056
  for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
1407
- const nestedRelDef = targetMeta.relations[nestedRelName];
2057
+ const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
1408
2058
  if (!nestedRelDef) {
1409
2059
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1410
2060
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
@@ -1463,7 +2113,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
1463
2113
  // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
1464
2114
  let limitClause = '';
1465
2115
  if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
1466
- limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
2116
+ limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
1467
2117
  }
1468
2118
  if (relDef.type === 'hasMany') {
1469
2119
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -1474,14 +2124,11 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
1474
2124
  // Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
1475
2125
  const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${qi.q(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
1476
2126
  // For the json_build_object, reference the inner alias — only include resolved columns
1477
- const innerJsonPairs = targetColumns.map((col) => [
1478
- targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
1479
- `${innerAlias}.${qi.q(col)}`,
1480
- ]);
2127
+ const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
1481
2128
  // Build nested relation subqueries referencing innerAlias
1482
2129
  if (spec !== true && spec.with) {
1483
2130
  for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
1484
- const nestedRelDef = targetMeta.relations[nestedRelName];
2131
+ const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
1485
2132
  if (!nestedRelDef) {
1486
2133
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1487
2134
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
@@ -1584,7 +2231,7 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
1584
2231
  // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
1585
2232
  let limitClause = '';
1586
2233
  if (spec !== true && spec.limit !== undefined) {
1587
- limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
2234
+ limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
1588
2235
  }
1589
2236
  const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
1590
2237
  // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
@@ -1593,14 +2240,11 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
1593
2240
  const innerAlias = `${talias}i`;
1594
2241
  const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${qi.q(c)}`).join(', ')} ` +
1595
2242
  `${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
1596
- const innerJsonPairs = targetColumns.map((col) => [
1597
- targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
1598
- `${innerAlias}.${qi.q(col)}`,
1599
- ]);
2243
+ const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
1600
2244
  // Nested relations reference the inner alias.
1601
2245
  if (spec !== true && spec.with) {
1602
2246
  for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
1603
- const nestedRelDef = targetMeta.relations[nestedRelName];
2247
+ const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
1604
2248
  if (!nestedRelDef) {
1605
2249
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1606
2250
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
@@ -1617,13 +2261,10 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
1617
2261
  }
1618
2262
  // Simple path: build the json object pairs directly off the target alias,
1619
2263
  // including any nested relations (correlated to the target alias).
1620
- const jsonPairs = targetColumns.map((col) => [
1621
- targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
1622
- `${talias}.${qi.q(col)}`,
1623
- ]);
2264
+ const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, talias);
1624
2265
  if (spec !== true && spec.with) {
1625
2266
  for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
1626
- const nestedRelDef = targetMeta.relations[nestedRelName];
2267
+ const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
1627
2268
  if (!nestedRelDef) {
1628
2269
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1629
2270
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);