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.
@@ -0,0 +1,392 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm — Batched relation loader (the `relationLoadStrategy: 'batched'` path)
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Turbine's default `with`-clause strategy resolves nested relations in ONE SQL
8
+ * statement using correlated `json_agg(json_build_object(...))` subqueries — one
9
+ * probe per parent row (see `buildRelationSubquery` in builder.ts). That is the
10
+ * right default: a single round-trip, and when the child FK columns are indexed
11
+ * each probe is an index seek. But it degrades in two situations:
12
+ *
13
+ * 1. **Missing FK index** — a correlated probe per parent row becomes
14
+ * N-parents × full-table-scan. A batched-loader ORM pays that missing index
15
+ * only ONCE (a single `WHERE fk = ANY($1)` seq-scan), which is why schemas
16
+ * migrated from those ORMs often lack the index the json_agg path needs.
17
+ * 2. **Huge unpaginated result sets** — the JSON wire format
18
+ * (`json_build_object` per row, re-serialized inside `json_agg`) is heavy to
19
+ * encode/decode compared with flat rows.
20
+ *
21
+ * This module implements the alternative, opt-in strategy: run the base query
22
+ * WITHOUT relation subqueries, collect the parent keys, then issue ONE flat
23
+ * follow-up query per relation (`SELECT ... FROM child WHERE fk = ANY($1)`),
24
+ * and stitch the children onto the parents in memory. D relation levels cost D
25
+ * extra round-trips instead of one, but each is a single indexed lookup over a
26
+ * key set, and rows come back flat.
27
+ *
28
+ * ## Design constraints (see CLAUDE.md)
29
+ *
30
+ * - **Same executor / connection path.** Every follow-up query runs through the
31
+ * caller's own executor ({@link RelationLoadContext.exec}) and child query
32
+ * interfaces built on the caller's pool. Inside a `$transaction` that pool is
33
+ * the pinned-connection `txPool`, so batched loads join the transaction — no
34
+ * separate pool checkout per query.
35
+ * - **Identical output shape.** The stitched result is byte-for-byte the same
36
+ * shape the join strategy produces: relation arrays for hasMany/manyToMany
37
+ * (`[]` when empty), single-or-null for hasOne/belongsTo, with the same
38
+ * camelCase keys and Date coercion — because the child rows are parsed by the
39
+ * very same `parseRow`/`buildFindMany` machinery via a child QueryInterface.
40
+ * - **Stitch keys never leak.** To stitch, the follow-up query must select the
41
+ * FK/PK it joins on even when the caller's `select`/`omit` excluded it; the
42
+ * loader adds those columns for the query and strips them from the returned
43
+ * entities afterwards ({@link includeKeysForBatching}).
44
+ *
45
+ * PowDB (powql.ts) has its own batched loaders for the same reasons — this is the
46
+ * clean Postgres/SQL implementation, deliberately NOT shared with PowQL.
47
+ *
48
+ * @module
49
+ */
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.includeKeysForBatching = includeKeysForBatching;
52
+ exports.stripFields = stripFields;
53
+ exports.neededParentKeyFields = neededParentKeyFields;
54
+ exports.loadRelationsBatched = loadRelationsBatched;
55
+ const errors_js_1 = require("../errors.js");
56
+ const schema_js_1 = require("../schema.js");
57
+ /**
58
+ * Max parent keys per follow-up query. On Postgres the whole key set travels as
59
+ * ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
60
+ * only bounds planner/memory cost per statement. Keep it large: every extra
61
+ * chunk is an extra network round-trip, and round-trips are exactly what the
62
+ * batched strategy exists to minimize (a 9-chunk load was measured 2× slower
63
+ * than a single-statement one over a WAN link).
64
+ */
65
+ const MAX_RELATION_KEYS = 32_000;
66
+ /** Nesting cap — parity with the join strategy's depth-10 guard. */
67
+ const MAX_DEPTH = 10;
68
+ /**
69
+ * Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
70
+ * query result, returning the adjusted projection plus the list of fields that
71
+ * were added ONLY for stitching and must be stripped from the final entities.
72
+ *
73
+ * Used both for the base query (parent keys) and each follow-up query (child
74
+ * keys) so a caller's `select: { title: true }` on a relation still stitches even
75
+ * though the FK was not requested — and the FK never appears in the output.
76
+ */
77
+ function includeKeysForBatching(select, omit, fields) {
78
+ const unique = [...new Set(fields)];
79
+ if (select) {
80
+ const next = { ...select };
81
+ const strip = [];
82
+ for (const f of unique) {
83
+ if (!next[f]) {
84
+ next[f] = true;
85
+ strip.push(f); // not requested by the caller — added only to stitch
86
+ }
87
+ }
88
+ return { select: next, omit, strip };
89
+ }
90
+ if (omit) {
91
+ const next = { ...omit };
92
+ const strip = [];
93
+ for (const f of unique) {
94
+ if (next[f]) {
95
+ delete next[f]; // un-omit so the key is present; the caller wanted it gone
96
+ strip.push(f);
97
+ }
98
+ }
99
+ return { select, omit: next, strip };
100
+ }
101
+ // Neither select nor omit — every column is already present; nothing to strip.
102
+ return { select, omit, strip: [] };
103
+ }
104
+ /** Delete stitch-only key fields from each row (no-op when `fields` is empty). */
105
+ function stripFields(rows, fields) {
106
+ if (fields.length === 0)
107
+ return;
108
+ for (const row of rows) {
109
+ for (const f of fields)
110
+ delete row[f];
111
+ }
112
+ }
113
+ /**
114
+ * The set of parent FIELD names a batched load of `withClause` needs present on
115
+ * each parent row in order to stitch (the local key of every requested relation).
116
+ * The caller adds these to the base query and strips the added ones afterwards.
117
+ */
118
+ function neededParentKeyFields(parentMeta, withClause) {
119
+ const fields = new Set();
120
+ for (const [relName, spec] of Object.entries(withClause)) {
121
+ if (!spec)
122
+ continue;
123
+ const rel = parentMeta.relations[relName];
124
+ if (!rel)
125
+ continue; // unknown relation — the join path throws; let the loader surface it
126
+ for (const col of localKeyColumns(rel)) {
127
+ fields.add(parentMeta.reverseColumnMap[col] ?? col);
128
+ }
129
+ }
130
+ return [...fields];
131
+ }
132
+ /**
133
+ * The parent-side key column(s) used to correlate a relation:
134
+ * - hasMany / hasOne: the parent's `referenceKey` (child's FK points at it)
135
+ * - belongsTo: the parent's `foreignKey` (points at the child's PK)
136
+ * - manyToMany: the parent's `referenceKey` (junction's sourceKey → it)
137
+ */
138
+ function localKeyColumns(rel) {
139
+ if (rel.type === 'belongsTo')
140
+ return (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
141
+ return (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
142
+ }
143
+ /** Stringified stitch key — robust to number/uuid/bigint type drift across a join. */
144
+ function keyOf(value) {
145
+ return String(value);
146
+ }
147
+ /**
148
+ * Load every relation in `withClause` for `parents` and attach it onto each row
149
+ * in place. Mirrors the join strategy's output shape exactly. Recurses for nested
150
+ * `with` by re-running itself against the freshly-loaded child rows.
151
+ */
152
+ async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0, path = [ctx.parentMeta.name]) {
153
+ if (depth >= MAX_DEPTH)
154
+ throw new errors_js_1.CircularRelationError([...path, '…']);
155
+ if (parents.length === 0)
156
+ return;
157
+ // Sibling relations are independent (each writes only its own parent[relName]
158
+ // and reads only parent keys), so load them concurrently — on a pool that's
159
+ // real parallelism, inside a transaction pg queues them on the one connection.
160
+ const loads = [];
161
+ for (const [relName, spec] of Object.entries(withClause)) {
162
+ if (!spec)
163
+ continue;
164
+ const rel = ctx.parentMeta.relations[relName];
165
+ if (!rel) {
166
+ throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
167
+ `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
168
+ }
169
+ const options = spec === true ? {} : spec;
170
+ loads.push(rel.type === 'manyToMany'
171
+ ? loadManyToMany(ctx, parents, rel, relName, options, timeout, depth, path)
172
+ : loadToOneOrMany(ctx, parents, rel, relName, options, timeout, depth, path));
173
+ }
174
+ await Promise.all(loads);
175
+ }
176
+ /**
177
+ * hasMany / hasOne / belongsTo: one follow-up `SELECT ... WHERE childKey = ANY($1)`
178
+ * (chunked), grouped by the correlation key and attached (array vs single-or-null).
179
+ */
180
+ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, depth, path) {
181
+ const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
182
+ const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
183
+ if (fk.length > 1 || rk.length > 1) {
184
+ throw new errors_js_1.UnsupportedFeatureError('composite-key batched relation loading', 'relationLoadStrategy: "batched"', `relation "${relName}" — use the default 'join' strategy for composite-key relations`);
185
+ }
186
+ const targetMeta = requireTable(ctx.schema, rel.to, relName);
187
+ // Local key lives on the parent; the correlating key lives on the child.
188
+ // hasMany/hasOne: parent.referenceKey ← child.foreignKey
189
+ // belongsTo: parent.foreignKey → child.referenceKey
190
+ const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
191
+ const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
192
+ const parentKeyField = ctx.parentMeta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
193
+ const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
194
+ const keys = uniqueKeys(parents, parentKeyField);
195
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
196
+ if (keys.length === 0) {
197
+ for (const parent of parents)
198
+ parent[relName] = single ? null : [];
199
+ return;
200
+ }
201
+ // The follow-up must project the child correlation key even if the caller's
202
+ // select/omit excluded it; strip it back off afterwards so the shape matches join.
203
+ const proj = includeKeysForBatching(options.select, options.omit, [childKeyField]);
204
+ const child = ctx.makeChild(rel.to);
205
+ const chunks = [];
206
+ for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
207
+ chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
208
+ // Chunks run concurrently, results concatenated in chunk order. Per-relation
209
+ // `limit` is NOT pushed down here: `LIMIT` on a `fk = ANY($1)` query over the
210
+ // whole batch would cap TOTAL children, not children-per-parent. It is applied
211
+ // client-side per group after stitching (below).
212
+ const chunkResults = await Promise.all(chunks.map(async (chunk) => {
213
+ const deferred = child.buildFindMany({
214
+ where: mergeChildWhere(options.where, childKeyField, chunk),
215
+ select: proj.select,
216
+ omit: proj.omit,
217
+ orderBy: options.orderBy,
218
+ });
219
+ const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
220
+ return deferred.transform(result);
221
+ }));
222
+ const allChildren = chunkResults.flat();
223
+ // Recurse for nested `with` BEFORE stripping keys (children carry their own keys).
224
+ if (options.with && allChildren.length > 0) {
225
+ await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
226
+ }
227
+ const byKey = groupBy(allChildren, childKeyField);
228
+ const limit = options.limit;
229
+ for (const parent of parents) {
230
+ const bucket = byKey.get(keyOf(parent[parentKeyField])) ?? [];
231
+ if (single) {
232
+ parent[relName] = bucket[0] ?? null;
233
+ }
234
+ else {
235
+ parent[relName] = limit !== undefined ? bucket.slice(0, limit) : bucket;
236
+ }
237
+ }
238
+ stripFields(allChildren, proj.strip);
239
+ }
240
+ /**
241
+ * manyToMany: a three-hop batched loader (no join pushdown):
242
+ * (1) read junction rows for all parents (`sourceKey = ANY($1)` chunks),
243
+ * (2) read the target rows for the collected targetKeys,
244
+ * (3) stitch parent → junction targetKeys → target rows in memory.
245
+ * Composite junction/target keys fall back to the join strategy (throw E017).
246
+ */
247
+ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, depth, path) {
248
+ const through = rel.through;
249
+ if (!through) {
250
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
251
+ }
252
+ const sourceJ = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey);
253
+ const targetJ = (0, schema_js_1.normalizeKeyColumns)(through.targetKey);
254
+ const sourceRef = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
255
+ const targetMeta = requireTable(ctx.schema, rel.to, relName);
256
+ if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length !== 1) {
257
+ throw new errors_js_1.UnsupportedFeatureError('composite-key batched manyToMany loading', 'relationLoadStrategy: "batched"', `relation "${relName}" — use the default 'join' strategy for composite-key m2m relations`);
258
+ }
259
+ const sourceJCol = sourceJ[0];
260
+ const targetJCol = targetJ[0];
261
+ const sourceRefCol = sourceRef[0];
262
+ const targetPkCol = targetMeta.primaryKey[0];
263
+ const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
264
+ const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
265
+ const parentKeys = uniqueKeys(parents, parentRefField);
266
+ if (parentKeys.length === 0) {
267
+ for (const parent of parents)
268
+ parent[relName] = [];
269
+ return;
270
+ }
271
+ // (1) Junction rows: sourceKeyVal → [targetKeyVal]. Raw SQL through the caller's
272
+ // executor (the junction table has no relations we need, so no child reader).
273
+ const targetsBySource = new Map();
274
+ const targetValSet = new Set();
275
+ const jTable = ctx.quote(through.table);
276
+ const jSource = ctx.quote(sourceJCol);
277
+ const jTarget = ctx.quote(targetJCol);
278
+ const jChunks = [];
279
+ for (let i = 0; i < parentKeys.length; i += MAX_RELATION_KEYS) {
280
+ jChunks.push(parentKeys.slice(i, i + MAX_RELATION_KEYS));
281
+ }
282
+ const jResults = await Promise.all(jChunks.map((chunk) => {
283
+ const params = [ctx.inClauseParam(chunk)];
284
+ const predicate = ctx.buildInClause(`${jTable}.${jSource}`, ctx.paramPlaceholder(1), false);
285
+ const sql = `SELECT ${jTable}.${jSource} AS "s", ${jTable}.${jTarget} AS "t" FROM ${jTable} WHERE ${predicate}`;
286
+ return ctx.exec(sql, params);
287
+ }));
288
+ for (const { rows } of jResults) {
289
+ for (const row of rows) {
290
+ const sv = keyOf(row.s);
291
+ const tv = row.t;
292
+ if (tv == null)
293
+ continue;
294
+ const bucket = targetsBySource.get(sv);
295
+ if (bucket)
296
+ bucket.push(tv);
297
+ else
298
+ targetsBySource.set(sv, [tv]);
299
+ targetValSet.add(tv);
300
+ }
301
+ }
302
+ // (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
303
+ const proj = includeKeysForBatching(options.select, options.omit, [targetPkField]);
304
+ const child = ctx.makeChild(rel.to);
305
+ const targetVals = [...targetValSet];
306
+ const tChunks = [];
307
+ for (let i = 0; i < targetVals.length; i += MAX_RELATION_KEYS) {
308
+ tChunks.push(targetVals.slice(i, i + MAX_RELATION_KEYS));
309
+ }
310
+ const tResults = await Promise.all(tChunks.map(async (chunk) => {
311
+ const deferred = child.buildFindMany({
312
+ where: mergeChildWhere(options.where, targetPkField, chunk),
313
+ select: proj.select,
314
+ omit: proj.omit,
315
+ orderBy: options.orderBy,
316
+ });
317
+ const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
318
+ return deferred.transform(result);
319
+ }));
320
+ const targetsInOrder = tResults.flat();
321
+ // Nested `with` on the target rows (before stripping their PK).
322
+ if (options.with && targetsInOrder.length > 0) {
323
+ await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, targetsInOrder, options.with, timeout, depth + 1, [...path, relName]);
324
+ }
325
+ const targetByPk = new Map();
326
+ for (const t of targetsInOrder)
327
+ targetByPk.set(keyOf(t[targetPkField]), t);
328
+ // (3) Stitch. Iterate `targetsInOrder` (already ordered by the relation's
329
+ // orderBy) and pick the ones each parent links to, so per-parent order honours
330
+ // orderBy; then apply the per-relation `limit` client-side.
331
+ const limit = options.limit;
332
+ for (const parent of parents) {
333
+ const linked = new Set((targetsBySource.get(keyOf(parent[parentRefField])) ?? []).map(keyOf));
334
+ if (linked.size === 0) {
335
+ parent[relName] = [];
336
+ continue;
337
+ }
338
+ const out = [];
339
+ for (const t of targetsInOrder) {
340
+ if (linked.has(keyOf(t[targetPkField]))) {
341
+ out.push(t);
342
+ if (limit !== undefined && out.length >= limit)
343
+ break;
344
+ }
345
+ }
346
+ parent[relName] = out;
347
+ }
348
+ stripFields(targetsInOrder, proj.strip);
349
+ }
350
+ // ---------------------------------------------------------------------------
351
+ // Small helpers
352
+ // ---------------------------------------------------------------------------
353
+ /** Merge the batched correlation predicate (`key IN chunk`) into the relation's own where. */
354
+ function mergeChildWhere(where, keyField, chunk) {
355
+ return { ...(where ?? {}), [keyField]: { in: chunk } };
356
+ }
357
+ /** Distinct, non-null values of `field` across `rows`. */
358
+ function uniqueKeys(rows, field) {
359
+ const seen = new Set();
360
+ const out = [];
361
+ for (const row of rows) {
362
+ const v = row[field];
363
+ if (v == null)
364
+ continue;
365
+ const k = keyOf(v);
366
+ if (seen.has(k))
367
+ continue;
368
+ seen.add(k);
369
+ out.push(v);
370
+ }
371
+ return out;
372
+ }
373
+ /** Group rows by the stringified value of `field`, preserving input order. */
374
+ function groupBy(rows, field) {
375
+ const map = new Map();
376
+ for (const row of rows) {
377
+ const k = keyOf(row[field]);
378
+ const bucket = map.get(k);
379
+ if (bucket)
380
+ bucket.push(row);
381
+ else
382
+ map.set(k, [row]);
383
+ }
384
+ return map;
385
+ }
386
+ /** Resolve a table's metadata or throw a clear relation error. */
387
+ function requireTable(schema, table, relName) {
388
+ const meta = schema.tables[table];
389
+ if (!meta)
390
+ throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${table}".`);
391
+ return meta;
392
+ }