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
@@ -80,6 +80,12 @@ exports.makeNestedParser = makeNestedParser;
80
80
  exports.decodePositionalRelations = decodePositionalRelations;
81
81
  exports.decodePositionalValue = decodePositionalValue;
82
82
  exports.decodePositionalObject = decodePositionalObject;
83
+ exports.provableUniqueTargetKey = provableUniqueTargetKey;
84
+ exports.planFlattenWith = planFlattenWith;
85
+ exports.emitFlattenNode = emitFlattenNode;
86
+ exports.collectFlattenNodeParams = collectFlattenNodeParams;
87
+ exports.assembleFlattenNode = assembleFlattenNode;
88
+ exports.makeFlattenParser = makeFlattenParser;
83
89
  exports.buildSelectWithRelations = buildSelectWithRelations;
84
90
  exports.buildRelationSubquery = buildRelationSubquery;
85
91
  exports.buildManyToManySubquery = buildManyToManySubquery;
@@ -163,7 +169,7 @@ function withFingerprint(qi, withClause, table, depth = 0) {
163
169
  .join(',')})`);
164
170
  continue;
165
171
  }
166
- const relDef = meta.relations[relName];
172
+ const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
167
173
  if (!relDef) {
168
174
  parts.push(`unknown:${relName}`);
169
175
  continue;
@@ -193,7 +199,7 @@ function withFingerprint(qi, withClause, table, depth = 0) {
193
199
  // `{title: {contains: 'x'}}` emit different SQL so they must not share
194
200
  // a fingerprint)
195
201
  if (opts.where) {
196
- subParts.push(`w=${whereMod.fingerprintAliasWhere(qi, opts.where, meta.relations[relName]?.to)}`);
202
+ subParts.push(`w=${whereMod.fingerprintAliasWhere(qi, opts.where, relDef.to)}`);
197
203
  }
198
204
  // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
199
205
  if (opts.orderBy) {
@@ -221,14 +227,19 @@ function withFingerprint(qi, withClause, table, depth = 0) {
221
227
  * Collect params from a `with` clause tree. Mirrors buildSelectWithRelations +
222
228
  * buildRelationSubquery param-push order.
223
229
  */
224
- function collectWithParams(qi, withClause, params, table) {
230
+ function collectWithParams(qi, withClause, params, table, flattenPlan) {
225
231
  const meta = qi.schema.tables[table ?? qi.table];
226
232
  if (!meta)
227
233
  return;
228
234
  for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
229
- const relDef = meta.relations[relName];
235
+ const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
230
236
  if (!relDef)
231
237
  continue;
238
+ const flatNode = flattenPlan?.nodes[relName];
239
+ if (flatNode) {
240
+ collectFlattenNodeParams(qi, flatNode, params);
241
+ continue;
242
+ }
232
243
  collectRelationSubqueryParams(qi, relDef, relSpec, params, table ?? qi.table);
233
244
  }
234
245
  // `_count` global-filter params — mirror buildSelectWithRelations, which
@@ -270,11 +281,11 @@ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, dep
270
281
  }
271
282
  whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
272
283
  if (spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
273
- params.push(Number(spec.limit));
284
+ params.push(qi.paginationValue(spec.limit, 'relation limit'));
274
285
  }
275
286
  if (spec.with) {
276
287
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
277
- const nestedRelDef = targetMeta.relations[nestedRelName];
288
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
278
289
  if (!nestedRelDef)
279
290
  continue;
280
291
  collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
@@ -289,7 +300,7 @@ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, dep
289
300
  // Non-wrapped path: nested relations BEFORE where/limit
290
301
  if (!willWrap && spec.with) {
291
302
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
292
- const nestedRelDef = targetMeta.relations[nestedRelName];
303
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
293
304
  if (!nestedRelDef)
294
305
  continue;
295
306
  collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
@@ -313,12 +324,12 @@ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, dep
313
324
  // pushing one here would orphan a param and desync the collect path.
314
325
  // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
315
326
  if (relDef.type === 'hasMany' && spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
316
- params.push(Number(spec.limit));
327
+ params.push(qi.paginationValue(spec.limit, 'relation limit'));
317
328
  }
318
329
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
319
330
  if (willWrap && spec.with) {
320
331
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
321
- const nestedRelDef = targetMeta.relations[nestedRelName];
332
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
322
333
  if (!nestedRelDef)
323
334
  continue;
324
335
  collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'innerAlias', depth + 1);
@@ -549,7 +560,7 @@ function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lateralSin
549
560
  const ownerMeta = ctx?.meta ?? qi.tableMeta;
550
561
  const ownerTable = ctx?.table ?? qi.table;
551
562
  const parentRef = ctx?.parentRef ?? qi.table;
552
- const relDef = ownerMeta.relations[relName];
563
+ const relDef = (0, utils_js_1.ownLookup)(ownerMeta.relations, relName);
553
564
  if (!relDef) {
554
565
  // A table with no relations at all would otherwise render a dangling
555
566
  // "Available: " and read as a broken message; and the most likely cause of
@@ -869,7 +880,7 @@ function collectRelationOrderParams(qi, targetTable, targetMeta, orderEntries, p
869
880
  if ((0, filters_js_1.isRelationPickOrderBy)(dirValue)) {
870
881
  throw pickOrderNestedError(qi, key);
871
882
  }
872
- const relDef = targetMeta.relations[key];
883
+ const relDef = (0, utils_js_1.ownLookup)(targetMeta.relations, key);
873
884
  if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
874
885
  collectRelationCountParams(qi, relDef, params);
875
886
  }
@@ -984,12 +995,23 @@ function getCamelDateFields(qi, table, meta) {
984
995
  }
985
996
  return camel;
986
997
  }
987
- /** Parse a row that may contain JSON nested relation columns */
988
- function parseNestedRow(qi, row, table) {
989
- const parsed = qi.parseRow(row, table);
998
+ /**
999
+ * Parse a row that may contain JSON nested relation columns.
1000
+ *
1001
+ * `fromJson` says where THIS row's own scalar columns came from. A root row
1002
+ * (join, batched, flatten) is read straight off the driver, so `false`; a row
1003
+ * decoded out of a `json_agg`/`json_build_object` column is `true` and gets
1004
+ * its divergent scalars decoded back to the driver's representation first (see
1005
+ * the JSON-wire section above). Recursion into a relation column is always
1006
+ * `true`, which is exactly right on every strategy: `batched` and `flatten`
1007
+ * hand this function driver rows, but any relation still nested INSIDE one of
1008
+ * those rows arrived as a correlated JSON subquery.
1009
+ */
1010
+ function parseNestedRow(qi, row, table, fromJson = false) {
990
1011
  const meta = qi.schema.tables[table];
991
1012
  if (!meta)
992
- return parsed;
1013
+ return qi.parseRow(row, table);
1014
+ const parsed = qi.parseRow(fromJson ? decodeJsonWireRow(qi, row, table, meta) : row, table);
993
1015
  // Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
994
1016
  // parseRow copies these unknown columns through under their raw key.
995
1017
  let countObj;
@@ -1029,11 +1051,11 @@ function parseNestedRow(qi, row, table) {
1029
1051
  // coercion AND its own sub-relations parsed at arbitrary depth.
1030
1052
  if (Array.isArray(jsonVal)) {
1031
1053
  parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
1032
- ? parseNestedRow(qi, item, relDef.to)
1054
+ ? parseNestedRow(qi, item, relDef.to, true)
1033
1055
  : item);
1034
1056
  }
1035
1057
  else if (typeof jsonVal === 'object' && jsonVal !== null) {
1036
- parsed[relName] = parseNestedRow(qi, jsonVal, relDef.to);
1058
+ parsed[relName] = parseNestedRow(qi, jsonVal, relDef.to, true);
1037
1059
  }
1038
1060
  else {
1039
1061
  parsed[relName] = jsonVal;
@@ -1046,11 +1068,11 @@ function parseNestedRow(qi, row, table) {
1046
1068
  }
1047
1069
  else if (Array.isArray(rawValue)) {
1048
1070
  parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
1049
- ? parseNestedRow(qi, item, relDef.to)
1071
+ ? parseNestedRow(qi, item, relDef.to, true)
1050
1072
  : item);
1051
1073
  }
1052
1074
  else if (typeof rawValue === 'object' && rawValue !== null) {
1053
- parsed[relName] = parseNestedRow(qi, rawValue, relDef.to);
1075
+ parsed[relName] = parseNestedRow(qi, rawValue, relDef.to, true);
1054
1076
  }
1055
1077
  else {
1056
1078
  parsed[relName] = rawValue;
@@ -1100,6 +1122,112 @@ function buildJsonRow(qi, jsonPairs) {
1100
1122
  }
1101
1123
  return qi.dialect.buildJsonObject(jsonPairs);
1102
1124
  }
1125
+ // ---------------------------------------------------------------------------
1126
+ // JSON-wire value fidelity (see JSON_WIRE_COERCION_OIDS in utils.ts)
1127
+ //
1128
+ // `json_build_object` renders a handful of Postgres types as something other
1129
+ // than the value the driver hands back for the same column, so the 'join'
1130
+ // strategy used to disagree with a top-level read, with 'batched', and with
1131
+ // 'flatten' — losing precision outright on numeric and int8. The two halves of
1132
+ // the fix live here and must stay in lockstep:
1133
+ //
1134
+ // SQL side jsonScalarPairs() emits `alias."col"::text` for a divergent
1135
+ // column, so the JSON carries the driver's wire text verbatim.
1136
+ // parse side parseNestedRow(..., fromJson = true) runs that text back
1137
+ // through the driver's own parser for the column's OID.
1138
+ //
1139
+ // Both derive the column set from the SAME jsonWireCoercionOid() call over the
1140
+ // target table's `pgTypes`, so a cast can never be emitted without a matching
1141
+ // decode (or vice versa). Postgres-only: other engines neither use
1142
+ // json_build_object nor share this divergence set.
1143
+ // ---------------------------------------------------------------------------
1144
+ /** True when this query's engine gets the JSON-wire cast/decode treatment. */
1145
+ function usesJsonWireCoercion(qi) {
1146
+ return qi.dialect.name === 'postgresql';
1147
+ }
1148
+ /**
1149
+ * The JSON value expression for one scalar relation column: the plain column
1150
+ * reference, or a `::text` cast when the type's JSON rendering would not match
1151
+ * what the driver produces.
1152
+ */
1153
+ function jsonScalarExpr(qi, targetMeta, col, ref) {
1154
+ const expr = `${ref}.${qi.q(col)}`;
1155
+ if (!usesJsonWireCoercion(qi))
1156
+ return expr;
1157
+ return (0, utils_js_1.jsonWireCoercionOid)(targetMeta.pgTypes?.[col]) === undefined ? expr : `${expr}::text`;
1158
+ }
1159
+ /**
1160
+ * The `[camelKey, valueExpr]` pairs for a relation's scalar columns. Single
1161
+ * source for all four `json_build_object` emission sites (to-one, hasMany
1162
+ * simple + wrapped, manyToMany) so the `::text` casts can never be applied on
1163
+ * one path and forgotten on another.
1164
+ */
1165
+ function jsonScalarPairs(qi, targetMeta, targetColumns, ref) {
1166
+ return targetColumns.map((col) => [
1167
+ targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
1168
+ jsonScalarExpr(qi, targetMeta, col, ref),
1169
+ ]);
1170
+ }
1171
+ /**
1172
+ * Per-`BuilderCtx`, per-table memo of the camelCase field names that need
1173
+ * JSON-wire decoding, mapped to the OID whose driver parser performs it.
1174
+ * `null` records "this table has none", the overwhelmingly common case, so a
1175
+ * relation of plain text/int columns costs one Map hit per row and no scan.
1176
+ *
1177
+ * Held in a WeakMap rather than on the ctx so the cache dies with the
1178
+ * QueryInterface and no shared interface has to grow a field for it.
1179
+ */
1180
+ const jsonWireFieldCache = new WeakMap();
1181
+ function jsonWireFields(qi, table, meta) {
1182
+ let byTable = jsonWireFieldCache.get(qi);
1183
+ if (!byTable) {
1184
+ byTable = new Map();
1185
+ jsonWireFieldCache.set(qi, byTable);
1186
+ }
1187
+ const cached = byTable.get(table);
1188
+ if (cached !== undefined)
1189
+ return cached;
1190
+ let fields = null;
1191
+ const pgTypes = meta.pgTypes;
1192
+ if (pgTypes) {
1193
+ for (const col of meta.allColumns) {
1194
+ const oid = (0, utils_js_1.jsonWireCoercionOid)(pgTypes[col]);
1195
+ if (oid === undefined)
1196
+ continue;
1197
+ if (fields === null)
1198
+ fields = new Map();
1199
+ fields.set(meta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col), oid);
1200
+ }
1201
+ }
1202
+ byTable.set(table, fields);
1203
+ return fields;
1204
+ }
1205
+ /**
1206
+ * Rewrite a JSON-sourced relation row's divergent scalars into the driver's
1207
+ * representation. Returns `row` untouched when the table has no such column,
1208
+ * so the ordinary relation pays nothing beyond the memo lookup.
1209
+ */
1210
+ function decodeJsonWireRow(qi, row, table, meta) {
1211
+ if (!usesJsonWireCoercion(qi))
1212
+ return row;
1213
+ const fields = jsonWireFields(qi, table, meta);
1214
+ if (fields === null)
1215
+ return row;
1216
+ // Copy-on-first-write rather than mutating in place: the row can be a
1217
+ // sub-object of the driver's parsed `json` column, which middleware may still
1218
+ // be holding. Measured against an in-place variant on a 20K-child-row join the
1219
+ // two were indistinguishable, so the copy is free insurance.
1220
+ let decoded;
1221
+ for (const [field, oid] of fields) {
1222
+ const raw = row[field];
1223
+ if (typeof raw !== 'string')
1224
+ continue;
1225
+ if (decoded === undefined)
1226
+ decoded = { ...row };
1227
+ decoded[field] = (0, utils_js_1.coerceJsonWireValue)(oid, raw);
1228
+ }
1229
+ return decoded ?? row;
1230
+ }
1103
1231
  /**
1104
1232
  * Build the top-level relation shapes for a `with` clause, mirroring
1105
1233
  * {@link buildSelectWithRelations}: same relation iteration order, same
@@ -1111,7 +1239,7 @@ function buildRelationShapes(qi, table, withClause, includePii) {
1111
1239
  return {};
1112
1240
  const shapes = {};
1113
1241
  for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
1114
- const relDef = meta.relations[relName];
1242
+ const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
1115
1243
  if (!relDef)
1116
1244
  continue; // buildSelectWithRelations already threw for this
1117
1245
  shapes[relName] = buildRelationShape(qi, relDef, relSpec, meta, includePii);
@@ -1134,7 +1262,7 @@ function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1134
1262
  const nested = {};
1135
1263
  if (spec !== true && spec.with) {
1136
1264
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1137
- const nestedRelDef = targetMeta.relations[nestedRelName];
1265
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
1138
1266
  if (!nestedRelDef)
1139
1267
  continue;
1140
1268
  keys.push(nestedRelName);
@@ -1150,7 +1278,12 @@ function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1150
1278
  * positional arrays into the object form first (shapes built once, not per
1151
1279
  * row), then delegates to parseNestedRow for date/snake-camel coercion.
1152
1280
  */
1153
- function makeNestedParser(qi, withClause, includePii) {
1281
+ function makeNestedParser(qi, withClause, includePii, flattenPlan) {
1282
+ // A flatten plan and the positional encoding are mutually exclusive (the
1283
+ // planner is only consulted for the object encoding), so this branch is safe
1284
+ // ahead of the positional one.
1285
+ if (flattenPlan)
1286
+ return makeFlattenParser(qi, flattenPlan);
1154
1287
  if (qi.jsonEncoding !== 'positional') {
1155
1288
  return (row) => parseNestedRow(qi, row, qi.table);
1156
1289
  }
@@ -1209,6 +1342,524 @@ function decodePositionalObject(qi, arr, shape) {
1209
1342
  }
1210
1343
  return obj;
1211
1344
  }
1345
+ // ---------------------------------------------------------------------------
1346
+ // relationLoadStrategy: 'flatten' — to-one relations compiled as LEFT JOINs
1347
+ // ---------------------------------------------------------------------------
1348
+ /**
1349
+ * Alias prefix for a flattened relation's join. Deliberately distinct from the
1350
+ * `t<n>` family {@link buildRelationSubquery} (and its `i`/`j`/`t`/`ord`
1351
+ * suffixes) allocates, so the two compilation paths can share one SELECT list
1352
+ * without ever colliding.
1353
+ */
1354
+ const FLATTEN_ALIAS_PREFIX = 'f';
1355
+ /**
1356
+ * WHY EACH FLATTENED SUBTREE IS WRAPPED IN A DERIVED TABLE
1357
+ *
1358
+ * The top-level `WHERE` and `ORDER BY` reference the parent's columns
1359
+ * UNQUALIFIED (`WHERE "name" = $1`). A bare `LEFT JOIN "orgs" f0` puts a second
1360
+ * table in scope, so any column name the two tables share ("id", "name",
1361
+ * "created_at" — i.e. most of them) turns those references ambiguous and
1362
+ * Postgres rejects the statement. Qualifying the parent's references is not an
1363
+ * option here: they are compiled by the shared WHERE walk, which is scope-blind
1364
+ * by design.
1365
+ *
1366
+ * So a flattened subtree joins as a derived table that exposes ONLY prefixed
1367
+ * names (`f0__id`, `f0__name`, ...). Nothing it contributes can collide with an
1368
+ * unqualified parent reference, by construction rather than by analysis. This is
1369
+ * the same trick the `plan: 'lateral'` pick join uses when it exposes a single
1370
+ * reserved `__turbine_pick` column.
1371
+ *
1372
+ * Inside the derived table every reference is alias-qualified, so the whole
1373
+ * to-one subtree (chained joins, per-relation filters, and any to-many
1374
+ * correlated subqueries hanging off it) lives in one flat, unambiguous scope.
1375
+ * The subquery is a plain SELECT with no LIMIT / DISTINCT / aggregate, so
1376
+ * Postgres pulls it up into the outer join rather than materializing it.
1377
+ */
1378
+ /** Suffix for the inner (real-table) alias inside a flattened derived table. */
1379
+ const FLATTEN_SRC_SUFFIX = 's';
1380
+ /** Alias prefix for a top-level node's exposed correlation columns. */
1381
+ const FLATTEN_CORR = '$c';
1382
+ /**
1383
+ * Maximum SELECT-list alias length. Postgres truncates identifiers at 63 bytes
1384
+ * (MySQL/SQLite are more generous), and a truncated alias could collide with
1385
+ * another projected column, so a relation whose prefixed projection would
1386
+ * exceed it is ineligible and falls back to the correlated subquery.
1387
+ */
1388
+ const FLATTEN_MAX_ALIAS_LEN = 63;
1389
+ /** Nesting depth past which a flattened subtree declines (mirrors the join cap). */
1390
+ const FLATTEN_MAX_DEPTH = 10;
1391
+ /** Suffix of the non-null discriminator column projected for every flattened node. */
1392
+ const FLATTEN_DISCRIMINATOR = '$k';
1393
+ /** Set equality over two column lists (order-insensitive, duplicate-intolerant). */
1394
+ function sameColumnSet(a, b) {
1395
+ if (a.length === 0 || a.length !== b.length)
1396
+ return false;
1397
+ const set = new Set(a);
1398
+ if (set.size !== a.length)
1399
+ return false;
1400
+ for (const col of b)
1401
+ if (!set.has(col))
1402
+ return false;
1403
+ return true;
1404
+ }
1405
+ /**
1406
+ * The target-side correlation columns of a to-one relation, but ONLY when the
1407
+ * schema PROVES they are unique on the target table. This is the row-
1408
+ * multiplication guard: a LEFT JOIN over a non-unique key silently duplicates
1409
+ * parent rows, which would change results rather than just the plan.
1410
+ *
1411
+ * Proof sources, all exact set matches (a unique index on `(a, b)` does NOT
1412
+ * make `a` unique):
1413
+ * - the target's primary key,
1414
+ * - a declared unique constraint (`uniqueColumns`),
1415
+ * - a full, non-expression UNIQUE index. Partial unique indexes are refused
1416
+ * (they only constrain the rows matching their predicate) and PowDB
1417
+ * doc-field expression indexes are refused (they index a JSON path, not the
1418
+ * raw column).
1419
+ *
1420
+ * Returns `null` for to-many / manyToMany relations and for anything it cannot
1421
+ * prove, which routes the relation back to the correlated subquery.
1422
+ *
1423
+ * NULLs need no special handling: a unique constraint permits repeated NULLs,
1424
+ * but `target.key = parent.fk` never matches a NULL key, so a null-keyed target
1425
+ * row can never join.
1426
+ */
1427
+ function provableUniqueTargetKey(relDef, targetMeta) {
1428
+ let cols;
1429
+ if (relDef.type === 'belongsTo') {
1430
+ // belongsTo: SOURCE holds the FK, so the join is target.referenceKey = parent.foreignKey.
1431
+ cols = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
1432
+ }
1433
+ else if (relDef.type === 'hasOne') {
1434
+ // hasOne: TARGET holds the FK, so the join is target.foreignKey = parent.referenceKey.
1435
+ cols = (0, schema_js_1.normalizeKeyColumns)(relDef.foreignKey);
1436
+ }
1437
+ else {
1438
+ return null;
1439
+ }
1440
+ if (cols.length === 0)
1441
+ return null;
1442
+ if (!cols.every((col) => targetMeta.allColumns.includes(col)))
1443
+ return null;
1444
+ if (sameColumnSet(cols, targetMeta.primaryKey))
1445
+ return cols;
1446
+ for (const unique of targetMeta.uniqueColumns) {
1447
+ if (sameColumnSet(cols, unique))
1448
+ return cols;
1449
+ }
1450
+ for (const idx of targetMeta.indexes) {
1451
+ if (!idx.unique || idx.partial || idx.docPath)
1452
+ continue;
1453
+ if (sameColumnSet(cols, idx.columns))
1454
+ return cols;
1455
+ }
1456
+ return null;
1457
+ }
1458
+ /**
1459
+ * Plan one relation as a flattened LEFT JOIN, or return `null` to leave it on
1460
+ * the correlated-subquery path.
1461
+ *
1462
+ * A relation is ELIGIBLE when all of the following hold:
1463
+ * 1. it is `belongsTo` or `hasOne` AND its target-side correlation columns are
1464
+ * provably unique ({@link provableUniqueTargetKey}) — the row-multiplication
1465
+ * guard;
1466
+ * 2. its spec declares no `limit` and no `orderBy` (both are no-ops over a
1467
+ * single matching row, but refusing them keeps the emitted SQL and the
1468
+ * param stream trivially equivalent);
1469
+ * 3. its nested `with` names only real relations and no reserved `_count`
1470
+ * (nested `_count` is unsupported on every strategy — falling back lets the
1471
+ * subquery path raise the same error);
1472
+ * 4. the depth cap is not reached, so a too-deep chain still raises
1473
+ * {@link CircularRelationError} from the subquery path instead of silently
1474
+ * succeeding;
1475
+ * 5. its projected aliases are unique within the node and within the identifier
1476
+ * length limit.
1477
+ *
1478
+ * Relation `where` IS supported: it moves into the join's `ON` clause, where a
1479
+ * non-matching row null-extends exactly as the subquery's `LIMIT 1` returned
1480
+ * NULL. The target's global filter is applied the same way, so a flattened
1481
+ * relation can never surface soft-deleted / out-of-tenant rows.
1482
+ */
1483
+ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includePii, aliasSink, rejects) {
1484
+ /** Record why this relation stays on the subquery path, then decline it. */
1485
+ const decline = (reason) => {
1486
+ rejects.push({ relation: [...path.slice(1), relName].join('.'), reason });
1487
+ return null;
1488
+ };
1489
+ if (depth >= FLATTEN_MAX_DEPTH)
1490
+ return decline(`it nests more than ${FLATTEN_MAX_DEPTH} levels deep`);
1491
+ const targetMeta = qi.schema.tables[relDef.to];
1492
+ if (!targetMeta)
1493
+ return decline(`its target table "${relDef.to}" is not in the schema metadata`);
1494
+ const key = provableUniqueTargetKey(relDef, targetMeta);
1495
+ if (!key) {
1496
+ return decline(relDef.type !== 'belongsTo' && relDef.type !== 'hasOne'
1497
+ ? `it is ${relDef.type}, and only a to-one relation can be flattened into a join`
1498
+ : `its correlation column(s) on "${relDef.to}" are not provably unique (no primary key, ` +
1499
+ 'unique constraint or non-partial unique index covers them), so a join could multiply parent rows');
1500
+ }
1501
+ const opts = spec === true ? undefined : spec;
1502
+ if (opts) {
1503
+ if (opts.limit !== undefined)
1504
+ return decline('it declares a `limit`');
1505
+ if (opts.orderBy && (0, filters_js_1.orderByEntries)(opts.orderBy).some(([, dir]) => dir !== undefined)) {
1506
+ return decline('it declares an `orderBy`');
1507
+ }
1508
+ }
1509
+ const nestedEntries = opts?.with ? (0, filters_js_1.sortedEntries)(opts.with) : [];
1510
+ for (const [nestedRelName] of nestedEntries) {
1511
+ if (nestedRelName === '_count')
1512
+ return decline('its nested `with` uses `_count`');
1513
+ if (!(0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName)) {
1514
+ return decline(`its nested \`with\` names "${nestedRelName}", which is not a relation of "${relDef.to}"`);
1515
+ }
1516
+ }
1517
+ const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
1518
+ const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
1519
+ const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
1520
+ const node = {
1521
+ relName,
1522
+ relDef,
1523
+ spec,
1524
+ targetTable: relDef.to,
1525
+ targetMeta,
1526
+ alias,
1527
+ srcAlias: `${alias}${FLATTEN_SRC_SUFFIX}`,
1528
+ keyColumns: key,
1529
+ discAlias,
1530
+ cols: cols.map((col) => [col, `${alias}__${col}`]),
1531
+ slots: [],
1532
+ children: {},
1533
+ jsonAliases: {},
1534
+ corrAliases: depth === 0 ? key.map((_, i) => `${alias}__${FLATTEN_CORR}${i}`) : [],
1535
+ depth,
1536
+ path,
1537
+ };
1538
+ // Local alias uniqueness: a target whose relation name equals one of its own
1539
+ // column names ("owner" the column and "owner" the relation) would otherwise
1540
+ // project two `f0__owner` columns and silently clobber one.
1541
+ const local = [discAlias, ...node.corrAliases, ...node.cols.map(([, a]) => a)];
1542
+ for (const [nestedRelName] of nestedEntries)
1543
+ local.push(`${alias}__${nestedRelName}`);
1544
+ if (new Set(local).size !== local.length) {
1545
+ return decline('a projected column alias would collide (a relation name matches one of its own column names)');
1546
+ }
1547
+ if (local.some((a) => a.length > FLATTEN_MAX_ALIAS_LEN)) {
1548
+ return decline(`a projected column alias would exceed ${FLATTEN_MAX_ALIAS_LEN} characters`);
1549
+ }
1550
+ for (const [nestedRelName, nestedSpec] of nestedEntries) {
1551
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
1552
+ const child = planFlattenNode(qi, counter, nestedRelName, nestedRelDef, nestedSpec, depth + 1, [...path, relName], includePii, aliasSink, rejects);
1553
+ if (child) {
1554
+ node.slots.push({ relName: nestedRelName, kind: 'flat' });
1555
+ node.children[nestedRelName] = child;
1556
+ }
1557
+ else {
1558
+ node.slots.push({ relName: nestedRelName, kind: 'json' });
1559
+ node.jsonAliases[nestedRelName] = `${alias}__${nestedRelName}`;
1560
+ }
1561
+ }
1562
+ aliasSink.push(discAlias, ...node.cols.map(([, a]) => a), ...Object.values(node.jsonAliases));
1563
+ return node;
1564
+ }
1565
+ /** Structural signature of one node, recursively. Feeds the SQL cache key. */
1566
+ function flattenNodeSignature(node) {
1567
+ const slots = node.slots
1568
+ .map((slot) => slot.kind === 'flat' ? `+${flattenNodeSignature(node.children[slot.relName])}` : `~${slot.relName}`)
1569
+ .join('');
1570
+ return `${node.relName}@${node.alias}(${node.cols.map(([col]) => col).join(',')})${slots}`;
1571
+ }
1572
+ /**
1573
+ * Dev-only, once-per-(table, relation, reason) note that an explicitly
1574
+ * requested `relationLoadStrategy: 'flatten'` did NOT engage for a relation.
1575
+ *
1576
+ * Worth its own warning because the fallback is otherwise INVISIBLE: the query
1577
+ * succeeds, the rows are correct (the strategies are value-identical), and the
1578
+ * only symptom is that the plan the caller asked for is not the plan that ran.
1579
+ * Silence there is indistinguishable from the strategy being a no-op, which is
1580
+ * exactly the shape of thing that gets filed as a bug.
1581
+ *
1582
+ * Suppressed in production like the other planner notes, and deduped through
1583
+ * the shared registry so a hot query logs once rather than per call. Note that
1584
+ * a flatten plan is recomputed on the build, param-collect and row-assembly
1585
+ * paths for the same query; the registry is what keeps that to one line.
1586
+ */
1587
+ function warnFlattenFallback(table, rejects) {
1588
+ if (rejects.length === 0)
1589
+ return;
1590
+ if (process.env.NODE_ENV === 'production')
1591
+ return;
1592
+ for (const { relation, reason } of rejects) {
1593
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.flattenFallback, `${table}.${relation}|${reason}`))
1594
+ continue;
1595
+ console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage for relation "${relation}" on "${table}": ` +
1596
+ `${reason}. It loads via the correlated subquery instead (same rows, same values, different plan).`);
1597
+ }
1598
+ }
1599
+ /**
1600
+ * Compile the `'flatten'` plan for a top-level `with` clause, or return `null`
1601
+ * when nothing in it is eligible (in which case the caller emits exactly the SQL
1602
+ * it emits today, down to the cache key).
1603
+ *
1604
+ * The plan is a pure function of the schema, the `with` clause shape and
1605
+ * `includePii` — never of any bound value — so the build path, the cache-hit
1606
+ * param-collect path and the row assembler can each recompute it and agree.
1607
+ */
1608
+ function planFlattenWith(qi, table, withClause, includePii) {
1609
+ const meta = qi.schema.tables[table];
1610
+ if (!meta)
1611
+ return null;
1612
+ const counter = { n: 0 };
1613
+ const aliases = [];
1614
+ const nodes = {};
1615
+ const rejects = [];
1616
+ for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
1617
+ if (relName === '_count')
1618
+ continue;
1619
+ const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
1620
+ // An unknown relation stays on the subquery path so it raises E005 there.
1621
+ if (!relDef)
1622
+ continue;
1623
+ const node = planFlattenNode(qi, counter, relName, relDef, relSpec, 0, [table], includePii, aliases, rejects);
1624
+ if (node)
1625
+ nodes[relName] = node;
1626
+ }
1627
+ warnFlattenFallback(table, rejects);
1628
+ if (Object.keys(nodes).length === 0)
1629
+ return null;
1630
+ // Flat rows carry the root table's own columns, one column per non-flattened
1631
+ // relation (named for the relation) and `_count__<rel>` scalars alongside the
1632
+ // prefixed aliases. A collision would make the assembler read the wrong cell,
1633
+ // so refuse the whole plan rather than plan around it.
1634
+ const reserved = new Set([...meta.allColumns, ...Object.keys(withClause)]);
1635
+ for (const alias of aliases) {
1636
+ if (reserved.has(alias)) {
1637
+ warnFlattenFallback(table, [
1638
+ {
1639
+ relation: Object.keys(nodes).join(', '),
1640
+ reason: `the projected alias "${alias}" collides with a column or relation name on "${table}"`,
1641
+ },
1642
+ ]);
1643
+ return null;
1644
+ }
1645
+ }
1646
+ const signature = Object.keys(nodes)
1647
+ .sort()
1648
+ .map((relName) => flattenNodeSignature(nodes[relName]))
1649
+ .join('|');
1650
+ return { nodes, aliases, signature };
1651
+ }
1652
+ /**
1653
+ * Compile the correlation of a to-one relation as a join condition:
1654
+ * - `belongsTo` → `target.referenceKey = parent.foreignKey` (SOURCE holds the FK)
1655
+ * - `hasOne` → `target.foreignKey = parent.referenceKey` (TARGET holds the FK)
1656
+ *
1657
+ * Same column pairing {@link buildRelationSubquery} correlates on; getting it
1658
+ * backwards silently compares the wrong columns.
1659
+ */
1660
+ function flattenCorrelation(qi, relDef, targetRef, targetColumns, parentRef) {
1661
+ const parentColumns = relDef.type === 'belongsTo' ? relDef.foreignKey : relDef.referenceKey;
1662
+ return qi.dialect.buildCorrelation(targetRef, targetColumns, parentRef, parentColumns);
1663
+ }
1664
+ /**
1665
+ * Emit one TOP-LEVEL flattened relation: a derived table holding its whole
1666
+ * to-one subtree, joined to the parent on the re-exposed correlation columns,
1667
+ * plus the pass-through projection of every name that subtree contributes.
1668
+ *
1669
+ * ```sql
1670
+ * LEFT JOIN (
1671
+ * SELECT 1 AS "f0__$k", f0s."id" AS "f0__$c0",
1672
+ * f0s."id" AS "f0__id", f0s."name" AS "f0__name",
1673
+ * (f1s."id" IS NOT NULL) AS "f1__$k", f1s."code" AS "f1__code"
1674
+ * FROM "orgs" f0s
1675
+ * LEFT JOIN "regions" f1s ON f1s."id" = f0s."region_id"
1676
+ * WHERE f0s."deleted" = $1
1677
+ * ) f0 ON f0."f0__$c0" = "users"."org_id"
1678
+ * ```
1679
+ *
1680
+ * See the derived-table note above for why the join cannot expose the target's
1681
+ * real column names.
1682
+ */
1683
+ function emitFlattenNode(qi, node, parentRef, params, joinSink, selectSink, aliasCounter, includePii) {
1684
+ const innerSelects = [];
1685
+ const innerJoins = [];
1686
+ const innerWhere = [];
1687
+ emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere, aliasCounter, includePii);
1688
+ const whereSql = innerWhere.length > 0 ? ` WHERE ${innerWhere.join(' AND ')}` : '';
1689
+ const derived = `SELECT ${innerSelects.join(', ')} FROM ${qi.q(node.targetTable)} ${node.srcAlias}${innerJoins.join('')}${whereSql}`;
1690
+ const on = flattenCorrelation(qi, node.relDef, node.alias, node.corrAliases, parentRef);
1691
+ joinSink.push(` LEFT JOIN (${derived}) ${node.alias} ON ${on}`);
1692
+ projectFlattenNode(qi, node, node.alias, selectSink);
1693
+ }
1694
+ /**
1695
+ * Build one node's contribution INSIDE its top-level derived table: its filters,
1696
+ * its prefixed column projection, and its nested slots (another inner LEFT JOIN
1697
+ * for a flattened child, a correlated subquery column for anything else).
1698
+ *
1699
+ * Param push order, mirrored exactly by {@link collectFlattenNodeParams}:
1700
+ * relation `where` → target global filter → each nested slot in `slots` order.
1701
+ */
1702
+ function emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere, aliasCounter, includePii) {
1703
+ const { srcAlias, targetTable, targetMeta } = node;
1704
+ const isRoot = node.depth === 0;
1705
+ // A root node's filters go in the derived table's WHERE; a nested node's go in
1706
+ // its own inner LEFT JOIN's ON. Both null-extend the relation on a miss rather
1707
+ // than dropping the parent row, matching what the correlated subquery's
1708
+ // `LIMIT 1` did when it returned NULL.
1709
+ const filters = innerWhere;
1710
+ if (node.spec !== true && node.spec.where) {
1711
+ const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, srcAlias, node.spec.where, params);
1712
+ if (extra)
1713
+ filters.push(extra);
1714
+ }
1715
+ // Global filter on the target (soft-delete / tenancy): a flattened relation
1716
+ // must never surface rows the join strategy would have filtered out.
1717
+ const gf = whereMod.targetGlobalFilterAlias(qi, targetTable, srcAlias, params);
1718
+ if (gf)
1719
+ filters.push(gf);
1720
+ if (isRoot) {
1721
+ // The derived table's rows exist only where the target matched, so a
1722
+ // constant marks the match; the outer LEFT JOIN nulls it on a miss.
1723
+ innerSelects.push(`1 AS ${qi.q(node.discAlias)}`);
1724
+ node.keyColumns.forEach((col, i) => {
1725
+ innerSelects.push(`${srcAlias}.${qi.q(col)} AS ${qi.q(node.corrAliases[i])}`);
1726
+ });
1727
+ }
1728
+ else {
1729
+ innerSelects.push(`(${srcAlias}.${qi.q(node.keyColumns[0])} IS NOT NULL) AS ${qi.q(node.discAlias)}`);
1730
+ }
1731
+ for (const [col, sqlAlias] of node.cols) {
1732
+ innerSelects.push(`${srcAlias}.${qi.q(col)} AS ${qi.q(sqlAlias)}`);
1733
+ }
1734
+ for (const slot of node.slots) {
1735
+ if (slot.kind === 'flat') {
1736
+ const child = node.children[slot.relName];
1737
+ const on = flattenCorrelation(qi, child.relDef, child.srcAlias, child.keyColumns, srcAlias);
1738
+ // Placeholder: the child's own filters are appended to this ON below, so
1739
+ // reserve the slot now to keep join order matching slot order.
1740
+ const at = innerJoins.length;
1741
+ innerJoins.push('');
1742
+ const childFilters = [];
1743
+ emitFlattenInner(qi, child, params, innerSelects, innerJoins, childFilters, aliasCounter, includePii);
1744
+ const conds = [on, ...childFilters].join(' AND ');
1745
+ innerJoins[at] = ` LEFT JOIN ${qi.q(child.targetTable)} ${child.srcAlias} ON ${conds}`;
1746
+ continue;
1747
+ }
1748
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, slot.relName);
1749
+ const nestedSpec = node.spec.with[slot.relName];
1750
+ const sub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, srcAlias, aliasCounter, node.depth + 1, [...node.path, node.relName], includePii);
1751
+ // Same fallback the subquery path picks for a nested relation slot.
1752
+ const fallback = nestedRelDef.type === 'hasMany' ? qi.dialect.emptyJsonArrayLiteral : qi.dialect.nullJsonLiteral;
1753
+ innerSelects.push(`${qi.dialect.wrapJsonSubresult(sub, fallback)} AS ${qi.q(node.jsonAliases[slot.relName])}`);
1754
+ }
1755
+ }
1756
+ /**
1757
+ * Pass every name a flattened subtree contributes through the outer SELECT, in
1758
+ * the same order {@link emitFlattenInner} produced it (which is the order the
1759
+ * join strategy's `json_build_object` uses, so assembled key order matches).
1760
+ * The internal `$c` correlation columns are deliberately NOT projected: they
1761
+ * exist only for the outer join condition.
1762
+ */
1763
+ function projectFlattenNode(qi, node, outerAlias, selectSink) {
1764
+ const ref = (name) => `${qi.q(outerAlias)}.${qi.q(name)}`;
1765
+ selectSink.push(ref(node.discAlias));
1766
+ for (const [, sqlAlias] of node.cols)
1767
+ selectSink.push(ref(sqlAlias));
1768
+ for (const slot of node.slots) {
1769
+ if (slot.kind === 'flat')
1770
+ projectFlattenNode(qi, node.children[slot.relName], outerAlias, selectSink);
1771
+ else
1772
+ selectSink.push(ref(node.jsonAliases[slot.relName]));
1773
+ }
1774
+ }
1775
+ /** Param-collect mirror of {@link emitFlattenNode}. */
1776
+ function collectFlattenNodeParams(qi, node, params) {
1777
+ if (node.spec !== true && node.spec.where) {
1778
+ whereMod.collectAliasWhereParams(qi, node.targetTable, node.targetMeta, node.spec.where, params);
1779
+ }
1780
+ whereMod.collectTargetGlobalFilterAlias(qi, node.targetTable, params);
1781
+ for (const slot of node.slots) {
1782
+ if (slot.kind === 'flat') {
1783
+ collectFlattenNodeParams(qi, node.children[slot.relName], params);
1784
+ continue;
1785
+ }
1786
+ const nestedRelDef = (0, utils_js_1.ownLookup)(node.targetMeta.relations, slot.relName);
1787
+ if (!nestedRelDef)
1788
+ continue;
1789
+ const nestedSpec = node.spec.with[slot.relName];
1790
+ collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, node.alias, node.depth + 1);
1791
+ }
1792
+ }
1793
+ /**
1794
+ * Rebuild one flattened relation's object from a flat row.
1795
+ *
1796
+ * Returns `null` when the discriminator is NULL (the LEFT JOIN matched
1797
+ * nothing), which is the ONLY signal that distinguishes "no related row" from
1798
+ * "a related row whose every projected column is NULL".
1799
+ *
1800
+ * The sub-row is keyed by the target's raw snake_case column names and handed
1801
+ * to {@link parseNestedRow}, so the flattened object goes through the very same
1802
+ * camelCase mapping, Date coercion and nested-JSON parsing the join strategy's
1803
+ * `json_build_object` output does. Nested flattened relations are assigned
1804
+ * after the parse, over placeholder keys inserted in slot order, so the
1805
+ * assembled object's key order matches the join strategy's exactly.
1806
+ */
1807
+ function assembleFlattenNode(qi, row, node) {
1808
+ // The discriminator is the projected predicate `key IS NOT NULL`, so its
1809
+ // truthy encodings are engine-specific (Postgres booleans, MySQL/SQLite 0/1,
1810
+ // and a text-mode driver's 't'/'1'). Whitelist the matched forms: an
1811
+ // unrecognized encoding reads as "no related row", which the cross-engine
1812
+ // parity suites surface loudly rather than as scattered wrong objects.
1813
+ const disc = row[node.discAlias];
1814
+ const matched = disc === true || disc === 1 || disc === '1' || disc === 't' || disc === 'true';
1815
+ if (!matched)
1816
+ return null;
1817
+ const sub = {};
1818
+ for (const [col, sqlAlias] of node.cols)
1819
+ sub[col] = row[sqlAlias];
1820
+ for (const slot of node.slots) {
1821
+ // A 'flat' slot is a placeholder here: parseNestedRow skips `undefined`
1822
+ // relation values, so the key keeps its position and is filled in below.
1823
+ sub[slot.relName] = slot.kind === 'json' ? row[node.jsonAliases[slot.relName]] : undefined;
1824
+ }
1825
+ const parsed = parseNestedRow(qi, sub, node.targetTable);
1826
+ for (const slot of node.slots) {
1827
+ if (slot.kind === 'flat')
1828
+ parsed[slot.relName] = assembleFlattenNode(qi, row, node.children[slot.relName]);
1829
+ }
1830
+ return parsed;
1831
+ }
1832
+ /**
1833
+ * Row parser for a `'flatten'` plan: strip the prefixed join columns out of the
1834
+ * flat row (inserting a placeholder at the position each relation's block
1835
+ * started, so key order is preserved), parse the remainder exactly as the join
1836
+ * strategy does, then assemble each flattened relation.
1837
+ */
1838
+ function makeFlattenParser(qi, plan) {
1839
+ const flatAliases = new Set(plan.aliases);
1840
+ // The discriminator is emitted FIRST for each node, so it marks where a
1841
+ // relation's block begins in the flat row's key order.
1842
+ const relationByLeadAlias = new Map();
1843
+ for (const [relName, node] of Object.entries(plan.nodes))
1844
+ relationByLeadAlias.set(node.discAlias, relName);
1845
+ return (row) => {
1846
+ const stripped = {};
1847
+ for (const key of Object.keys(row)) {
1848
+ if (!flatAliases.has(key)) {
1849
+ stripped[key] = row[key];
1850
+ continue;
1851
+ }
1852
+ const relName = relationByLeadAlias.get(key);
1853
+ if (relName !== undefined)
1854
+ stripped[relName] = undefined;
1855
+ }
1856
+ const parsed = parseNestedRow(qi, stripped, qi.table);
1857
+ for (const [relName, node] of Object.entries(plan.nodes)) {
1858
+ parsed[relName] = assembleFlattenNode(qi, row, node);
1859
+ }
1860
+ return parsed;
1861
+ };
1862
+ }
1212
1863
  /**
1213
1864
  * Build a SELECT clause that includes both base columns and nested relation subqueries.
1214
1865
  *
@@ -1250,7 +1901,7 @@ function decodePositionalObject(qi, arr, shape) {
1250
1901
  * @returns A complete SELECT clause string (without the `SELECT` keyword) containing
1251
1902
  * base columns and relation subqueries.
1252
1903
  */
1253
- function buildSelectWithRelations(qi, table, withClause, params, columnsList, depth, path, includePii) {
1904
+ function buildSelectWithRelations(qi, table, withClause, params, columnsList, depth, path, includePii, flatten) {
1254
1905
  const meta = qi.schema.tables[table];
1255
1906
  if (!meta)
1256
1907
  throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}"`);
@@ -1270,11 +1921,19 @@ function buildSelectWithRelations(qi, table, withClause, params, columnsList, de
1270
1921
  // `_count` is a reserved key handled after the relation subqueries.
1271
1922
  if (relName === '_count')
1272
1923
  continue;
1273
- const relDef = meta.relations[relName];
1924
+ const relDef = (0, utils_js_1.ownLookup)(meta.relations, relName);
1274
1925
  if (!relDef) {
1275
1926
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
1276
1927
  `Available: ${Object.keys(meta.relations).join(', ')}`);
1277
1928
  }
1929
+ // `relationLoadStrategy: 'flatten'`: an eligible to-one relation becomes a
1930
+ // LEFT JOIN + prefixed scalar projection instead of a per-parent-row
1931
+ // correlated subquery. Every other relation falls through unchanged.
1932
+ const flatNode = flatten?.plan.nodes[relName];
1933
+ if (flatNode) {
1934
+ emitFlattenNode(qi, flatNode, qi.q(table), params, flatten.joinSink, relationSelects, aliasCounter, includePii);
1935
+ continue;
1936
+ }
1278
1937
  // The main table is not aliased, so pass table name as parentRef
1279
1938
  const subquery = buildRelationSubquery(qi, relDef, relSpec, params, table, aliasCounter, depth, path, includePii);
1280
1939
  relationSelects.push(`(${subquery}) AS ${qi.q(relName)}`);
@@ -1454,10 +2113,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
1454
2113
  });
1455
2114
  }
1456
2115
  // Build JSON object pairs for resolved columns
1457
- const jsonPairs = targetColumns.map((col) => [
1458
- targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
1459
- `${alias}.${qi.q(col)}`,
1460
- ]);
2116
+ const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, alias);
1461
2117
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
1462
2118
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
1463
2119
  // so we must NOT build them here (they would push orphaned params).
@@ -1475,7 +2131,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
1475
2131
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
1476
2132
  if (!willWrap && spec !== true && spec.with) {
1477
2133
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1478
- const nestedRelDef = targetMeta.relations[nestedRelName];
2134
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
1479
2135
  if (!nestedRelDef) {
1480
2136
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1481
2137
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
@@ -1534,7 +2190,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
1534
2190
  // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
1535
2191
  let limitClause = '';
1536
2192
  if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
1537
- limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
2193
+ limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
1538
2194
  }
1539
2195
  if (relDef.type === 'hasMany') {
1540
2196
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -1545,14 +2201,11 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
1545
2201
  // Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
1546
2202
  const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${qi.q(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
1547
2203
  // For the json_build_object, reference the inner alias — only include resolved columns
1548
- const innerJsonPairs = targetColumns.map((col) => [
1549
- targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
1550
- `${innerAlias}.${qi.q(col)}`,
1551
- ]);
2204
+ const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
1552
2205
  // Build nested relation subqueries referencing innerAlias
1553
2206
  if (spec !== true && spec.with) {
1554
2207
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1555
- const nestedRelDef = targetMeta.relations[nestedRelName];
2208
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
1556
2209
  if (!nestedRelDef) {
1557
2210
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1558
2211
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
@@ -1655,7 +2308,7 @@ function buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCount
1655
2308
  // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
1656
2309
  let limitClause = '';
1657
2310
  if (spec !== true && spec.limit !== undefined) {
1658
- limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
2311
+ limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
1659
2312
  }
1660
2313
  const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
1661
2314
  // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
@@ -1664,14 +2317,11 @@ function buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCount
1664
2317
  const innerAlias = `${talias}i`;
1665
2318
  const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${qi.q(c)}`).join(', ')} ` +
1666
2319
  `${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
1667
- const innerJsonPairs = targetColumns.map((col) => [
1668
- targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
1669
- `${innerAlias}.${qi.q(col)}`,
1670
- ]);
2320
+ const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
1671
2321
  // Nested relations reference the inner alias.
1672
2322
  if (spec !== true && spec.with) {
1673
2323
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1674
- const nestedRelDef = targetMeta.relations[nestedRelName];
2324
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
1675
2325
  if (!nestedRelDef) {
1676
2326
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1677
2327
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
@@ -1688,13 +2338,10 @@ function buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCount
1688
2338
  }
1689
2339
  // Simple path: build the json object pairs directly off the target alias,
1690
2340
  // including any nested relations (correlated to the target alias).
1691
- const jsonPairs = targetColumns.map((col) => [
1692
- targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
1693
- `${talias}.${qi.q(col)}`,
1694
- ]);
2341
+ const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, talias);
1695
2342
  if (spec !== true && spec.with) {
1696
2343
  for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1697
- const nestedRelDef = targetMeta.relations[nestedRelName];
2344
+ const nestedRelDef = (0, utils_js_1.ownLookup)(targetMeta.relations, nestedRelName);
1698
2345
  if (!nestedRelDef) {
1699
2346
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1700
2347
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);