turbine-orm 0.35.0 → 0.36.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 (68) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/dialect.js +1 -1
  8. package/dist/cjs/generate.js +23 -2
  9. package/dist/cjs/index.js +2 -1
  10. package/dist/cjs/mssql.js +22 -5
  11. package/dist/cjs/powdb.js +41 -1
  12. package/dist/cjs/powql.js +80 -25
  13. package/dist/cjs/query/aggregates.js +683 -0
  14. package/dist/cjs/query/batched-loader.js +2 -0
  15. package/dist/cjs/query/builder.js +297 -4504
  16. package/dist/cjs/query/filters.js +12 -0
  17. package/dist/cjs/query/relations.js +1698 -0
  18. package/dist/cjs/query/where-compile.js +180 -0
  19. package/dist/cjs/query/where.js +1491 -0
  20. package/dist/cjs/query/writes.js +680 -0
  21. package/dist/cjs/schema-builder.js +6 -0
  22. package/dist/cjs/schema-metadata.js +4 -0
  23. package/dist/cjs/schema-sql.js +265 -3
  24. package/dist/cjs/sqlite.js +1 -1
  25. package/dist/cli/index.d.ts +8 -2
  26. package/dist/cli/index.js +111 -18
  27. package/dist/cli/migrate.d.ts +24 -1
  28. package/dist/cli/migrate.js +77 -3
  29. package/dist/cli/studio-ui.generated.js +1 -1
  30. package/dist/cli/studio.d.ts +46 -13
  31. package/dist/cli/studio.js +331 -23
  32. package/dist/cli/ui.js +7 -1
  33. package/dist/dialect.d.ts +15 -6
  34. package/dist/dialect.js +1 -1
  35. package/dist/generate.js +23 -2
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/mssql.js +22 -5
  39. package/dist/powdb.d.ts +20 -0
  40. package/dist/powdb.js +40 -0
  41. package/dist/powql.d.ts +33 -1
  42. package/dist/powql.js +80 -25
  43. package/dist/query/aggregates.d.ts +74 -0
  44. package/dist/query/aggregates.js +641 -0
  45. package/dist/query/batched-loader.d.ts +6 -0
  46. package/dist/query/batched-loader.js +2 -0
  47. package/dist/query/builder.d.ts +62 -829
  48. package/dist/query/builder.js +302 -4509
  49. package/dist/query/deferred.d.ts +7 -0
  50. package/dist/query/filters.d.ts +7 -0
  51. package/dist/query/filters.js +11 -0
  52. package/dist/query/relations.d.ts +441 -0
  53. package/dist/query/relations.js +1627 -0
  54. package/dist/query/types.d.ts +15 -0
  55. package/dist/query/where-compile.d.ts +139 -0
  56. package/dist/query/where-compile.js +175 -0
  57. package/dist/query/where.d.ts +494 -0
  58. package/dist/query/where.js +1431 -0
  59. package/dist/query/writes.d.ts +131 -0
  60. package/dist/query/writes.js +626 -0
  61. package/dist/schema-builder.d.ts +18 -3
  62. package/dist/schema-builder.js +6 -0
  63. package/dist/schema-metadata.js +4 -0
  64. package/dist/schema-sql.d.ts +60 -3
  65. package/dist/schema-sql.js +261 -4
  66. package/dist/schema.d.ts +10 -0
  67. package/dist/sqlite.js +1 -1
  68. package/package.json +2 -2
@@ -0,0 +1,1698 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm: relation + orderBy compilation (extracted from builder.ts)
4
+ *
5
+ * The json_agg nested-relation machinery (buildSelectWithRelations,
6
+ * buildRelationSubquery, buildManyToManySubquery), the positional-encoding
7
+ * shapes + nested-row parser, the full orderBy surface (plain / JSON-path /
8
+ * vector KNN / relation _count / pick-row), relation _count expressions and
9
+ * their global-filter params, and the with-clause fingerprint + param
10
+ * collectors. All functions take a {@link BuilderCtx} first argument; WHERE
11
+ * compilation is reused from where.ts (whereMod.*), the PII column set from
12
+ * writes.ts (writesMod.*), and the remaining primitives stay class-resident,
13
+ * reached through the ctx. See builder.ts for the thin delegating methods and
14
+ * the findMany/findUnique execute assembly.
15
+ */
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.resolveColumns = resolveColumns;
51
+ exports.withFingerprint = withFingerprint;
52
+ exports.collectWithParams = collectWithParams;
53
+ exports.collectRelationSubqueryParams = collectRelationSubqueryParams;
54
+ exports.orderByEntryFingerprint = orderByEntryFingerprint;
55
+ exports.buildOrderBy = buildOrderBy;
56
+ exports.isRelationOrderByValue = isRelationOrderByValue;
57
+ exports.nullsSuffix = nullsSuffix;
58
+ exports.resolveOrderByColumn = resolveOrderByColumn;
59
+ exports.validateJsonPathOrderBy = validateJsonPathOrderBy;
60
+ exports.buildJsonPathOrderEntry = buildJsonPathOrderEntry;
61
+ exports.buildRelationOrderBy = buildRelationOrderBy;
62
+ exports.pickOrderNestedError = pickOrderNestedError;
63
+ exports.validatePickOrderBy = validatePickOrderBy;
64
+ exports.buildRelationPickOrderBy = buildRelationPickOrderBy;
65
+ exports.compilePickPieces = compilePickPieces;
66
+ exports.collectRelationPickOrderParams = collectRelationPickOrderParams;
67
+ exports.buildRelationOrderClause = buildRelationOrderClause;
68
+ exports.collectRelationOrderParams = collectRelationOrderParams;
69
+ exports.buildRelationCountExpr = buildRelationCountExpr;
70
+ exports.manyToManyTargetGlobalFilterExists = manyToManyTargetGlobalFilterExists;
71
+ exports.collectManyToManyTargetGlobalFilter = collectManyToManyTargetGlobalFilter;
72
+ exports.collectRelationCountParams = collectRelationCountParams;
73
+ exports.getCamelDateFields = getCamelDateFields;
74
+ exports.parseNestedRow = parseNestedRow;
75
+ exports.resolveTargetColumns = resolveTargetColumns;
76
+ exports.buildJsonRow = buildJsonRow;
77
+ exports.buildRelationShapes = buildRelationShapes;
78
+ exports.buildRelationShape = buildRelationShape;
79
+ exports.makeNestedParser = makeNestedParser;
80
+ exports.decodePositionalRelations = decodePositionalRelations;
81
+ exports.decodePositionalValue = decodePositionalValue;
82
+ exports.decodePositionalObject = decodePositionalObject;
83
+ exports.buildSelectWithRelations = buildSelectWithRelations;
84
+ exports.buildRelationSubquery = buildRelationSubquery;
85
+ exports.buildManyToManySubquery = buildManyToManySubquery;
86
+ const errors_js_1 = require("../errors.js");
87
+ const index_advisor_js_1 = require("../index-advisor.js");
88
+ const schema_js_1 = require("../schema.js");
89
+ const batched_loader_js_1 = require("./batched-loader.js");
90
+ const filters_js_1 = require("./filters.js");
91
+ const whereMod = __importStar(require("./where.js"));
92
+ const writesMod = __importStar(require("./writes.js"));
93
+ /** Relations already warned about missing FK indexes (once per process, dev only). */
94
+ const unindexedRelationWarned = new Set();
95
+ /**
96
+ * Resolve select/omit options into a list of snake_case column names.
97
+ * Returns null if neither is provided (meaning all columns).
98
+ */
99
+ function resolveColumns(qi, select, omit, includePii) {
100
+ if (select) {
101
+ // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
102
+ // style) instead of the object shape. Object.entries() would iterate the
103
+ // numeric indices and throw a cryptic `Unknown field "0"` — catch it early
104
+ // with an actionable message.
105
+ if (Array.isArray(select)) {
106
+ throw new errors_js_1.ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
107
+ `(e.g. { id: true, name: true }), not an array.`);
108
+ }
109
+ // Only include columns where value is true. An explicit `select` naming a
110
+ // PII column IS the opt-in: it comes back regardless of `includePii`.
111
+ return Object.entries(select)
112
+ .filter(([, v]) => v)
113
+ .map(([k]) => qi.toColumn(k));
114
+ }
115
+ // Default / omit-only projection: PII-tagged columns are excluded unless the
116
+ // caller passed `includePii: true`. An empty set (untagged schema) keeps the
117
+ // `null`/`*` fast path so the emitted SQL is byte-identical to before.
118
+ const piiCols = includePii ? undefined : writesMod.piiColumns(qi, qi.tableMeta);
119
+ const hasPii = piiCols !== undefined && piiCols.size > 0;
120
+ if (omit) {
121
+ if (Array.isArray(omit)) {
122
+ throw new errors_js_1.ValidationError(`[turbine] "omit" must be an object mapping field names to true ` + `(e.g. { createdAt: true }), not an array.`);
123
+ }
124
+ // Include all columns except those where value is true (and PII columns).
125
+ const omitCols = new Set(Object.entries(omit)
126
+ .filter(([, v]) => v)
127
+ .map(([k]) => qi.toColumn(k)));
128
+ return qi.tableMeta.allColumns.filter((col) => !omitCols.has(col) && !(hasPii && piiCols.has(col)));
129
+ }
130
+ if (hasPii) {
131
+ return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col));
132
+ }
133
+ return null;
134
+ }
135
+ /**
136
+ * Produce a fingerprint for a `with` clause tree. Recursion mirrors
137
+ * buildSelectWithRelations / buildRelationSubquery.
138
+ *
139
+ * @internal Exposed as package-private for testing.
140
+ */
141
+ function withFingerprint(qi, withClause, table, depth = 0) {
142
+ if (!withClause)
143
+ return '';
144
+ const meta = qi.schema.tables[table ?? qi.table];
145
+ if (!meta)
146
+ return '';
147
+ const relNames = Object.keys(withClause).sort();
148
+ const parts = [];
149
+ for (const relName of relNames) {
150
+ const spec = withClause[relName];
151
+ if (!spec)
152
+ continue;
153
+ // Reserved `_count` key — fingerprint by the selected relation set so
154
+ // `_count: true` and `_count: { posts: true }` never share a cache entry.
155
+ if (relName === '_count') {
156
+ const c = spec;
157
+ parts.push(c === true
158
+ ? '_count(*)'
159
+ : `_count(${Object.entries(c)
160
+ .filter(([, v]) => v)
161
+ .map(([k]) => k)
162
+ .sort()
163
+ .join(',')})`);
164
+ continue;
165
+ }
166
+ const relDef = meta.relations[relName];
167
+ if (!relDef) {
168
+ parts.push(`unknown:${relName}`);
169
+ continue;
170
+ }
171
+ if (spec === true) {
172
+ parts.push(relName);
173
+ continue;
174
+ }
175
+ const opts = spec;
176
+ const subParts = [];
177
+ // select/omit shape
178
+ if (opts.select) {
179
+ const selKeys = Object.entries(opts.select)
180
+ .filter(([, v]) => v)
181
+ .map(([k]) => k)
182
+ .sort();
183
+ subParts.push(`sl=${selKeys.join(',')}`);
184
+ }
185
+ if (opts.omit) {
186
+ const omKeys = Object.entries(opts.omit)
187
+ .filter(([, v]) => v)
188
+ .map(([k]) => k)
189
+ .sort();
190
+ subParts.push(`om=${omKeys.join(',')}`);
191
+ }
192
+ // where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
193
+ // `{title: {contains: 'x'}}` emit different SQL so they must not share
194
+ // a fingerprint)
195
+ if (opts.where) {
196
+ subParts.push(`w=${whereMod.fingerprintAliasWhere(qi, opts.where, meta.relations[relName]?.to)}`);
197
+ }
198
+ // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
199
+ if (opts.orderBy) {
200
+ const targetRels = qi.schema.tables[relDef.to]?.relations;
201
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${orderByEntryFingerprint(qi, d, targetRels?.[k]?.to)}`);
202
+ subParts.push(`o=${oEntries.join(',')}`);
203
+ }
204
+ // limit presence, but on inline-pagination engines (MySQL) the literal
205
+ // value is baked into the subquery SQL, so fingerprint the value there or
206
+ // `{limit:3}` and `{limit:5}` would share one cached statement.
207
+ if (opts.limit !== undefined) {
208
+ subParts.push(qi.dialect.inlineLimitOffset ? `l=${opts.limit}` : 'l=1');
209
+ }
210
+ // nested with (recurse)
211
+ if (opts.with) {
212
+ const nested = withFingerprint(qi, opts.with, relDef.to, depth + 1);
213
+ if (nested)
214
+ subParts.push(`W=(${nested})`);
215
+ }
216
+ parts.push(subParts.length > 0 ? `${relName}/{${subParts.join('/')}}` : relName);
217
+ }
218
+ return parts.join('|');
219
+ }
220
+ /**
221
+ * Collect params from a `with` clause tree. Mirrors buildSelectWithRelations +
222
+ * buildRelationSubquery param-push order.
223
+ */
224
+ function collectWithParams(qi, withClause, params, table) {
225
+ const meta = qi.schema.tables[table ?? qi.table];
226
+ if (!meta)
227
+ return;
228
+ for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
229
+ const relDef = meta.relations[relName];
230
+ if (!relDef)
231
+ continue;
232
+ collectRelationSubqueryParams(qi, relDef, relSpec, params, table ?? qi.table);
233
+ }
234
+ // `_count` global-filter params — mirror buildSelectWithRelations, which
235
+ // appends the count subqueries (and any target-filter params) AFTER every
236
+ // relation subquery, in resolveCountRelations order.
237
+ const countSpec = withClause._count;
238
+ if (countSpec !== undefined) {
239
+ for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
240
+ collectRelationCountParams(qi, rel, params);
241
+ }
242
+ }
243
+ }
244
+ /**
245
+ * Collect params from a single relation subquery. Mirrors buildRelationSubquery.
246
+ */
247
+ function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, depth = 0) {
248
+ if (spec === true)
249
+ return; // No params for default include
250
+ const targetTable = relDef.to;
251
+ const targetMeta = qi.schema.tables[targetTable];
252
+ if (!targetMeta)
253
+ return;
254
+ // A dialect that owns the whole subquery (buildRelationSubquery override,
255
+ // SQL Server FOR JSON) compiles orderBy through its OWN paging clause -
256
+ // plain directions only, no order params: so the native order-param
257
+ // mirrors below must stay off for it (its documented contract remains
258
+ // where → limit → nested).
259
+ const nativeOrderPath = !qi.dialect.buildRelationSubquery;
260
+ // manyToMany param order mirrors buildManyToManySubquery:
261
+ // orderBy params → where params → limit param → nested-with params
262
+ // (always, both paths).
263
+ if (relDef.type === 'manyToMany') {
264
+ const m2mOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
265
+ if (nativeOrderPath && m2mOrderEntries.length > 0) {
266
+ collectRelationOrderParams(qi, targetTable, targetMeta, m2mOrderEntries, params);
267
+ }
268
+ if (spec.where) {
269
+ whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, spec.where, params);
270
+ }
271
+ whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
272
+ if (spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
273
+ params.push(Number(spec.limit));
274
+ }
275
+ if (spec.with) {
276
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
277
+ const nestedRelDef = targetMeta.relations[nestedRelName];
278
+ if (!nestedRelDef)
279
+ continue;
280
+ collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
281
+ }
282
+ }
283
+ return;
284
+ }
285
+ // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
286
+ const relOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
287
+ const hasOrder = relOrderEntries.length > 0;
288
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
289
+ // Non-wrapped path: nested relations BEFORE where/limit
290
+ if (!willWrap && spec.with) {
291
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
292
+ const nestedRelDef = targetMeta.relations[nestedRelName];
293
+ if (!nestedRelDef)
294
+ continue;
295
+ collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'alias', depth + 1);
296
+ }
297
+ }
298
+ // orderBy params (JSON paths / relation-order global filters): mirrors
299
+ // buildRelationSubquery, which builds its ORDER BY terms BEFORE compiling
300
+ // spec.where (both wrapped and non-wrapped paths).
301
+ if (nativeOrderPath && hasOrder) {
302
+ collectRelationOrderParams(qi, targetTable, targetMeta, relOrderEntries, params);
303
+ }
304
+ // where params — mirrors buildAliasWhere push order
305
+ if (spec.where) {
306
+ whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, spec.where, params);
307
+ }
308
+ // Global filter on the target — mirrors targetGlobalFilterAlias in
309
+ // buildRelationSubquery (pushed after spec.where, before limit).
310
+ whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
311
+ // limit param — only hasMany parameterizes its limit (mirrors
312
+ // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
313
+ // pushing one here would orphan a param and desync the collect path.
314
+ // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
315
+ if (relDef.type === 'hasMany' && spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
316
+ params.push(Number(spec.limit));
317
+ }
318
+ // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
319
+ if (willWrap && spec.with) {
320
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
321
+ const nestedRelDef = targetMeta.relations[nestedRelName];
322
+ if (!nestedRelDef)
323
+ continue;
324
+ collectRelationSubqueryParams(qi, nestedRelDef, nestedSpec, params, 'innerAlias', depth + 1);
325
+ }
326
+ }
327
+ }
328
+ /**
329
+ * Value-shape fingerprint for a single orderBy entry, so two queries whose
330
+ * ORDER BY differs only in nulls placement, vector metric, or relation-count
331
+ * vs relation-column never collide on one cached SQL string. Captures the
332
+ * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
333
+ */
334
+ function orderByEntryFingerprint(qi, d, targetTable) {
335
+ // Vector KNN ordering changes the emitted operator by metric and adds a
336
+ // `::vector` param, so metric + direction must be part of the cache key.
337
+ if ((0, filters_js_1.isVectorOrderBy)(d)) {
338
+ return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
339
+ }
340
+ // JSON-path ordering: direction, cast kind, and nulls placement change the
341
+ // SQL text; the path itself is a bound param and stays OUT of the key.
342
+ if ((0, filters_js_1.isJsonPathOrderBy)(d)) {
343
+ return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
344
+ }
345
+ // Pick-row relation ordering: the by-shape (column vs JSON path vs cast),
346
+ // direction, nulls, pick.orderBy shape, and pick.where SHAPE are all SQL
347
+ // text; the JSON paths and pick.where values are bound params and stay OUT
348
+ // of the key. `targetTable` (the relation's target, resolved by the
349
+ // caller) lets the pick.where fingerprint distinguish relation-filter
350
+ // shapes inside it: two pick.wheres that differ only in shape must never
351
+ // share one cached SQL string.
352
+ if ((0, filters_js_1.isRelationPickOrderBy)(d)) {
353
+ const by = typeof d.by === 'string'
354
+ ? `col=${JSON.stringify(d.by)}`
355
+ : `jp(${JSON.stringify(d.by?.field)},${d.by?.type === 'numeric' ? 'num' : 'text'})`;
356
+ const pickOrder = Object.entries(d.pick?.orderBy ?? {})
357
+ .map(([k, v]) => `${k}:${orderByEntryFingerprint(qi, v)}`)
358
+ .join(',');
359
+ const pickWhere = d.pick?.where
360
+ ? `;pw=${whereMod.fingerprintAliasWhere(qi, d.pick.where, targetTable)}`
361
+ : '';
362
+ // Plan discriminator: the lateral plan emits DIFFERENT SQL (a FROM-clause
363
+ // join + a qualified order term) so a warm cache must never serve one
364
+ // plan's SQL for the other. Emitted ONLY for `'lateral'`: absent means
365
+ // the default subquery plan, keeping every pre-existing cache key
366
+ // byte-identical (no cold-cache churn on upgrade).
367
+ const planTag = d.plan === 'lateral' ? ';plan=lat' : '';
368
+ return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere}${planTag})`;
369
+ }
370
+ if ((0, filters_js_1.isOrderBySpec)(d))
371
+ return `spec(${d.sort},${d.nulls ?? ''})`;
372
+ if (d && typeof d === 'object') {
373
+ // Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
374
+ // INSERTION order, never sorted: the compile side (buildRelationOrderBy)
375
+ // emits one ORDER BY term per entry in Object.entries order, so entry
376
+ // order is SQL-shaping precedence. A sorted fingerprint made
377
+ // `{ name: 'asc', email: 'desc' }` and the swapped literal share one
378
+ // cached SQL string — silently mis-ordered results on a warm cache.
379
+ return `rel(${Object.entries(d)
380
+ .map(([k, v]) => `${k}=${orderByEntryFingerprint(qi, v)}`)
381
+ .join(',')})`;
382
+ }
383
+ return String(d);
384
+ }
385
+ function buildOrderBy(qi, orderBy, params, lateralSink) {
386
+ // Dev-only: validate that orderBy fields exist in the table schema. Relation
387
+ // orderBy keys (object values that are neither a vector nor an OrderBySpec)
388
+ // are validated in the relation branch below, so skip them here.
389
+ if (process.env.NODE_ENV !== 'production') {
390
+ for (const [key, value] of Object.entries(orderBy)) {
391
+ if (isRelationOrderByValue(qi, value) && qi.tableMeta.relations[key])
392
+ continue;
393
+ const snakeKey = (0, schema_js_1.camelToSnake)(key);
394
+ if (!qi.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in qi.tableMeta.columnMap)) {
395
+ console.warn(`[turbine] Unknown orderBy field "${key}" for table "${qi.tableMeta.name}". ` +
396
+ 'This will cause a runtime error.');
397
+ }
398
+ }
399
+ }
400
+ const meta = qi.schema.tables[qi.table];
401
+ let relOrdCounter = 0;
402
+ return Object.entries(orderBy)
403
+ .map(([key, value]) => {
404
+ // Vector KNN ordering: { distance: { to, metric, direction? } }
405
+ if ((0, filters_js_1.isVectorOrderBy)(value)) {
406
+ if (meta && !(key in meta.columnMap)) {
407
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${qi.table}". ` +
408
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
409
+ }
410
+ if (!params) {
411
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
412
+ }
413
+ const rawColumn = qi.toColumn(key);
414
+ const operator = whereMod.vectorOperator(qi, key, rawColumn, value.distance.metric);
415
+ const placeholder = whereMod.pushVectorParam(qi, key, rawColumn, value.distance.to, params);
416
+ const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
417
+ return `${qi.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
418
+ }
419
+ // JSON-path ordering: { path: [...], direction?, type?, nulls? } on a
420
+ // json/jsonb column of THIS table. Path is bound as one text[] param.
421
+ if ((0, filters_js_1.isJsonPathOrderBy)(value)) {
422
+ return buildJsonPathOrderEntry(qi, qi.table, qi.tableMeta, key, value, '', params);
423
+ }
424
+ // Relation ordering: an object value that is not a vector or OrderBySpec,
425
+ // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
426
+ // { name: 'asc' } }`).
427
+ if (isRelationOrderByValue(qi, value)) {
428
+ return buildRelationOrderBy(qi, key, value, `ord${relOrdCounter++}`, params, undefined, lateralSink);
429
+ }
430
+ // Scalar column ordering — a plain direction or an OrderBySpec (nulls).
431
+ if (meta && !(key in meta.columnMap)) {
432
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${qi.table}". ` +
433
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
434
+ }
435
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
436
+ return `${qi.toSqlColumn(key)} ${dir}${nullsSuffix(qi, nulls)}`;
437
+ })
438
+ .join(', ');
439
+ }
440
+ /**
441
+ * True when an orderBy value is a relation-ordering object: a plain object
442
+ * that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
443
+ * in the orderBy clause is a relation name.
444
+ */
445
+ function isRelationOrderByValue(_qi, value) {
446
+ return (typeof value === 'object' &&
447
+ value !== null &&
448
+ !Array.isArray(value) &&
449
+ !(0, filters_js_1.isVectorOrderBy)(value) &&
450
+ !(0, filters_js_1.isJsonPathOrderBy)(value) &&
451
+ !(0, filters_js_1.isOrderBySpec)(value));
452
+ }
453
+ /**
454
+ * Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
455
+ * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
456
+ * other engine a caller asking for explicit nulls placement gets a clear
457
+ * {@link UnsupportedFeatureError} (E017) instead of broken SQL.
458
+ */
459
+ function nullsSuffix(qi, nulls) {
460
+ if (!nulls)
461
+ return '';
462
+ if (qi.dialect.name !== 'postgresql' && qi.dialect.name !== 'sqlite') {
463
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST/LAST ordering', qi.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
464
+ }
465
+ return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
466
+ }
467
+ /**
468
+ * Resolve an orderBy key to its snake_case column via the table's columnMap
469
+ * (camelToSnake fallback), throwing the SAME unknown-field E003 the top-level
470
+ * where path uses. Shared by top-level JSON-path ordering and every nested
471
+ * relation orderBy path so nested orderBy accepts exactly what top-level
472
+ * accepts (the 0.30.x bug: nested orderBy skipped the columnMap and rejected
473
+ * camelCase-named DB columns like "sortOrder").
474
+ */
475
+ function resolveOrderByColumn(_qi, table, meta, key) {
476
+ const col = meta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
477
+ if (!meta.allColumns.includes(col)) {
478
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
479
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
480
+ }
481
+ return col;
482
+ }
483
+ /**
484
+ * Validate a {@link JsonPathOrderBy} entry: column must exist AND be
485
+ * json/jsonb, path must be a non-empty array of keys/indexes: and return
486
+ * the resolved column. Shared by the SQL-build path
487
+ * ({@link buildJsonPathOrderEntry}) and the cache-hit param-collect mirrors
488
+ * so both always throw identically.
489
+ */
490
+ function validateJsonPathOrderBy(qi, table, meta, field, spec) {
491
+ const col = resolveOrderByColumn(qi, table, meta, field);
492
+ if (spec.path.length === 0 ||
493
+ spec.path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
494
+ throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}" (table "${table}") requires a non-empty \`path\` array ` +
495
+ `of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
496
+ }
497
+ const colType = whereMod.pgTypeForColumn(qi, meta, col);
498
+ if (!whereMod.isJsonColumnType(qi, colType)) {
499
+ throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
500
+ `(actual type: ${colType}).`);
501
+ }
502
+ return col;
503
+ }
504
+ /**
505
+ * Compile one {@link JsonPathOrderBy} entry:
506
+ * `("col" #>> $n::text[])::numeric ASC`: the numeric cast only with
507
+ * `type: 'numeric'` (default is text comparison), the extraction routed
508
+ * through the dialect's JSON hook exactly like the JSON where-filters, the
509
+ * path bound as ONE text[] param (mirrored by the order-param collectors).
510
+ * `prefix` scopes the column (`''` top-level, `t0.` inside a relation
511
+ * subquery).
512
+ */
513
+ function buildJsonPathOrderEntry(qi, table, meta, field, spec, prefix, params) {
514
+ const col = validateJsonPathOrderBy(qi, table, meta, field, spec);
515
+ if (!params) {
516
+ throw new errors_js_1.ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
517
+ }
518
+ params.push(whereMod.jsonPathParam(qi, spec.path));
519
+ const extract = qi.dialect.buildJsonPathExtract(`${prefix}${qi.q(col)}`, qi.p(params.length));
520
+ const lhs = spec.type === 'numeric' ? whereMod.castJsonNumeric(qi, extract) : extract;
521
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
522
+ // Rows whose document lacks the path extract to NULL. Without a nulls
523
+ // clause, Postgres DESC defaults to NULLS FIRST, which both diverges from
524
+ // pick-row ordering (NULLS LAST both directions since 0.33) and from
525
+ // engines whose path ordering is nulls-last in both directions. Default to
526
+ // NULLS LAST in BOTH directions unless the caller set `nulls` explicitly;
527
+ // the grammar gate matches nullsSuffix.
528
+ const nullsSql = spec.nulls
529
+ ? nullsSuffix(qi, spec.nulls)
530
+ : qi.dialect.name === 'postgresql' || qi.dialect.name === 'sqlite'
531
+ ? ' NULLS LAST'
532
+ : '';
533
+ return `${lhs} ${dir}${nullsSql}`;
534
+ }
535
+ /**
536
+ * Compile a relation ordering term. For a to-many relation the only allowed
537
+ * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
538
+ * to-one relation each entry names a target column and becomes a correlated
539
+ * scalar subquery (supporting {@link OrderBySpec} nulls placement).
540
+ *
541
+ * Validation: relation must exist (E005); to-many only allows `_count`, and
542
+ * to-one only allows real target columns (E003).
543
+ *
544
+ * `ctx` generalizes the term beyond the root table: inside a relation
545
+ * subquery's orderBy the relations live on the TARGET table's metadata and
546
+ * the correlation parent is the relation's alias, not `qi.table`.
547
+ */
548
+ function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lateralSink) {
549
+ const ownerMeta = ctx?.meta ?? qi.tableMeta;
550
+ const ownerTable = ctx?.table ?? qi.table;
551
+ const parentRef = ctx?.parentRef ?? qi.table;
552
+ const relDef = ownerMeta.relations[relName];
553
+ if (!relDef) {
554
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
555
+ `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
556
+ }
557
+ // Pick-row ordering (`{ pick, by }`): order by a value from ONE related
558
+ // row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
559
+ // Top-level findMany only (`ctx` present means we are inside a relation
560
+ // subquery's orderBy) and hasMany only: validatePickOrderBy throws the
561
+ // scope errors, shared with the cache-hit collect mirror.
562
+ if ((0, filters_js_1.isRelationPickOrderBy)(value)) {
563
+ validatePickOrderBy(qi, relName, relDef, value, ctx !== undefined);
564
+ return buildRelationPickOrderBy(qi, relName, relDef, value, alias, parentRef, params, lateralSink);
565
+ }
566
+ // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
567
+ if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
568
+ const keys = Object.keys(value);
569
+ if (keys.length !== 1 || keys[0] !== '_count') {
570
+ throw new errors_js_1.ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
571
+ `or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
572
+ }
573
+ const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
574
+ return `${buildRelationCountExpr(qi, relDef, parentRef, alias, params)} ${dir}`;
575
+ }
576
+ // To-one: each entry orders by a correlated scalar subquery on a target column.
577
+ const targetMeta = qi.schema.tables[relDef.to];
578
+ if (!targetMeta)
579
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
580
+ const qTarget = qi.q(relDef.to);
581
+ const qParent = qi.q(parentRef);
582
+ // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
583
+ const correlation = relDef.type === 'belongsTo'
584
+ ? qi.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
585
+ : qi.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
586
+ const entries = Object.entries(value);
587
+ if (entries.length === 0) {
588
+ throw new errors_js_1.ValidationError(`[turbine] orderBy on to-one relation "${relName}" needs at least one target column.`);
589
+ }
590
+ return entries
591
+ .map(([col, dirValue]) => {
592
+ // columnMap-first resolution (camelToSnake fallback): mirrors the
593
+ // scalar orderBy path so camelCase-named DB columns resolve here too.
594
+ const snakeCol = targetMeta.columnMap[col] ?? (0, schema_js_1.camelToSnake)(col);
595
+ if (!targetMeta.allColumns.includes(snakeCol)) {
596
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
597
+ }
598
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
599
+ // Target's global filter applies here too — otherwise ordering keys off
600
+ // a soft-deleted / other-tenant related row's value (matches the with
601
+ // subquery semantics for belongsTo/hasOne).
602
+ let where = correlation;
603
+ if (params) {
604
+ const gf = whereMod.targetGlobalFilterAlias(qi, relDef.to, alias, params);
605
+ if (gf)
606
+ where += ` AND ${gf}`;
607
+ }
608
+ return `(SELECT ${alias}.${qi.q(snakeCol)} FROM ${qTarget} ${alias} WHERE ${where}${qi.limitOneClause()}) ${dir}${nullsSuffix(qi, nulls)}`;
609
+ })
610
+ .join(', ');
611
+ }
612
+ /**
613
+ * Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
614
+ * the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
615
+ * param-collect mirror ({@link collectRelationPickOrderParams}) so both
616
+ * always throw identically:
617
+ *
618
+ * - `nested` (inside a relation subquery's orderBy or a pick.orderBy):
619
+ * top-level findMany only in this release (E003),
620
+ * - manyToMany: not supported (E003 naming the limitation),
621
+ * - to-one: order by the target column directly instead (E003),
622
+ * - `pick.orderBy` is REQUIRED (deterministic row choice),
623
+ * - `by` must be a target column name or a `{ field, path }` JSON-path spec.
624
+ */
625
+ function pickOrderNestedError(_qi, relName) {
626
+ return new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level ` +
627
+ 'findMany orderBy: nested `with` orderBy does not support it.');
628
+ }
629
+ function validatePickOrderBy(qi, relName, relDef, spec, nested) {
630
+ if (nested) {
631
+ throw pickOrderNestedError(qi, relName);
632
+ }
633
+ if (relDef.type === 'manyToMany') {
634
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering is not supported on manyToMany relation "${relName}": hasMany relations only.`);
635
+ }
636
+ if (relDef.type !== 'hasMany') {
637
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering is only for to-many (hasMany) relations; "${relName}" is ${relDef.type}. ` +
638
+ `Order by the target column directly instead ({ ${relName}: { <column>: 'asc' } }).`);
639
+ }
640
+ const pickOrder = spec.pick?.orderBy;
641
+ if (typeof spec.pick !== 'object' ||
642
+ spec.pick === null ||
643
+ typeof pickOrder !== 'object' ||
644
+ pickOrder === null ||
645
+ Object.keys(pickOrder).length === 0) {
646
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`pick.orderBy\` to choose ONE ` +
647
+ "related row deterministically (e.g. pick: { orderBy: { createdAt: 'desc' } }).");
648
+ }
649
+ const by = spec.by;
650
+ const validJsonBy = typeof by === 'object' && by !== null && typeof by.field === 'string' && Array.isArray(by.path);
651
+ if (typeof by !== 'string' && !validJsonBy) {
652
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
653
+ "or a JSON-path spec ({ field: 'data', path: ['title'] }).");
654
+ }
655
+ // Physical plan gate. A typo like `plan: 'latreal'` must never silently run
656
+ // the subquery plan (a silent plan change wearing a validation gap). Shared
657
+ // by build and cache-hit collect so a warmed cache throws identically.
658
+ if (spec.plan !== undefined && spec.plan !== 'subquery' && spec.plan !== 'lateral') {
659
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" has an invalid \`plan\`: ` +
660
+ `${JSON.stringify(spec.plan)}. Use 'subquery' (default) or 'lateral'.`);
661
+ }
662
+ if (spec.plan === 'lateral') {
663
+ if (!qi.dialect.supportsLateralJoin) {
664
+ throw new errors_js_1.UnsupportedFeatureError("pick-row ordering with plan: 'lateral'", qi.dialect.name, "LATERAL joins are only available on PostgreSQL. Omit `plan` (or use 'subquery').");
665
+ }
666
+ // The lateral exposes one reserved output column, `__turbine_pick`. A
667
+ // parent column with that exact name would make the unqualified WHERE
668
+ // reference ambiguous once the join is in scope; refuse it explicitly.
669
+ if (qi.tableMeta.allColumns.includes('__turbine_pick')) {
670
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' cannot be used: table "${qi.tableMeta.name}" ` +
671
+ 'has a column named "__turbine_pick", which the lateral join output reserves.');
672
+ }
673
+ }
674
+ }
675
+ /**
676
+ * Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
677
+ * that picks ONE related row (`ORDER BY <pick.orderBy> LIMIT 1`, optionally
678
+ * filtered by `pick.where` and the target's global filter) and surfaces one
679
+ * value from it (a plain target column or a JSON-path extraction) as the
680
+ * parent ORDER BY key:
681
+ *
682
+ * ```sql
683
+ * (SELECT ord0."data" #>> $1::text[] FROM "versions" ord0
684
+ * WHERE ord0."instance_id" = "instances"."id" AND ord0."is_current" = $2
685
+ * ORDER BY ord0."created_at" DESC LIMIT 1) ASC NULLS LAST
686
+ * ```
687
+ *
688
+ * Param-push order (mirrored EXACTLY by
689
+ * {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
690
+ * target global filter → `pick.where` → `pick.orderBy` JSON paths.
691
+ */
692
+ function buildRelationPickOrderBy(qi, relName, relDef, spec, alias, parentRef, params, lateralSink) {
693
+ if (!params) {
694
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
695
+ }
696
+ const targetMeta = qi.schema.tables[relDef.to];
697
+ if (!targetMeta)
698
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
699
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
700
+ const limitOne = qi.buildPagination('1', undefined, true);
701
+ // Parents with ZERO surviving related rows have no row to pick: the
702
+ // correlated subquery yields NULL, and the LEFT JOIN LATERAL null-extends
703
+ // its single row identically. Without a nulls clause, Postgres DESC
704
+ // defaults to NULLS FIRST (every childless parent tops a "highest first"
705
+ // sort). Default to NULLS LAST in BOTH directions (deterministic across
706
+ // engines: SQLite's NULL-is-smallest default diverges from Postgres) unless
707
+ // the caller set `nulls` explicitly; the grammar gate matches nullsSuffix.
708
+ const nullsSql = spec.nulls
709
+ ? nullsSuffix(qi, spec.nulls)
710
+ : qi.dialect.name === 'postgresql' || qi.dialect.name === 'sqlite'
711
+ ? ' NULLS LAST'
712
+ : '';
713
+ // Lateral plan: splice a `LEFT JOIN LATERAL (... LIMIT 1) ON true` into the
714
+ // FROM clause (via the sink) and order by its single reserved output column.
715
+ // Param push order is IDENTICAL to the subquery plan (compilePickPieces is
716
+ // shared), so the cache-hit collect mirror needs no changes. Scope +
717
+ // capability were already enforced by validatePickOrderBy (shared with the
718
+ // collect path); the missing-sink guard catches a non-findMany build
719
+ // context and hard-fails rather than silently emitting a subquery.
720
+ if (spec.plan === 'lateral') {
721
+ if (!lateralSink) {
722
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' on relation "${relName}" is only supported ` +
723
+ 'in a top-level findMany orderBy.');
724
+ }
725
+ const childAlias = `${alias}i`;
726
+ const { byExpr, where, orderClause } = compilePickPieces(qi, relDef, targetMeta, spec, childAlias, parentRef, params);
727
+ lateralSink.push(` LEFT JOIN LATERAL (SELECT ${byExpr} AS ${qi.q('__turbine_pick')} FROM ${qi.q(relDef.to)} ${childAlias}` +
728
+ ` WHERE ${where}${orderClause}${limitOne}) ${alias} ON true`);
729
+ return `${alias}.${qi.q('__turbine_pick')} ${dir}${nullsSql}`;
730
+ }
731
+ // Subquery plan (default): a correlated scalar subquery in ORDER BY.
732
+ const { byExpr, where, orderClause } = compilePickPieces(qi, relDef, targetMeta, spec, alias, parentRef, params);
733
+ return `(SELECT ${byExpr} FROM ${qi.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
734
+ }
735
+ /**
736
+ * Compile the shared inner pieces of a pick-row ordering against `childAlias`
737
+ * (the table alias the related row is read from): the `by` value expression,
738
+ * the correlation + target global filter + `pick.where` predicate, and the
739
+ * `pick.orderBy` clause. Factored out of {@link buildRelationPickOrderBy} so
740
+ * the subquery and lateral plans build IDENTICAL pieces in the SAME param
741
+ * push order (`by` JSON path → target global filter → `pick.where` →
742
+ * `pick.orderBy` JSON paths), which is why the collect mirror
743
+ * ({@link collectRelationPickOrderParams}) is plan-agnostic.
744
+ */
745
+ function compilePickPieces(qi, relDef, targetMeta, spec, childAlias, parentRef, params) {
746
+ // The value surfaced from the picked row (SELECT list: its param binds first).
747
+ let byExpr;
748
+ if (typeof spec.by === 'string') {
749
+ const col = resolveOrderByColumn(qi, relDef.to, targetMeta, spec.by);
750
+ byExpr = `${childAlias}.${qi.q(col)}`;
751
+ }
752
+ else {
753
+ const col = validateJsonPathOrderBy(qi, relDef.to, targetMeta, spec.by.field, {
754
+ path: spec.by.path,
755
+ });
756
+ params.push(whereMod.jsonPathParam(qi, spec.by.path));
757
+ const extract = qi.dialect.buildJsonPathExtract(`${childAlias}.${qi.q(col)}`, qi.p(params.length));
758
+ byExpr = spec.by.type === 'numeric' ? whereMod.castJsonNumeric(qi, extract) : extract;
759
+ }
760
+ // Correlation to the parent row, then the target's global filter (a
761
+ // soft-deleted / other-tenant row must never be picked: matches the
762
+ // `with` subquery and to-one relation-orderBy semantics), then pick.where.
763
+ let where = qi.dialect.buildCorrelation(childAlias, relDef.foreignKey, qi.q(parentRef), relDef.referenceKey);
764
+ const gf = whereMod.targetGlobalFilterAlias(qi, relDef.to, childAlias, params);
765
+ if (gf)
766
+ where += ` AND ${gf}`;
767
+ if (spec.pick.where) {
768
+ const pickWhere = whereMod.buildAliasWhere(qi, relDef.to, targetMeta, childAlias, spec.pick.where, params);
769
+ if (pickWhere)
770
+ where += ` AND ${pickWhere}`;
771
+ }
772
+ // pick.orderBy: same surface as a relation `with` orderBy on the target
773
+ // (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
774
+ // here routes back through buildRelationOrderBy with ctx set and throws
775
+ // the top-level-only E003.
776
+ const orderClause = buildRelationOrderClause(qi, relDef.to, targetMeta, childAlias, Object.entries(spec.pick.orderBy), params);
777
+ return { byExpr, where, orderClause };
778
+ }
779
+ /**
780
+ * Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
781
+ * validation (a warmed cache can never skip it), then pushes in the same
782
+ * order: `by` JSON path → target global filter → `pick.where` →
783
+ * `pick.orderBy` JSON paths.
784
+ */
785
+ function collectRelationPickOrderParams(qi, relName, relDef, spec, params) {
786
+ validatePickOrderBy(qi, relName, relDef, spec, false);
787
+ const targetMeta = qi.schema.tables[relDef.to];
788
+ if (!targetMeta)
789
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
790
+ if (typeof spec.by === 'string') {
791
+ resolveOrderByColumn(qi, relDef.to, targetMeta, spec.by);
792
+ }
793
+ else {
794
+ validateJsonPathOrderBy(qi, relDef.to, targetMeta, spec.by.field, { path: spec.by.path });
795
+ params.push(whereMod.jsonPathParam(qi, spec.by.path));
796
+ }
797
+ whereMod.collectTargetGlobalFilterAlias(qi, relDef.to, params);
798
+ if (spec.pick.where) {
799
+ whereMod.collectAliasWhereParams(qi, relDef.to, targetMeta, spec.pick.where, params);
800
+ }
801
+ collectRelationOrderParams(qi, relDef.to, targetMeta, Object.entries(spec.pick.orderBy), params);
802
+ }
803
+ /**
804
+ * Compile the ORDER BY terms of a relation `with` clause against the
805
+ * relation's table alias. One unified path for every relation shape
806
+ * (hasMany / manyToMany / belongsTo / hasOne) supporting exactly what the
807
+ * top-level orderBy accepts at this level:
808
+ *
809
+ * - scalar columns via columnMap resolution (camelToSnake fallback) with
810
+ * {@link OrderBySpec} nulls placement,
811
+ * - {@link JsonPathOrderBy} entries (path bound as one text[] param),
812
+ * - relation ordering on the TARGET's relations (`_count` for to-many, a
813
+ * target column for to-one), correlated to the relation alias,
814
+ * - vector KNN ordering stays top-level-only (E003, same as before).
815
+ *
816
+ * Param pushes (JSON paths, relation-order global filters) MUST be mirrored,
817
+ * in the same order, by {@link collectRelationOrderParams}.
818
+ */
819
+ function buildRelationOrderClause(qi, targetTable, targetMeta, alias, orderEntries, params) {
820
+ let relOrdCounter = 0;
821
+ const orders = orderEntries
822
+ .map(([key, dirValue]) => {
823
+ if ((0, filters_js_1.isVectorOrderBy)(dirValue)) {
824
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
825
+ }
826
+ if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
827
+ return buildJsonPathOrderEntry(qi, targetTable, targetMeta, key, dirValue, `${alias}.`, params);
828
+ }
829
+ if (isRelationOrderByValue(qi, dirValue)) {
830
+ return buildRelationOrderBy(qi, key, dirValue, `${alias}ord${relOrdCounter++}`, params, { meta: targetMeta, table: targetTable, parentRef: alias });
831
+ }
832
+ const col = resolveOrderByColumn(qi, targetTable, targetMeta, key);
833
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
834
+ return `${alias}.${qi.q(col)} ${dir}${nullsSuffix(qi, nulls)}`;
835
+ })
836
+ .join(', ');
837
+ return ` ORDER BY ${orders}`;
838
+ }
839
+ /**
840
+ * Param-collect mirror of {@link buildRelationOrderClause}: JSON-path
841
+ * entries push their path (one text[] param each); relation-order entries
842
+ * mirror {@link collectOrderByParams}' relation branch (count / to-one
843
+ * global-filter params); scalar entries push nothing but re-run the same
844
+ * column validation so a warmed cache can never skip it.
845
+ */
846
+ function collectRelationOrderParams(qi, targetTable, targetMeta, orderEntries, params) {
847
+ for (const [key, dirValue] of orderEntries) {
848
+ if ((0, filters_js_1.isVectorOrderBy)(dirValue)) {
849
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
850
+ }
851
+ if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
852
+ validateJsonPathOrderBy(qi, targetTable, targetMeta, key, dirValue);
853
+ params.push(whereMod.jsonPathParam(qi, dirValue.path));
854
+ continue;
855
+ }
856
+ if (isRelationOrderByValue(qi, dirValue)) {
857
+ // Pick-row ordering is top-level-only: the build path throws the same
858
+ // E003 (buildRelationOrderBy with ctx set), so the mirror must too.
859
+ if ((0, filters_js_1.isRelationPickOrderBy)(dirValue)) {
860
+ throw pickOrderNestedError(qi, key);
861
+ }
862
+ const relDef = targetMeta.relations[key];
863
+ if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
864
+ collectRelationCountParams(qi, relDef, params);
865
+ }
866
+ else if (relDef) {
867
+ for (const _col of Object.keys(dirValue)) {
868
+ whereMod.collectTargetGlobalFilterAlias(qi, relDef.to, params);
869
+ }
870
+ }
871
+ continue;
872
+ }
873
+ resolveOrderByColumn(qi, targetTable, targetMeta, key);
874
+ }
875
+ }
876
+ /**
877
+ * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
878
+ * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
879
+ * manyToMany counts junction rows via the source key. Shared by the `_count`
880
+ * `with` key and to-many relation orderBy.
881
+ *
882
+ * When `params` is supplied and the target has a global filter, it is
883
+ * AND-merged so the count only sees surviving rows (a soft-deleted child is
884
+ * not counted): hasMany filters the counted rows directly; manyToMany adds an
885
+ * `EXISTS` on the target through the junction (the junction rows themselves
886
+ * carry no filter). Params are mirrored by {@link collectRelationCountParams}.
887
+ */
888
+ function buildRelationCountExpr(qi, relDef, parentRef, alias, params) {
889
+ const qParent = qi.q(parentRef);
890
+ const count = qi.castAgg('COUNT(*)', 'int');
891
+ if (relDef.type === 'manyToMany') {
892
+ if (!relDef.through) {
893
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing its \`through\` junction.`);
894
+ }
895
+ const qJ = qi.q(relDef.through.table);
896
+ const jalias = `${alias}j`;
897
+ const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
898
+ const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
899
+ let where = sourceKeys.map((jc, i) => `${jalias}.${qi.q(jc)} = ${qParent}.${qi.q(refKeys[i])}`).join(' AND ');
900
+ if (params) {
901
+ const targetExists = manyToManyTargetGlobalFilterExists(qi, relDef, alias, jalias, params);
902
+ if (targetExists)
903
+ where += ` AND ${targetExists}`;
904
+ }
905
+ return `(SELECT ${count} FROM ${qJ} ${jalias} WHERE ${where})`;
906
+ }
907
+ // hasMany: child FK correlates to the parent reference key.
908
+ const qTarget = qi.q(relDef.to);
909
+ let where = qi.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
910
+ if (params) {
911
+ const gf = whereMod.targetGlobalFilterAlias(qi, relDef.to, alias, params);
912
+ if (gf)
913
+ where += ` AND ${gf}`;
914
+ }
915
+ return `(SELECT ${count} FROM ${qTarget} ${alias} WHERE ${where})`;
916
+ }
917
+ /**
918
+ * `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
919
+ * a manyToMany `_count` to targets that survive their global filter. `''` when
920
+ * the target has no filter. Pushes gf params; mirror:
921
+ * {@link collectManyToManyTargetGlobalFilter}.
922
+ */
923
+ function manyToManyTargetGlobalFilterExists(qi, relDef, alias, jalias, params) {
924
+ const gf = whereMod.resolveGlobalFilter(qi, relDef.to);
925
+ if (!gf || !relDef.through)
926
+ return '';
927
+ const tMeta = qi.schema.tables[relDef.to];
928
+ if (!tMeta || tMeta.primaryKey.length === 0)
929
+ return '';
930
+ const talias = `${alias}t`;
931
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
932
+ const pk = tMeta.primaryKey;
933
+ if (targetKeys.length !== pk.length)
934
+ return '';
935
+ const join = targetKeys.map((jc, i) => `${talias}.${qi.q(pk[i])} = ${jalias}.${qi.q(jc)}`).join(' AND ');
936
+ const gfClause = whereMod.buildAliasWhere(qi, relDef.to, tMeta, talias, gf, params);
937
+ const gfAnd = gfClause ? ` AND ${gfClause}` : '';
938
+ return `EXISTS (SELECT 1 FROM ${qi.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
939
+ }
940
+ /** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
941
+ function collectManyToManyTargetGlobalFilter(qi, relDef, params) {
942
+ const gf = whereMod.resolveGlobalFilter(qi, relDef.to);
943
+ if (!gf || !relDef.through)
944
+ return;
945
+ const tMeta = qi.schema.tables[relDef.to];
946
+ if (!tMeta || tMeta.primaryKey.length === 0)
947
+ return;
948
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
949
+ if (targetKeys.length !== tMeta.primaryKey.length)
950
+ return;
951
+ whereMod.collectAliasWhereParams(qi, relDef.to, tMeta, gf, params);
952
+ }
953
+ /**
954
+ * Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
955
+ * params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
956
+ * when a filter applies — no-op otherwise.
957
+ */
958
+ function collectRelationCountParams(qi, relDef, params) {
959
+ if (relDef.type === 'manyToMany') {
960
+ collectManyToManyTargetGlobalFilter(qi, relDef, params);
961
+ }
962
+ else {
963
+ whereMod.collectTargetGlobalFilterAlias(qi, relDef.to, params);
964
+ }
965
+ }
966
+ function getCamelDateFields(qi, table, meta) {
967
+ let camel = qi.camelDateFieldCache.get(table);
968
+ if (!camel) {
969
+ camel = new Set();
970
+ for (const col of meta.dateColumns) {
971
+ camel.add(meta.reverseColumnMap[col] ?? col);
972
+ }
973
+ qi.camelDateFieldCache.set(table, camel);
974
+ }
975
+ return camel;
976
+ }
977
+ /** Parse a row that may contain JSON nested relation columns */
978
+ function parseNestedRow(qi, row, table) {
979
+ const parsed = qi.parseRow(row, table);
980
+ const meta = qi.schema.tables[table];
981
+ if (!meta)
982
+ return parsed;
983
+ // Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
984
+ // parseRow copies these unknown columns through under their raw key.
985
+ let countObj;
986
+ for (const key of Object.keys(parsed)) {
987
+ if (key.startsWith('_count__')) {
988
+ if (countObj === undefined)
989
+ countObj = {};
990
+ countObj[key.slice('_count__'.length)] = Number(parsed[key]);
991
+ delete parsed[key];
992
+ }
993
+ }
994
+ if (countObj)
995
+ parsed._count = countObj;
996
+ for (const [relName, relDef] of Object.entries(meta.relations)) {
997
+ const rawValue = row[relName];
998
+ if (rawValue === undefined)
999
+ continue;
1000
+ // --- Short-circuit: skip JSON.parse for common empty/null cases ---
1001
+ // hasMany returns '[]' (from COALESCE(..., '[]'::json)); belongsTo/hasOne returns null
1002
+ if (rawValue === null || rawValue === 'null') {
1003
+ parsed[relName] = null;
1004
+ continue;
1005
+ }
1006
+ if (rawValue === '[]') {
1007
+ parsed[relName] = [];
1008
+ continue;
1009
+ }
1010
+ if (Array.isArray(rawValue) && rawValue.length === 0) {
1011
+ parsed[relName] = [];
1012
+ continue;
1013
+ }
1014
+ // --- Non-empty values: full parse path ---
1015
+ if (typeof rawValue === 'string') {
1016
+ try {
1017
+ const jsonVal = JSON.parse(rawValue);
1018
+ // After parsing, recurse via parseNestedRow so each item gets date
1019
+ // coercion AND its own sub-relations parsed at arbitrary depth.
1020
+ if (Array.isArray(jsonVal)) {
1021
+ parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
1022
+ ? parseNestedRow(qi, item, relDef.to)
1023
+ : item);
1024
+ }
1025
+ else if (typeof jsonVal === 'object' && jsonVal !== null) {
1026
+ parsed[relName] = parseNestedRow(qi, jsonVal, relDef.to);
1027
+ }
1028
+ else {
1029
+ parsed[relName] = jsonVal;
1030
+ }
1031
+ }
1032
+ catch {
1033
+ console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${qi.table}". Using raw value.`);
1034
+ parsed[relName] = rawValue;
1035
+ }
1036
+ }
1037
+ else if (Array.isArray(rawValue)) {
1038
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
1039
+ ? parseNestedRow(qi, item, relDef.to)
1040
+ : item);
1041
+ }
1042
+ else if (typeof rawValue === 'object' && rawValue !== null) {
1043
+ parsed[relName] = parseNestedRow(qi, rawValue, relDef.to);
1044
+ }
1045
+ else {
1046
+ parsed[relName] = rawValue;
1047
+ }
1048
+ }
1049
+ return parsed;
1050
+ }
1051
+ /**
1052
+ * Resolve the emitted column list for a relation, honoring `select` / `omit`.
1053
+ * Shared by {@link buildRelationSubquery} (json order) and
1054
+ * {@link buildRelationShape} (decode key order) so they can never diverge.
1055
+ */
1056
+ function resolveTargetColumns(qi, spec, targetMeta, includePii) {
1057
+ if (spec !== true && spec.select) {
1058
+ // Explicit `select` names the columns: a PII column named here IS the
1059
+ // opt-in and comes back regardless of the query's `includePii`.
1060
+ const selectedFields = Object.entries(spec.select)
1061
+ .filter(([, v]) => v)
1062
+ .map(([k]) => targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k));
1063
+ return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
1064
+ }
1065
+ // Default / omit-only relation projection: PII columns are excluded unless
1066
+ // the query opted in via `includePii`.
1067
+ const piiCols = includePii ? undefined : writesMod.piiColumns(qi, targetMeta);
1068
+ const hasPii = piiCols !== undefined && piiCols.size > 0;
1069
+ if (spec !== true && spec.omit) {
1070
+ const omittedFields = new Set(Object.entries(spec.omit)
1071
+ .filter(([, v]) => v)
1072
+ .map(([k]) => targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k)));
1073
+ return targetMeta.allColumns.filter((col) => !omittedFields.has(col) && !(hasPii && piiCols.has(col)));
1074
+ }
1075
+ if (hasPii) {
1076
+ return targetMeta.allColumns.filter((col) => !piiCols.has(col));
1077
+ }
1078
+ return targetMeta.allColumns;
1079
+ }
1080
+ /**
1081
+ * Render a single relation row's JSON: a keyed object (`'object'`) or a
1082
+ * positional array (`'positional'`). The array drops the keys but keeps the
1083
+ * exact expression order, so {@link RelationShape.keys} maps positions back.
1084
+ */
1085
+ function buildJsonRow(qi, jsonPairs) {
1086
+ if (qi.jsonEncoding === 'positional') {
1087
+ // buildJsonArray is defined on postgresDialect; positional is gated to PG
1088
+ // in buildSelectWithRelations, so the `?? buildJsonObject` never fires.
1089
+ return qi.dialect.buildJsonArray?.(jsonPairs.map(([, expr]) => expr)) ?? qi.dialect.buildJsonObject(jsonPairs);
1090
+ }
1091
+ return qi.dialect.buildJsonObject(jsonPairs);
1092
+ }
1093
+ /**
1094
+ * Build the top-level relation shapes for a `with` clause, mirroring
1095
+ * {@link buildSelectWithRelations}: same relation iteration order, same
1096
+ * per-relation column resolution, same nested recursion.
1097
+ */
1098
+ function buildRelationShapes(qi, table, withClause, includePii) {
1099
+ const meta = qi.schema.tables[table];
1100
+ if (!meta)
1101
+ return {};
1102
+ const shapes = {};
1103
+ for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
1104
+ const relDef = meta.relations[relName];
1105
+ if (!relDef)
1106
+ continue; // buildSelectWithRelations already threw for this
1107
+ shapes[relName] = buildRelationShape(qi, relDef, relSpec, meta, includePii);
1108
+ }
1109
+ return shapes;
1110
+ }
1111
+ /**
1112
+ * Recursively describe one relation's positional layout: the camelCase key
1113
+ * order (scalar columns first, then nested relation slots in the same order
1114
+ * {@link buildRelationSubquery} appends them), the nested sub-shapes, and the
1115
+ * cardinality (single object for belongsTo/hasOne, array for the rest).
1116
+ */
1117
+ function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1118
+ void parentMeta;
1119
+ const targetMeta = qi.schema.tables[relDef.to];
1120
+ if (!targetMeta)
1121
+ return { keys: [], nested: {}, cardinality: 'many' };
1122
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
1123
+ const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col));
1124
+ const nested = {};
1125
+ if (spec !== true && spec.with) {
1126
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1127
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1128
+ if (!nestedRelDef)
1129
+ continue;
1130
+ keys.push(nestedRelName);
1131
+ nested[nestedRelName] = buildRelationShape(qi, nestedRelDef, nestedSpec, targetMeta, includePii);
1132
+ }
1133
+ }
1134
+ const cardinality = relDef.type === 'belongsTo' || relDef.type === 'hasOne' ? 'one' : 'many';
1135
+ return { keys, nested, cardinality };
1136
+ }
1137
+ /**
1138
+ * Build the row parser for a `with` clause. In object mode this is just
1139
+ * {@link parseNestedRow}. In positional mode it decodes each relation's
1140
+ * positional arrays into the object form first (shapes built once, not per
1141
+ * row), then delegates to parseNestedRow for date/snake-camel coercion.
1142
+ */
1143
+ function makeNestedParser(qi, withClause, includePii) {
1144
+ if (qi.jsonEncoding !== 'positional') {
1145
+ return (row) => parseNestedRow(qi, row, qi.table);
1146
+ }
1147
+ const shapes = buildRelationShapes(qi, qi.table, withClause, includePii);
1148
+ return (row) => parseNestedRow(qi, decodePositionalRelations(qi, row, shapes), qi.table);
1149
+ }
1150
+ /**
1151
+ * Return a shallow copy of a top-level row with each relation column decoded
1152
+ * from its positional array(s) into the object representation. Only relation
1153
+ * columns are positional — base scalar columns stay object-keyed — so the
1154
+ * result is exactly what the object encoding would have handed parseNestedRow.
1155
+ */
1156
+ function decodePositionalRelations(qi, row, shapes) {
1157
+ const cloned = { ...row };
1158
+ for (const [relName, shape] of Object.entries(shapes)) {
1159
+ if (relName in cloned)
1160
+ cloned[relName] = decodePositionalValue(qi, cloned[relName], shape);
1161
+ }
1162
+ return cloned;
1163
+ }
1164
+ /**
1165
+ * Decode one relation's positional JSON value. `json_agg` returns the value as
1166
+ * a JSON string at the top level (JSON.parse once); nested relation slots are
1167
+ * already-parsed arrays. A `'many'` value is an array of positional arrays; a
1168
+ * `'one'` value is a single positional array or null.
1169
+ */
1170
+ function decodePositionalValue(qi, raw, shape) {
1171
+ let val = raw;
1172
+ if (typeof val === 'string') {
1173
+ try {
1174
+ val = JSON.parse(val);
1175
+ }
1176
+ catch {
1177
+ return raw; // parseNestedRow's warn path handles unparseable JSON
1178
+ }
1179
+ }
1180
+ if (val === null || val === undefined) {
1181
+ return shape.cardinality === 'many' ? [] : null;
1182
+ }
1183
+ if (shape.cardinality === 'many') {
1184
+ if (!Array.isArray(val))
1185
+ return val;
1186
+ return val.map((inner) => decodePositionalObject(qi, inner, shape));
1187
+ }
1188
+ return decodePositionalObject(qi, val, shape);
1189
+ }
1190
+ /** Map one positional array back to a keyed object using the shape's key order. */
1191
+ function decodePositionalObject(qi, arr, shape) {
1192
+ if (!Array.isArray(arr))
1193
+ return arr;
1194
+ const obj = {};
1195
+ for (let i = 0; i < shape.keys.length; i++) {
1196
+ const key = shape.keys[i];
1197
+ const nestedShape = shape.nested[key];
1198
+ obj[key] = nestedShape ? decodePositionalValue(qi, arr[i], nestedShape) : arr[i];
1199
+ }
1200
+ return obj;
1201
+ }
1202
+ /**
1203
+ * Build a SELECT clause that includes both base columns and nested relation subqueries.
1204
+ *
1205
+ * For each relation specified in the `with` clause, this method generates a correlated
1206
+ * subquery using PostgreSQL's `json_agg(json_build_object(...))` pattern. The result
1207
+ * is a single SQL SELECT clause that resolves the full object tree in one query --
1208
+ * no N+1 problem.
1209
+ *
1210
+ * **How it works:**
1211
+ * 1. Resolves the base columns for the root table (all columns, or a subset via `columnsList`).
1212
+ * 2. Iterates over each key in the `with` clause, looking up the relation definition.
1213
+ * 3. For each relation, delegates to {@link buildRelationSubquery} to generate a
1214
+ * correlated subquery that returns JSON (array for hasMany, object for belongsTo/hasOne).
1215
+ * 4. Each subquery is aliased as the relation name in the final SELECT.
1216
+ *
1217
+ * **aliasCounter:** A shared `{ n: number }` object is passed through all nesting levels.
1218
+ * Each call to `buildRelationSubquery` increments it to produce unique table aliases
1219
+ * (`t0`, `t1`, `t2`, ...) across arbitrarily deep relation trees, preventing alias
1220
+ * collisions in the generated SQL.
1221
+ *
1222
+ * **Example output:**
1223
+ * ```sql
1224
+ * "users"."id", "users"."name", "users"."email",
1225
+ * (SELECT COALESCE(json_agg(json_build_object('id', t0."id", 'title', t0."title")), '[]'::json)
1226
+ * FROM "posts" t0 WHERE t0."user_id" = "users"."id") AS "posts"
1227
+ * ```
1228
+ *
1229
+ * @param table - The root table name (e.g. `"users"`).
1230
+ * @param withClause - An object mapping relation names to their include specs
1231
+ * (`true` for default inclusion, or `WithOptions` for select/omit/where/orderBy/limit).
1232
+ * @param params - Shared parameter array for parameterized values (`$1`, `$2`, ...).
1233
+ * Nested where/limit values are pushed here to prevent SQL injection.
1234
+ * @param columnsList - Optional subset of columns to include in the SELECT. When `null`
1235
+ * or omitted, all columns from the table's schema metadata are used.
1236
+ * @param depth - Current nesting depth, passed through to {@link buildRelationSubquery}
1237
+ * for circular-relation detection. Defaults to `0` at the top level.
1238
+ * @param path - Breadcrumb trail of relation names traversed so far, used in error
1239
+ * messages when circular or too-deep nesting is detected.
1240
+ * @returns A complete SELECT clause string (without the `SELECT` keyword) containing
1241
+ * base columns and relation subqueries.
1242
+ */
1243
+ function buildSelectWithRelations(qi, table, withClause, params, columnsList, depth, path, includePii) {
1244
+ const meta = qi.schema.tables[table];
1245
+ if (!meta)
1246
+ throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}"`);
1247
+ // Positional JSON encoding is Postgres-only in v1. Gate here — the single
1248
+ // entry point for every `with` clause — so no engine ever emits the
1249
+ // json_build_array shape its dialect can't produce (and mssql's FOR JSON
1250
+ // override path is never reached with positional active).
1251
+ if (qi.jsonEncoding === 'positional' && qi.dialect.name !== 'postgresql') {
1252
+ throw new errors_js_1.UnsupportedFeatureError("jsonEncoding: 'positional'", qi.dialect.name, 'Positional relation encoding is only available on PostgreSQL in this version.');
1253
+ }
1254
+ const cols = columnsList ?? meta.allColumns;
1255
+ const qtbl = qi.q(table);
1256
+ const baseCols = cols.map((col) => `${qtbl}.${qi.q(col)}`).join(', ');
1257
+ const relationSelects = [];
1258
+ const aliasCounter = { n: 0 };
1259
+ for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
1260
+ // `_count` is a reserved key handled after the relation subqueries.
1261
+ if (relName === '_count')
1262
+ continue;
1263
+ const relDef = meta.relations[relName];
1264
+ if (!relDef) {
1265
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
1266
+ `Available: ${Object.keys(meta.relations).join(', ')}`);
1267
+ }
1268
+ // The main table is not aliased, so pass table name as parentRef
1269
+ const subquery = buildRelationSubquery(qi, relDef, relSpec, params, table, aliasCounter, depth, path, includePii);
1270
+ relationSelects.push(`(${subquery}) AS ${qi.q(relName)}`);
1271
+ }
1272
+ // Reserved `_count` key → one correlated COUNT(*) scalar subquery per
1273
+ // selected to-many relation, aliased `_count__<rel>`. Appended after the
1274
+ // relation subqueries; the only params they can push come from a global
1275
+ // filter on the counted target (mirrored at the tail of collectWithParams).
1276
+ // Read via a cast so WithClause keeps its narrow `true | WithOptions` type.
1277
+ const countSpec = withClause._count;
1278
+ if (countSpec !== undefined) {
1279
+ for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
1280
+ const expr = buildRelationCountExpr(qi, rel, table, `t${aliasCounter.n++}`, params);
1281
+ relationSelects.push(`${expr} AS ${qi.q(`_count__${rel.name}`)}`);
1282
+ }
1283
+ }
1284
+ return [baseCols, ...relationSelects].join(', ');
1285
+ }
1286
+ /**
1287
+ * Generate a correlated subquery that returns JSON for a single relation.
1288
+ *
1289
+ * This is the core of Turbine's single-query nested relation strategy. For a given
1290
+ * relation (e.g. `posts` on a `users` query), it produces a self-contained SQL subquery
1291
+ * that PostgreSQL evaluates per parent row, returning either a JSON array (hasMany) or
1292
+ * a single JSON object (belongsTo / hasOne).
1293
+ *
1294
+ * ### Algorithm overview
1295
+ *
1296
+ * 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
1297
+ * `aliasCounter` so that deeply nested subqueries never collide.
1298
+ *
1299
+ * 2. **Column resolution:** Honors `select` / `omit` options to control which columns
1300
+ * appear in the output JSON.
1301
+ *
1302
+ * 3. **`json_build_object`:** Builds a JSON object for each row by mapping camelCase
1303
+ * field names to their column values:
1304
+ * ```sql
1305
+ * json_build_object('id', t0."id", 'title', t0."title", 'createdAt', t0."created_at")
1306
+ * ```
1307
+ *
1308
+ * 4. **`json_agg` wrapping (hasMany):** For one-to-many relations, wraps the
1309
+ * `json_build_object` call in `json_agg(...)` to aggregate all matching child rows
1310
+ * into a JSON array. Uses `COALESCE(..., '[]'::json)` so the result is never NULL.
1311
+ * For belongsTo / hasOne, no aggregation is used -- just the single JSON object
1312
+ * with `LIMIT 1`.
1313
+ *
1314
+ * 5. **Correlation (WHERE clause):** Links the subquery to the parent row:
1315
+ * - **hasMany:** `alias.foreignKey = parentRef.referenceKey`
1316
+ * (e.g. `t0."user_id" = "users"."id"` -- child FK points to parent PK)
1317
+ * - **belongsTo / hasOne:** `alias.referenceKey = parentRef.foreignKey`
1318
+ * (e.g. `t0."id" = "posts"."author_id"` -- parent FK points to child PK)
1319
+ *
1320
+ * 6. **Recursion:** If the spec includes a nested `with` clause, this method calls
1321
+ * itself recursively for each nested relation, passing the current alias as
1322
+ * `parentRef`. The nested subquery appears as an additional key in the
1323
+ * `json_build_object` call, wrapped in `COALESCE(..., '[]'::json)`.
1324
+ * Depth is incremented and capped at 10 to guard against circular relations.
1325
+ *
1326
+ * 7. **LIMIT / ORDER BY wrapping:** For hasMany relations with `limit` or `orderBy`,
1327
+ * the query is restructured into a two-level form:
1328
+ * ```sql
1329
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
1330
+ * FROM (
1331
+ * SELECT t0.* FROM "posts" t0
1332
+ * WHERE t0."user_id" = "users"."id"
1333
+ * ORDER BY t0."created_at" DESC
1334
+ * LIMIT $1
1335
+ * ) t0i
1336
+ * ```
1337
+ * This ensures LIMIT and ORDER BY apply to the raw rows *before* `json_agg`
1338
+ * aggregation. Without the inner subquery, LIMIT would be meaningless because
1339
+ * `json_agg` produces a single aggregated row.
1340
+ *
1341
+ * 8. **Parameter threading:** All user-supplied values (where filters, limit) are
1342
+ * pushed to the shared `params` array with `$N` placeholders. No string
1343
+ * interpolation of user data ever occurs -- all identifiers go through
1344
+ * `qi.q()` and all values are parameterized.
1345
+ *
1346
+ * ### Example output (hasMany with nested relation)
1347
+ * ```sql
1348
+ * SELECT COALESCE(json_agg(json_build_object(
1349
+ * 'id', t0."id",
1350
+ * 'title', t0."title",
1351
+ * 'comments', COALESCE((
1352
+ * SELECT COALESCE(json_agg(json_build_object('id', t1."id", 'body', t1."body")), '[]'::json)
1353
+ * FROM "comments" t1 WHERE t1."post_id" = t0."id"
1354
+ * ), '[]'::json)
1355
+ * )), '[]'::json) FROM "posts" t0 WHERE t0."user_id" = "users"."id"
1356
+ * ```
1357
+ *
1358
+ * @param relDef - The relation definition from schema metadata (contains `to`, `type`,
1359
+ * `foreignKey`, `referenceKey`).
1360
+ * @param spec - Either `true` (include with defaults) or a `WithOptions` object that
1361
+ * can specify `select`, `omit`, `where`, `orderBy`, `limit`, and nested `with`.
1362
+ * @param params - Shared parameter array. User-supplied values are pushed here and
1363
+ * referenced as `$1`, `$2`, etc. in the generated SQL.
1364
+ * @param parentRef - The alias (e.g. `"t0"`) or table name (e.g. `"users"`) of the
1365
+ * parent query. Used to build the correlated WHERE clause that ties
1366
+ * child rows to their parent row.
1367
+ * @param aliasCounter - Shared mutable counter (`{ n: number }`) for generating unique
1368
+ * table aliases (`t0`, `t1`, `t2`, ...) across all nesting levels.
1369
+ * Each call increments `n` by 1.
1370
+ * @param depth - Current nesting depth (starts at `0`). Incremented on each recursive
1371
+ * call. If it reaches 10, a {@link CircularRelationError} is thrown.
1372
+ * @param path - Breadcrumb trail of relation/table names traversed so far
1373
+ * (e.g. `["users", "posts", "comments"]`). Used in the error message
1374
+ * when circular or too-deep nesting is detected.
1375
+ * @returns A complete SQL subquery string (without surrounding parentheses) that
1376
+ * evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
1377
+ */
1378
+ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter, depth, path, includePii) {
1379
+ const currentDepth = depth ?? 0;
1380
+ const currentPath = path ?? [qi.table];
1381
+ const targetTable = relDef.to;
1382
+ // Hard depth cap — the `with` clause is a finite JSON structure so users can't
1383
+ // create true infinite recursion, but extremely deep nesting (10+ levels) produces
1384
+ // unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
1385
+ // since they are legitimate queries (Prisma supports the same pattern).
1386
+ if (currentDepth >= 10) {
1387
+ throw new errors_js_1.CircularRelationError([...currentPath, targetTable]);
1388
+ }
1389
+ const targetMeta = qi.schema.tables[targetTable];
1390
+ if (!targetMeta)
1391
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${targetTable}"`);
1392
+ // Dev-only: correlated relation loading probes the child table once per parent
1393
+ // row, so a missing FK index multiplies into per-parent full-table scans (a
1394
+ // batched-loader ORM pays the same missing index only once, which is why
1395
+ // schemas migrated from one often lack these). Name the exact index to create
1396
+ // instead of letting the slowness look like an ORM problem.
1397
+ if (process.env.NODE_ENV !== 'production') {
1398
+ const warnKey = `${relDef.from}.${relDef.name}`;
1399
+ if (!unindexedRelationWarned.has(warnKey)) {
1400
+ const miss = (0, index_advisor_js_1.missingIndexForRelation)(qi.schema, relDef);
1401
+ if (miss) {
1402
+ unindexedRelationWarned.add(warnKey);
1403
+ console.warn(`[turbine] Relation "${relDef.name}" on "${relDef.from}" probes ` +
1404
+ `"${miss.table}"(${miss.columns.join(', ')}) which has no covering index — ` +
1405
+ `each parent row scans the full table. Fix: ${miss.createSql}; ` +
1406
+ 'or run `npx turbine doctor` for a full report.');
1407
+ }
1408
+ }
1409
+ }
1410
+ // Generate a unique alias: t0, t1, t2, ...
1411
+ const alias = `t${aliasCounter.n++}`;
1412
+ // Resolve which columns to include based on select/omit (and the query-level
1413
+ // `includePii` opt-in). Shared with the positional-shape builder so the
1414
+ // emitted json_build_array column order and the decode-side key order can
1415
+ // never drift apart.
1416
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
1417
+ // Engine override seam (additive): a dialect whose JSON-aggregation shape does
1418
+ // not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
1419
+ // the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
1420
+ // unchanged (byte-identical output, all their tests stay green). The override
1421
+ // pushes params per the documented RelationSubqueryContext ordering contract,
1422
+ // which mirrors collectRelationSubqueryParams so the SQL cache / pipeline stay
1423
+ // in sync.
1424
+ if (qi.dialect.buildRelationSubquery) {
1425
+ return qi.dialect.buildRelationSubquery({
1426
+ relDef,
1427
+ spec,
1428
+ params,
1429
+ parentRef,
1430
+ alias,
1431
+ targetTable,
1432
+ targetMeta,
1433
+ targetColumns,
1434
+ depth: currentDepth,
1435
+ path: currentPath,
1436
+ quote: (name) => qi.q(name),
1437
+ buildWhere: (whereAlias) => (spec !== true && spec.where
1438
+ ? whereMod.buildAliasWhere(qi, targetTable, targetMeta, whereAlias, spec.where, params)
1439
+ : '') ?? '',
1440
+ recurse: (nRelDef, nSpec, nParent, nDepth, nPath) => buildRelationSubquery(qi, nRelDef, nSpec, params, nParent, aliasCounter, nDepth, nPath, includePii),
1441
+ });
1442
+ }
1443
+ // Build JSON object pairs for resolved columns
1444
+ const jsonPairs = targetColumns.map((col) => [
1445
+ targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
1446
+ `${alias}.${qi.q(col)}`,
1447
+ ]);
1448
+ // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
1449
+ // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
1450
+ // so we must NOT build them here (they would push orphaned params).
1451
+ // An orderBy with no defined entries (`orderBy: {}`) is treated as absent —
1452
+ // it must neither trigger the wrap (dropping nested relations) nor render a
1453
+ // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
1454
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
1455
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
1456
+ // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
1457
+ // where, orderBy, and select/omit are handled there (the target alias is the
1458
+ // row source, exactly like hasMany), so short-circuit before the hasMany logic.
1459
+ if (relDef.type === 'manyToMany') {
1460
+ return buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns, includePii);
1461
+ }
1462
+ // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
1463
+ if (!willWrap && spec !== true && spec.with) {
1464
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1465
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1466
+ if (!nestedRelDef) {
1467
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1468
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1469
+ }
1470
+ // Recursively build nested subquery, passing THIS alias as the parent reference
1471
+ const nestedSubquery = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
1472
+ // Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
1473
+ const fallback = nestedRelDef.type === 'hasMany' ? qi.dialect.emptyJsonArrayLiteral : qi.dialect.nullJsonLiteral;
1474
+ jsonPairs.push([nestedRelName, qi.dialect.wrapJsonSubresult(nestedSubquery, fallback)]);
1475
+ }
1476
+ }
1477
+ const jsonObj = buildJsonRow(qi, jsonPairs);
1478
+ // Quote parent ref — can be a table name or auto-generated alias
1479
+ const qParent = qi.q(parentRef);
1480
+ const qTarget = qi.q(targetTable);
1481
+ // Build ORDER BY for json_agg: unified with the top-level orderBy surface
1482
+ // (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
1483
+ // Param pushes here land BEFORE the spec.where params, mirrored by
1484
+ // collectRelationSubqueryParams.
1485
+ let orderClause = '';
1486
+ if (relOrderEntries.length > 0) {
1487
+ orderClause = buildRelationOrderClause(qi, targetTable, targetMeta, alias, relOrderEntries, params);
1488
+ }
1489
+ // Build WHERE — correlate to parent via parentRef (alias or table name).
1490
+ // For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
1491
+ // the child-side column), so alias.fk = parentRef.pk. hasOne is just
1492
+ // hasMany with a unique FK — treating it like belongsTo here silently
1493
+ // correlated the wrong columns (caught dogfooding: uuid = varchar).
1494
+ // For belongsTo: SOURCE has the FK, so alias.pk = parentRef.fk (reversed).
1495
+ // Supports composite foreign keys (string[]) via buildCorrelation.
1496
+ let whereClause;
1497
+ if (relDef.type === 'belongsTo') {
1498
+ whereClause = qi.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey);
1499
+ }
1500
+ else {
1501
+ whereClause = qi.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
1502
+ }
1503
+ // Additional filters — full scalar where surface (equality, null, operator
1504
+ // objects, OR/AND/NOT), properly parameterized against this alias.
1505
+ if (spec !== true && spec.where) {
1506
+ const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, alias, spec.where, params);
1507
+ if (extra)
1508
+ whereClause += ` AND ${extra}`;
1509
+ }
1510
+ // Global filter on the target table (soft-delete / tenancy) — AND-merged so
1511
+ // a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
1512
+ // mirrored by collectRelationSubqueryParams.
1513
+ const gfExtra = whereMod.targetGlobalFilterAlias(qi, targetTable, alias, params);
1514
+ if (gfExtra)
1515
+ whereClause += ` AND ${gfExtra}`;
1516
+ // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
1517
+ // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
1518
+ // a parameter: doing so orphans an untyped `$N` that the SQL never references,
1519
+ // which Postgres rejects with "could not determine data type of parameter $N"
1520
+ // (and shifts every later placeholder by one). To-one relations ignore limit.
1521
+ // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
1522
+ let limitClause = '';
1523
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
1524
+ limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
1525
+ }
1526
+ if (relDef.type === 'hasMany') {
1527
+ // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
1528
+ // BEFORE json_agg aggregation (otherwise LIMIT on aggregated result is meaningless)
1529
+ if (limitClause || orderClause) {
1530
+ const innerAlias = `${alias}i`;
1531
+ // Rewrite: SELECT json_agg(json_build_object(...)) FROM (SELECT * FROM table WHERE ... ORDER BY ... LIMIT N) AS alias
1532
+ // Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
1533
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${qi.q(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
1534
+ // For the json_build_object, reference the inner alias — only include resolved columns
1535
+ const innerJsonPairs = targetColumns.map((col) => [
1536
+ targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
1537
+ `${innerAlias}.${qi.q(col)}`,
1538
+ ]);
1539
+ // Build nested relation subqueries referencing innerAlias
1540
+ if (spec !== true && spec.with) {
1541
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1542
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1543
+ if (!nestedRelDef) {
1544
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1545
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1546
+ }
1547
+ const nestedSub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
1548
+ const fallback = nestedRelDef.type === 'hasMany' ? qi.dialect.emptyJsonArrayLiteral : qi.dialect.nullJsonLiteral;
1549
+ innerJsonPairs.push([nestedRelName, qi.dialect.wrapJsonSubresult(nestedSub, fallback)]);
1550
+ }
1551
+ }
1552
+ const innerJsonObj = buildJsonRow(qi, innerJsonPairs);
1553
+ return `SELECT ${qi.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
1554
+ }
1555
+ // Inline ORDER BY only when the dialect's array-agg supports it (PG). For
1556
+ // hasMany this path is reached only when there is no orderClause, so the
1557
+ // argument is `undefined` either way — keeping PG output byte-identical.
1558
+ const inlineOrder = qi.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
1559
+ return `SELECT ${qi.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
1560
+ }
1561
+ // belongsTo / hasOne: return single object. An orderBy picks WHICH row
1562
+ // the LIMIT 1 keeps (deterministic hasOne over a non-unique FK): matching
1563
+ // the batched strategy, which orders its flat follow-up and takes bucket[0].
1564
+ return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause} LIMIT 1`;
1565
+ }
1566
+ /**
1567
+ * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
1568
+ * table through a junction (join) table.
1569
+ *
1570
+ * Shape (no LIMIT/ORDER):
1571
+ * ```sql
1572
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
1573
+ * FROM <target> <talias>
1574
+ * JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
1575
+ * WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
1576
+ * ```
1577
+ *
1578
+ * With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
1579
+ * applies BEFORE aggregation (identical strategy to hasMany).
1580
+ *
1581
+ * Cardinality is always 'many' → empty-array fallback, never NULL.
1582
+ *
1583
+ * IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
1584
+ * {@link collectRelationSubqueryParams} or pipeline batching will desync.
1585
+ */
1586
+ function buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns, includePii) {
1587
+ if (!relDef.through) {
1588
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
1589
+ }
1590
+ const targetTable = relDef.to;
1591
+ const qTarget = qi.q(targetTable);
1592
+ const qJunction = qi.q(relDef.through.table);
1593
+ const qParent = qi.q(parentRef);
1594
+ const jalias = `${talias}j`; // junction alias, distinct from the target alias
1595
+ // JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
1596
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
1597
+ // The target PK is the column(s) the junction's targetKey references. An empty
1598
+ // introspected PK means we cannot know what to JOIN on — fail loudly rather than
1599
+ // silently guessing `id` and generating a wrong JOIN.
1600
+ if (targetMeta.primaryKey.length === 0) {
1601
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
1602
+ `cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
1603
+ }
1604
+ const targetPk = targetMeta.primaryKey;
1605
+ if (targetKeys.length !== targetPk.length) {
1606
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
1607
+ `but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
1608
+ }
1609
+ const joinOn = targetKeys.map((jcol, i) => `${jalias}.${qi.q(jcol)} = ${talias}.${qi.q(targetPk[i])}`).join(' AND ');
1610
+ // Correlation: junction.sourceKey = parent.<referenceKey>.
1611
+ const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
1612
+ const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
1613
+ if (sourceKeys.length !== refKeys.length) {
1614
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
1615
+ `but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
1616
+ }
1617
+ let whereClause = sourceKeys
1618
+ .map((jcol, i) => `${jalias}.${qi.q(jcol)} = ${qParent}.${qi.q(refKeys[i])}`)
1619
+ .join(' AND ');
1620
+ // ORDER BY on the target rows: unified with the top-level orderBy surface
1621
+ // (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
1622
+ // `orderBy: {}` (no defined entries) is treated as absent: it must not
1623
+ // render a dangling `ORDER BY `. Param pushes here land BEFORE the
1624
+ // spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
1625
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
1626
+ let orderClause = '';
1627
+ if (relOrderEntries.length > 0) {
1628
+ orderClause = buildRelationOrderClause(qi, targetTable, targetMeta, talias, relOrderEntries, params);
1629
+ }
1630
+ // Additional WHERE filters on the target — full scalar where surface,
1631
+ // properly parameterized against the target alias.
1632
+ if (spec !== true && spec.where) {
1633
+ const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, talias, spec.where, params);
1634
+ if (extra)
1635
+ whereClause += ` AND ${extra}`;
1636
+ }
1637
+ // Global filter on the target table (mirrors collectRelationSubqueryParams'
1638
+ // m2m branch: after spec.where, before limit).
1639
+ const gfExtra = whereMod.targetGlobalFilterAlias(qi, targetTable, talias, params);
1640
+ if (gfExtra)
1641
+ whereClause += ` AND ${gfExtra}`;
1642
+ // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
1643
+ let limitClause = '';
1644
+ if (spec !== true && spec.limit !== undefined) {
1645
+ limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params)}`;
1646
+ }
1647
+ const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
1648
+ // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
1649
+ // so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
1650
+ if (limitClause || orderClause) {
1651
+ const innerAlias = `${talias}i`;
1652
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${qi.q(c)}`).join(', ')} ` +
1653
+ `${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
1654
+ const innerJsonPairs = targetColumns.map((col) => [
1655
+ targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
1656
+ `${innerAlias}.${qi.q(col)}`,
1657
+ ]);
1658
+ // Nested relations reference the inner alias.
1659
+ if (spec !== true && spec.with) {
1660
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1661
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1662
+ if (!nestedRelDef) {
1663
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1664
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1665
+ }
1666
+ const nestedSub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
1667
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
1668
+ ? qi.dialect.nullJsonLiteral
1669
+ : qi.dialect.emptyJsonArrayLiteral;
1670
+ innerJsonPairs.push([nestedRelName, qi.dialect.wrapJsonSubresult(nestedSub, fallback)]);
1671
+ }
1672
+ }
1673
+ const innerJsonObj = buildJsonRow(qi, innerJsonPairs);
1674
+ return `SELECT ${qi.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
1675
+ }
1676
+ // Simple path: build the json object pairs directly off the target alias,
1677
+ // including any nested relations (correlated to the target alias).
1678
+ const jsonPairs = targetColumns.map((col) => [
1679
+ targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
1680
+ `${talias}.${qi.q(col)}`,
1681
+ ]);
1682
+ if (spec !== true && spec.with) {
1683
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
1684
+ const nestedRelDef = targetMeta.relations[nestedRelName];
1685
+ if (!nestedRelDef) {
1686
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1687
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1688
+ }
1689
+ const nestedSub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
1690
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
1691
+ ? qi.dialect.nullJsonLiteral
1692
+ : qi.dialect.emptyJsonArrayLiteral;
1693
+ jsonPairs.push([nestedRelName, qi.dialect.wrapJsonSubresult(nestedSub, fallback)]);
1694
+ }
1695
+ }
1696
+ const jsonObj = buildJsonRow(qi, jsonPairs);
1697
+ return `SELECT ${qi.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
1698
+ }