turbine-orm 0.25.0 → 0.27.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.
package/dist/dialect.d.ts CHANGED
@@ -229,6 +229,17 @@ export interface Dialect {
229
229
  readonly nullJsonLiteral: string;
230
230
  /** Build a JSON object expression from output keys and SQL expressions. */
231
231
  buildJsonObject(pairs: [key: string, expr: string][]): string;
232
+ /**
233
+ * Build a positional JSON ARRAY expression from ordered SQL expressions —
234
+ * the key-less counterpart to {@link buildJsonObject} used by the opt-in
235
+ * `jsonEncoding: 'positional'` mode. Emitting `json_build_array(v1, v2, …)`
236
+ * instead of `json_build_object('k1', v1, …)` drops every repeated key name
237
+ * from every nested object of every row (the decode side maps positions back
238
+ * to keys via a build-time shape descriptor). Postgres-only in v1 — other
239
+ * engines never reach this because the builder gates positional encoding to
240
+ * `dialect.name === 'postgresql'`, so the method is optional on the contract.
241
+ */
242
+ buildJsonArray?(exprs: string[]): string;
232
243
  /** Build a JSON array aggregation expression with a dialect-specific empty-array fallback. */
233
244
  buildJsonArrayAgg(jsonObjectExpr: string, orderBy?: string): string;
234
245
  /**
package/dist/dialect.js CHANGED
@@ -32,8 +32,32 @@ export const postgresDialect = {
32
32
  },
33
33
  buildJsonObject(pairs) {
34
34
  const args = pairs.map(([key, expr]) => `'${this.escapeStringLiteral(key)}', ${expr}`);
35
+ // Postgres caps function calls at 100 arguments (= 50 key/value pairs).
36
+ // Wide tables (or wide select+relation trees) exceed that, so chunk into
37
+ // multiple jsonb_build_object calls merged with `||`, cast back to json.
38
+ if (pairs.length > 50) {
39
+ const chunks = [];
40
+ for (let i = 0; i < args.length; i += 50) {
41
+ chunks.push(`jsonb_build_object(${args.slice(i, i + 50).join(', ')})`);
42
+ }
43
+ return `(${chunks.join(' || ')})::json`;
44
+ }
35
45
  return `json_build_object(${args.join(', ')})`;
36
46
  },
47
+ buildJsonArray(exprs) {
48
+ // Mirror buildJsonObject's chunking at the SAME 50-element threshold: for
49
+ // wide rows, concatenate 50-element jsonb_build_array calls with `||` (which
50
+ // concatenates jsonb arrays) and cast back to json. `jsonb ||` preserves
51
+ // element order, so positions map back to keys unchanged after decode.
52
+ if (exprs.length > 50) {
53
+ const chunks = [];
54
+ for (let i = 0; i < exprs.length; i += 50) {
55
+ chunks.push(`jsonb_build_array(${exprs.slice(i, i + 50).join(', ')})`);
56
+ }
57
+ return `(${chunks.join(' || ')})::json`;
58
+ }
59
+ return `json_build_array(${exprs.join(', ')})`;
60
+ },
37
61
  buildJsonArrayAgg(jsonObjectExpr, orderBy) {
38
62
  const suffix = orderBy ? ` ${orderBy}` : '';
39
63
  return `COALESCE(json_agg(${jsonObjectExpr}${suffix}), ${this.emptyJsonArrayLiteral})`;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Index advisor — finds relation probes that lack index support.
3
+ *
4
+ * Turbine loads `with` relations as correlated subqueries: for every parent row,
5
+ * the child table is probed by its FK column(s) (`child.fk = parent.pk`). Relation
6
+ * filters (`some`/`none`/`every`, `is`/`isNot`) probe the same columns. This
7
+ * strategy outperforms batched loading (`WHERE fk IN (ids)`) when the probed
8
+ * column is indexed — but with NO index, each probe is a full table scan and the
9
+ * cost multiplies by the parent rowcount, while batched loading would pay the
10
+ * scan only once. A missing FK index that is invisible under a batched-loader
11
+ * ORM becomes pathological under a correlated one.
12
+ *
13
+ * This module derives every column set Turbine will probe from SchemaMetadata's
14
+ * relations and checks each against the table's known indexes (and primary key).
15
+ * Consumed by `turbine doctor` (CLI report + fix migration) and by the dev-mode
16
+ * runtime warning in query/builder.ts.
17
+ */
18
+ import { type SchemaMetadata, type TableMetadata } from './schema.js';
19
+ export interface RelationProbe {
20
+ /** Table on which the relation is declared */
21
+ from: string;
22
+ /** Relation field name */
23
+ relation: string;
24
+ /** Relation type */
25
+ type: 'hasMany' | 'hasOne' | 'belongsTo' | 'manyToMany';
26
+ }
27
+ export interface MissingRelationIndex {
28
+ /** Table that gets probed per parent row */
29
+ table: string;
30
+ /** Probed column(s) — equality lookups, so a covering index must LEAD with one of them */
31
+ columns: string[];
32
+ /** Every relation that generates this probe */
33
+ probes: RelationProbe[];
34
+ /** Suggested index name (matches the --fix migration) */
35
+ indexName: string;
36
+ /** CREATE INDEX statement for the fix migration */
37
+ createSql: string;
38
+ /** DROP INDEX statement for the fix migration's DOWN section */
39
+ dropSql: string;
40
+ }
41
+ /**
42
+ * Whether an equality probe on `columns` is served by the table's indexes.
43
+ *
44
+ * All probe columns are equality predicates, so any index whose FIRST column is
45
+ * one of the probed columns gives the planner an index path (a btree can't use
46
+ * a column that isn't in its leading prefix). This is deliberately the loose
47
+ * direction: it never flags a table that has *any* usable index for the probe,
48
+ * at the cost of not demanding the ideal multi-column index. The primary key
49
+ * counts as an index.
50
+ */
51
+ export declare function isProbeIndexed(meta: TableMetadata, columns: string[]): boolean;
52
+ /**
53
+ * Scan every relation in the schema and return the probes with no index support,
54
+ * deduplicated by (table, column set) with all contributing relations attached.
55
+ *
56
+ * Only meaningful when the metadata carries index information (i.e. it came from
57
+ * introspection or a generated client). Callers that may hold index-less metadata
58
+ * should gate on {@link schemaHasIndexInfo} to avoid blanket false positives.
59
+ */
60
+ export declare function findMissingRelationIndexes(schema: SchemaMetadata): MissingRelationIndex[];
61
+ /** True when at least one table in the schema carries index metadata. */
62
+ export declare function schemaHasIndexInfo(schema: SchemaMetadata): boolean;
63
+ /**
64
+ * Single-relation check for the dev-mode runtime warning: the (table, columns)
65
+ * this relation probes and whether that probe is unindexed. Returns null when
66
+ * indexed, unknown, or when the schema carries no index info at all (metadata
67
+ * built without introspection — warning would be a blanket false positive).
68
+ */
69
+ export declare function missingIndexForRelation(schema: SchemaMetadata, relDef: {
70
+ name: string;
71
+ type: RelationProbe['type'];
72
+ to: string;
73
+ foreignKey: string | string[];
74
+ referenceKey: string | string[];
75
+ through?: {
76
+ table: string;
77
+ sourceKey: string | string[];
78
+ };
79
+ }): {
80
+ table: string;
81
+ columns: string[];
82
+ createSql: string;
83
+ } | null;
Binary file
package/dist/mssql.js CHANGED
@@ -766,7 +766,9 @@ function buildForJsonSubquery(dialect, ctx) {
766
766
  return buildForJsonManyToMany(dialect, ctx, { colSelect, buildNested, buildPaging, hasLimit });
767
767
  }
768
768
  const isToOne = relDef.type === 'belongsTo' || relDef.type === 'hasOne';
769
- const correlation = isToOne
769
+ // Correlation direction is about WHERE THE FK LIVES, not cardinality:
770
+ // belongsTo has it on the source; hasMany AND hasOne have it on the target.
771
+ const correlation = relDef.type === 'belongsTo'
770
772
  ? dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
771
773
  : dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
772
774
  // ----- to-one (belongsTo / hasOne): single object, no paging --------------
@@ -0,0 +1,120 @@
1
+ /**
2
+ * turbine-orm — Batched relation loader (the `relationLoadStrategy: 'batched'` path)
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Turbine's default `with`-clause strategy resolves nested relations in ONE SQL
7
+ * statement using correlated `json_agg(json_build_object(...))` subqueries — one
8
+ * probe per parent row (see `buildRelationSubquery` in builder.ts). That is the
9
+ * right default: a single round-trip, and when the child FK columns are indexed
10
+ * each probe is an index seek. But it degrades in two situations:
11
+ *
12
+ * 1. **Missing FK index** — a correlated probe per parent row becomes
13
+ * N-parents × full-table-scan. A batched-loader ORM pays that missing index
14
+ * only ONCE (a single `WHERE fk = ANY($1)` seq-scan), which is why schemas
15
+ * migrated from those ORMs often lack the index the json_agg path needs.
16
+ * 2. **Huge unpaginated result sets** — the JSON wire format
17
+ * (`json_build_object` per row, re-serialized inside `json_agg`) is heavy to
18
+ * encode/decode compared with flat rows.
19
+ *
20
+ * This module implements the alternative, opt-in strategy: run the base query
21
+ * WITHOUT relation subqueries, collect the parent keys, then issue ONE flat
22
+ * follow-up query per relation (`SELECT ... FROM child WHERE fk = ANY($1)`),
23
+ * and stitch the children onto the parents in memory. D relation levels cost D
24
+ * extra round-trips instead of one, but each is a single indexed lookup over a
25
+ * key set, and rows come back flat.
26
+ *
27
+ * ## Design constraints (see CLAUDE.md)
28
+ *
29
+ * - **Same executor / connection path.** Every follow-up query runs through the
30
+ * caller's own executor ({@link RelationLoadContext.exec}) and child query
31
+ * interfaces built on the caller's pool. Inside a `$transaction` that pool is
32
+ * the pinned-connection `txPool`, so batched loads join the transaction — no
33
+ * separate pool checkout per query.
34
+ * - **Identical output shape.** The stitched result is byte-for-byte the same
35
+ * shape the join strategy produces: relation arrays for hasMany/manyToMany
36
+ * (`[]` when empty), single-or-null for hasOne/belongsTo, with the same
37
+ * camelCase keys and Date coercion — because the child rows are parsed by the
38
+ * very same `parseRow`/`buildFindMany` machinery via a child QueryInterface.
39
+ * - **Stitch keys never leak.** To stitch, the follow-up query must select the
40
+ * FK/PK it joins on even when the caller's `select`/`omit` excluded it; the
41
+ * loader adds those columns for the query and strips them from the returned
42
+ * entities afterwards ({@link includeKeysForBatching}).
43
+ *
44
+ * PowDB (powql.ts) has its own batched loaders for the same reasons — this is the
45
+ * clean Postgres/SQL implementation, deliberately NOT shared with PowQL.
46
+ *
47
+ * @module
48
+ */
49
+ import type pg from 'pg';
50
+ import { type SchemaMetadata, type TableMetadata } from '../schema.js';
51
+ import type { ReselectExecutor } from './builder.js';
52
+ import type { WithClause } from './types.js';
53
+ /**
54
+ * A DeferredQuery, minimally typed for what the loader consumes. Kept local to
55
+ * avoid a value import of builder.ts (which imports this module).
56
+ */
57
+ interface Deferred {
58
+ sql: string;
59
+ params: unknown[];
60
+ preparedName?: string;
61
+ transform: (result: pg.QueryResult) => unknown;
62
+ }
63
+ /**
64
+ * The read surface the loader needs from a child QueryInterface: build (but do
65
+ * not execute) a flat findMany. The loader runs the built SQL through
66
+ * {@link RelationLoadContext.exec}, so execution stays on the caller's connection.
67
+ */
68
+ export interface BatchedChildReader {
69
+ buildFindMany(args: Record<string, unknown>): Deferred;
70
+ }
71
+ /**
72
+ * Everything the loader needs from the owning QueryInterface, passed as closures
73
+ * so this module never imports builder.ts at runtime (it is imported BY it).
74
+ */
75
+ export interface RelationLoadContext {
76
+ /** Metadata of the table whose rows are the current `parents`. */
77
+ parentMeta: TableMetadata;
78
+ schema: SchemaMetadata;
79
+ /** Build a child reader for `table`, bound to the caller's pool (tx-safe). */
80
+ makeChild: (table: string) => BatchedChildReader;
81
+ /** Run raw SQL through the caller's executor (same timeout/instrumentation path). */
82
+ exec: ReselectExecutor;
83
+ /** Quote an identifier via the active dialect. */
84
+ quote: (name: string) => string;
85
+ /** Build an `IN`/`ANY` predicate via the active dialect (PG: `expr = ANY($n)`). */
86
+ buildInClause: (expr: string, paramRef: string, negated: boolean) => string;
87
+ /** The single bound value for an `IN` list (PG: the array as-is). */
88
+ inClauseParam: (values: unknown[]) => unknown;
89
+ /** Placeholder for a 1-indexed parameter position (PG: `$n`). */
90
+ paramPlaceholder: (index: number) => string;
91
+ }
92
+ /**
93
+ * Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
94
+ * query result, returning the adjusted projection plus the list of fields that
95
+ * were added ONLY for stitching and must be stripped from the final entities.
96
+ *
97
+ * Used both for the base query (parent keys) and each follow-up query (child
98
+ * keys) so a caller's `select: { title: true }` on a relation still stitches even
99
+ * though the FK was not requested — and the FK never appears in the output.
100
+ */
101
+ export declare function includeKeysForBatching(select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[]): {
102
+ select?: Record<string, boolean>;
103
+ omit?: Record<string, boolean>;
104
+ strip: string[];
105
+ };
106
+ /** Delete stitch-only key fields from each row (no-op when `fields` is empty). */
107
+ export declare function stripFields(rows: Record<string, unknown>[], fields: string[]): void;
108
+ /**
109
+ * The set of parent FIELD names a batched load of `withClause` needs present on
110
+ * each parent row in order to stitch (the local key of every requested relation).
111
+ * The caller adds these to the base query and strips the added ones afterwards.
112
+ */
113
+ export declare function neededParentKeyFields(parentMeta: TableMetadata, withClause: WithClause): string[];
114
+ /**
115
+ * Load every relation in `withClause` for `parents` and attach it onto each row
116
+ * in place. Mirrors the join strategy's output shape exactly. Recurses for nested
117
+ * `with` by re-running itself against the freshly-loaded child rows.
118
+ */
119
+ export declare function loadRelationsBatched(ctx: RelationLoadContext, parents: Record<string, unknown>[], withClause: WithClause, timeout?: number, depth?: number, path?: string[]): Promise<void>;
120
+ export {};
@@ -0,0 +1,386 @@
1
+ /**
2
+ * turbine-orm — Batched relation loader (the `relationLoadStrategy: 'batched'` path)
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Turbine's default `with`-clause strategy resolves nested relations in ONE SQL
7
+ * statement using correlated `json_agg(json_build_object(...))` subqueries — one
8
+ * probe per parent row (see `buildRelationSubquery` in builder.ts). That is the
9
+ * right default: a single round-trip, and when the child FK columns are indexed
10
+ * each probe is an index seek. But it degrades in two situations:
11
+ *
12
+ * 1. **Missing FK index** — a correlated probe per parent row becomes
13
+ * N-parents × full-table-scan. A batched-loader ORM pays that missing index
14
+ * only ONCE (a single `WHERE fk = ANY($1)` seq-scan), which is why schemas
15
+ * migrated from those ORMs often lack the index the json_agg path needs.
16
+ * 2. **Huge unpaginated result sets** — the JSON wire format
17
+ * (`json_build_object` per row, re-serialized inside `json_agg`) is heavy to
18
+ * encode/decode compared with flat rows.
19
+ *
20
+ * This module implements the alternative, opt-in strategy: run the base query
21
+ * WITHOUT relation subqueries, collect the parent keys, then issue ONE flat
22
+ * follow-up query per relation (`SELECT ... FROM child WHERE fk = ANY($1)`),
23
+ * and stitch the children onto the parents in memory. D relation levels cost D
24
+ * extra round-trips instead of one, but each is a single indexed lookup over a
25
+ * key set, and rows come back flat.
26
+ *
27
+ * ## Design constraints (see CLAUDE.md)
28
+ *
29
+ * - **Same executor / connection path.** Every follow-up query runs through the
30
+ * caller's own executor ({@link RelationLoadContext.exec}) and child query
31
+ * interfaces built on the caller's pool. Inside a `$transaction` that pool is
32
+ * the pinned-connection `txPool`, so batched loads join the transaction — no
33
+ * separate pool checkout per query.
34
+ * - **Identical output shape.** The stitched result is byte-for-byte the same
35
+ * shape the join strategy produces: relation arrays for hasMany/manyToMany
36
+ * (`[]` when empty), single-or-null for hasOne/belongsTo, with the same
37
+ * camelCase keys and Date coercion — because the child rows are parsed by the
38
+ * very same `parseRow`/`buildFindMany` machinery via a child QueryInterface.
39
+ * - **Stitch keys never leak.** To stitch, the follow-up query must select the
40
+ * FK/PK it joins on even when the caller's `select`/`omit` excluded it; the
41
+ * loader adds those columns for the query and strips them from the returned
42
+ * entities afterwards ({@link includeKeysForBatching}).
43
+ *
44
+ * PowDB (powql.ts) has its own batched loaders for the same reasons — this is the
45
+ * clean Postgres/SQL implementation, deliberately NOT shared with PowQL.
46
+ *
47
+ * @module
48
+ */
49
+ import { CircularRelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
50
+ import { normalizeKeyColumns } from '../schema.js';
51
+ /**
52
+ * Max parent keys per follow-up query. On Postgres the whole key set travels as
53
+ * ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
54
+ * only bounds planner/memory cost per statement. Keep it large: every extra
55
+ * chunk is an extra network round-trip, and round-trips are exactly what the
56
+ * batched strategy exists to minimize (a 9-chunk load was measured 2× slower
57
+ * than a single-statement one over a WAN link).
58
+ */
59
+ const MAX_RELATION_KEYS = 32_000;
60
+ /** Nesting cap — parity with the join strategy's depth-10 guard. */
61
+ const MAX_DEPTH = 10;
62
+ /**
63
+ * Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
64
+ * query result, returning the adjusted projection plus the list of fields that
65
+ * were added ONLY for stitching and must be stripped from the final entities.
66
+ *
67
+ * Used both for the base query (parent keys) and each follow-up query (child
68
+ * keys) so a caller's `select: { title: true }` on a relation still stitches even
69
+ * though the FK was not requested — and the FK never appears in the output.
70
+ */
71
+ export function includeKeysForBatching(select, omit, fields) {
72
+ const unique = [...new Set(fields)];
73
+ if (select) {
74
+ const next = { ...select };
75
+ const strip = [];
76
+ for (const f of unique) {
77
+ if (!next[f]) {
78
+ next[f] = true;
79
+ strip.push(f); // not requested by the caller — added only to stitch
80
+ }
81
+ }
82
+ return { select: next, omit, strip };
83
+ }
84
+ if (omit) {
85
+ const next = { ...omit };
86
+ const strip = [];
87
+ for (const f of unique) {
88
+ if (next[f]) {
89
+ delete next[f]; // un-omit so the key is present; the caller wanted it gone
90
+ strip.push(f);
91
+ }
92
+ }
93
+ return { select, omit: next, strip };
94
+ }
95
+ // Neither select nor omit — every column is already present; nothing to strip.
96
+ return { select, omit, strip: [] };
97
+ }
98
+ /** Delete stitch-only key fields from each row (no-op when `fields` is empty). */
99
+ export function stripFields(rows, fields) {
100
+ if (fields.length === 0)
101
+ return;
102
+ for (const row of rows) {
103
+ for (const f of fields)
104
+ delete row[f];
105
+ }
106
+ }
107
+ /**
108
+ * The set of parent FIELD names a batched load of `withClause` needs present on
109
+ * each parent row in order to stitch (the local key of every requested relation).
110
+ * The caller adds these to the base query and strips the added ones afterwards.
111
+ */
112
+ export function neededParentKeyFields(parentMeta, withClause) {
113
+ const fields = new Set();
114
+ for (const [relName, spec] of Object.entries(withClause)) {
115
+ if (!spec)
116
+ continue;
117
+ const rel = parentMeta.relations[relName];
118
+ if (!rel)
119
+ continue; // unknown relation — the join path throws; let the loader surface it
120
+ for (const col of localKeyColumns(rel)) {
121
+ fields.add(parentMeta.reverseColumnMap[col] ?? col);
122
+ }
123
+ }
124
+ return [...fields];
125
+ }
126
+ /**
127
+ * The parent-side key column(s) used to correlate a relation:
128
+ * - hasMany / hasOne: the parent's `referenceKey` (child's FK points at it)
129
+ * - belongsTo: the parent's `foreignKey` (points at the child's PK)
130
+ * - manyToMany: the parent's `referenceKey` (junction's sourceKey → it)
131
+ */
132
+ function localKeyColumns(rel) {
133
+ if (rel.type === 'belongsTo')
134
+ return normalizeKeyColumns(rel.foreignKey);
135
+ return normalizeKeyColumns(rel.referenceKey);
136
+ }
137
+ /** Stringified stitch key — robust to number/uuid/bigint type drift across a join. */
138
+ function keyOf(value) {
139
+ return String(value);
140
+ }
141
+ /**
142
+ * Load every relation in `withClause` for `parents` and attach it onto each row
143
+ * in place. Mirrors the join strategy's output shape exactly. Recurses for nested
144
+ * `with` by re-running itself against the freshly-loaded child rows.
145
+ */
146
+ export async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0, path = [ctx.parentMeta.name]) {
147
+ if (depth >= MAX_DEPTH)
148
+ throw new CircularRelationError([...path, '…']);
149
+ if (parents.length === 0)
150
+ return;
151
+ // Sibling relations are independent (each writes only its own parent[relName]
152
+ // and reads only parent keys), so load them concurrently — on a pool that's
153
+ // real parallelism, inside a transaction pg queues them on the one connection.
154
+ const loads = [];
155
+ for (const [relName, spec] of Object.entries(withClause)) {
156
+ if (!spec)
157
+ continue;
158
+ const rel = ctx.parentMeta.relations[relName];
159
+ if (!rel) {
160
+ throw new ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
161
+ `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
162
+ }
163
+ const options = spec === true ? {} : spec;
164
+ loads.push(rel.type === 'manyToMany'
165
+ ? loadManyToMany(ctx, parents, rel, relName, options, timeout, depth, path)
166
+ : loadToOneOrMany(ctx, parents, rel, relName, options, timeout, depth, path));
167
+ }
168
+ await Promise.all(loads);
169
+ }
170
+ /**
171
+ * hasMany / hasOne / belongsTo: one follow-up `SELECT ... WHERE childKey = ANY($1)`
172
+ * (chunked), grouped by the correlation key and attached (array vs single-or-null).
173
+ */
174
+ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, depth, path) {
175
+ const fk = normalizeKeyColumns(rel.foreignKey);
176
+ const rk = normalizeKeyColumns(rel.referenceKey);
177
+ if (fk.length > 1 || rk.length > 1) {
178
+ throw new UnsupportedFeatureError('composite-key batched relation loading', 'relationLoadStrategy: "batched"', `relation "${relName}" — use the default 'join' strategy for composite-key relations`);
179
+ }
180
+ const targetMeta = requireTable(ctx.schema, rel.to, relName);
181
+ // Local key lives on the parent; the correlating key lives on the child.
182
+ // hasMany/hasOne: parent.referenceKey ← child.foreignKey
183
+ // belongsTo: parent.foreignKey → child.referenceKey
184
+ const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
185
+ const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
186
+ const parentKeyField = ctx.parentMeta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
187
+ const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
188
+ const keys = uniqueKeys(parents, parentKeyField);
189
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
190
+ if (keys.length === 0) {
191
+ for (const parent of parents)
192
+ parent[relName] = single ? null : [];
193
+ return;
194
+ }
195
+ // The follow-up must project the child correlation key even if the caller's
196
+ // select/omit excluded it; strip it back off afterwards so the shape matches join.
197
+ const proj = includeKeysForBatching(options.select, options.omit, [childKeyField]);
198
+ const child = ctx.makeChild(rel.to);
199
+ const chunks = [];
200
+ for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
201
+ chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
202
+ // Chunks run concurrently, results concatenated in chunk order. Per-relation
203
+ // `limit` is NOT pushed down here: `LIMIT` on a `fk = ANY($1)` query over the
204
+ // whole batch would cap TOTAL children, not children-per-parent. It is applied
205
+ // client-side per group after stitching (below).
206
+ const chunkResults = await Promise.all(chunks.map(async (chunk) => {
207
+ const deferred = child.buildFindMany({
208
+ where: mergeChildWhere(options.where, childKeyField, chunk),
209
+ select: proj.select,
210
+ omit: proj.omit,
211
+ orderBy: options.orderBy,
212
+ });
213
+ const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
214
+ return deferred.transform(result);
215
+ }));
216
+ const allChildren = chunkResults.flat();
217
+ // Recurse for nested `with` BEFORE stripping keys (children carry their own keys).
218
+ if (options.with && allChildren.length > 0) {
219
+ await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
220
+ }
221
+ const byKey = groupBy(allChildren, childKeyField);
222
+ const limit = options.limit;
223
+ for (const parent of parents) {
224
+ const bucket = byKey.get(keyOf(parent[parentKeyField])) ?? [];
225
+ if (single) {
226
+ parent[relName] = bucket[0] ?? null;
227
+ }
228
+ else {
229
+ parent[relName] = limit !== undefined ? bucket.slice(0, limit) : bucket;
230
+ }
231
+ }
232
+ stripFields(allChildren, proj.strip);
233
+ }
234
+ /**
235
+ * manyToMany: a three-hop batched loader (no join pushdown):
236
+ * (1) read junction rows for all parents (`sourceKey = ANY($1)` chunks),
237
+ * (2) read the target rows for the collected targetKeys,
238
+ * (3) stitch parent → junction targetKeys → target rows in memory.
239
+ * Composite junction/target keys fall back to the join strategy (throw E017).
240
+ */
241
+ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, depth, path) {
242
+ const through = rel.through;
243
+ if (!through) {
244
+ throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
245
+ }
246
+ const sourceJ = normalizeKeyColumns(through.sourceKey);
247
+ const targetJ = normalizeKeyColumns(through.targetKey);
248
+ const sourceRef = normalizeKeyColumns(rel.referenceKey);
249
+ const targetMeta = requireTable(ctx.schema, rel.to, relName);
250
+ if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length !== 1) {
251
+ throw new UnsupportedFeatureError('composite-key batched manyToMany loading', 'relationLoadStrategy: "batched"', `relation "${relName}" — use the default 'join' strategy for composite-key m2m relations`);
252
+ }
253
+ const sourceJCol = sourceJ[0];
254
+ const targetJCol = targetJ[0];
255
+ const sourceRefCol = sourceRef[0];
256
+ const targetPkCol = targetMeta.primaryKey[0];
257
+ const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
258
+ const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
259
+ const parentKeys = uniqueKeys(parents, parentRefField);
260
+ if (parentKeys.length === 0) {
261
+ for (const parent of parents)
262
+ parent[relName] = [];
263
+ return;
264
+ }
265
+ // (1) Junction rows: sourceKeyVal → [targetKeyVal]. Raw SQL through the caller's
266
+ // executor (the junction table has no relations we need, so no child reader).
267
+ const targetsBySource = new Map();
268
+ const targetValSet = new Set();
269
+ const jTable = ctx.quote(through.table);
270
+ const jSource = ctx.quote(sourceJCol);
271
+ const jTarget = ctx.quote(targetJCol);
272
+ const jChunks = [];
273
+ for (let i = 0; i < parentKeys.length; i += MAX_RELATION_KEYS) {
274
+ jChunks.push(parentKeys.slice(i, i + MAX_RELATION_KEYS));
275
+ }
276
+ const jResults = await Promise.all(jChunks.map((chunk) => {
277
+ const params = [ctx.inClauseParam(chunk)];
278
+ const predicate = ctx.buildInClause(`${jTable}.${jSource}`, ctx.paramPlaceholder(1), false);
279
+ const sql = `SELECT ${jTable}.${jSource} AS "s", ${jTable}.${jTarget} AS "t" FROM ${jTable} WHERE ${predicate}`;
280
+ return ctx.exec(sql, params);
281
+ }));
282
+ for (const { rows } of jResults) {
283
+ for (const row of rows) {
284
+ const sv = keyOf(row.s);
285
+ const tv = row.t;
286
+ if (tv == null)
287
+ continue;
288
+ const bucket = targetsBySource.get(sv);
289
+ if (bucket)
290
+ bucket.push(tv);
291
+ else
292
+ targetsBySource.set(sv, [tv]);
293
+ targetValSet.add(tv);
294
+ }
295
+ }
296
+ // (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
297
+ const proj = includeKeysForBatching(options.select, options.omit, [targetPkField]);
298
+ const child = ctx.makeChild(rel.to);
299
+ const targetVals = [...targetValSet];
300
+ const tChunks = [];
301
+ for (let i = 0; i < targetVals.length; i += MAX_RELATION_KEYS) {
302
+ tChunks.push(targetVals.slice(i, i + MAX_RELATION_KEYS));
303
+ }
304
+ const tResults = await Promise.all(tChunks.map(async (chunk) => {
305
+ const deferred = child.buildFindMany({
306
+ where: mergeChildWhere(options.where, targetPkField, chunk),
307
+ select: proj.select,
308
+ omit: proj.omit,
309
+ orderBy: options.orderBy,
310
+ });
311
+ const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
312
+ return deferred.transform(result);
313
+ }));
314
+ const targetsInOrder = tResults.flat();
315
+ // Nested `with` on the target rows (before stripping their PK).
316
+ if (options.with && targetsInOrder.length > 0) {
317
+ await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, targetsInOrder, options.with, timeout, depth + 1, [...path, relName]);
318
+ }
319
+ const targetByPk = new Map();
320
+ for (const t of targetsInOrder)
321
+ targetByPk.set(keyOf(t[targetPkField]), t);
322
+ // (3) Stitch. Iterate `targetsInOrder` (already ordered by the relation's
323
+ // orderBy) and pick the ones each parent links to, so per-parent order honours
324
+ // orderBy; then apply the per-relation `limit` client-side.
325
+ const limit = options.limit;
326
+ for (const parent of parents) {
327
+ const linked = new Set((targetsBySource.get(keyOf(parent[parentRefField])) ?? []).map(keyOf));
328
+ if (linked.size === 0) {
329
+ parent[relName] = [];
330
+ continue;
331
+ }
332
+ const out = [];
333
+ for (const t of targetsInOrder) {
334
+ if (linked.has(keyOf(t[targetPkField]))) {
335
+ out.push(t);
336
+ if (limit !== undefined && out.length >= limit)
337
+ break;
338
+ }
339
+ }
340
+ parent[relName] = out;
341
+ }
342
+ stripFields(targetsInOrder, proj.strip);
343
+ }
344
+ // ---------------------------------------------------------------------------
345
+ // Small helpers
346
+ // ---------------------------------------------------------------------------
347
+ /** Merge the batched correlation predicate (`key IN chunk`) into the relation's own where. */
348
+ function mergeChildWhere(where, keyField, chunk) {
349
+ return { ...(where ?? {}), [keyField]: { in: chunk } };
350
+ }
351
+ /** Distinct, non-null values of `field` across `rows`. */
352
+ function uniqueKeys(rows, field) {
353
+ const seen = new Set();
354
+ const out = [];
355
+ for (const row of rows) {
356
+ const v = row[field];
357
+ if (v == null)
358
+ continue;
359
+ const k = keyOf(v);
360
+ if (seen.has(k))
361
+ continue;
362
+ seen.add(k);
363
+ out.push(v);
364
+ }
365
+ return out;
366
+ }
367
+ /** Group rows by the stringified value of `field`, preserving input order. */
368
+ function groupBy(rows, field) {
369
+ const map = new Map();
370
+ for (const row of rows) {
371
+ const k = keyOf(row[field]);
372
+ const bucket = map.get(k);
373
+ if (bucket)
374
+ bucket.push(row);
375
+ else
376
+ map.set(k, [row]);
377
+ }
378
+ return map;
379
+ }
380
+ /** Resolve a table's metadata or throw a clear relation error. */
381
+ function requireTable(schema, table, relName) {
382
+ const meta = schema.tables[table];
383
+ if (!meta)
384
+ throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${table}".`);
385
+ return meta;
386
+ }