turbine-orm 0.65.0 → 0.66.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 (142) hide show
  1. package/README.md +34 -32
  2. package/dist/adapters/cockroachdb.js +21 -3
  3. package/dist/adapters/index.d.ts +15 -0
  4. package/dist/adapters/yugabytedb.js +20 -3
  5. package/dist/cjs/adapters/cockroachdb.js +21 -3
  6. package/dist/cjs/adapters/index.d.ts +15 -0
  7. package/dist/cjs/adapters/yugabytedb.js +20 -3
  8. package/dist/cjs/cli/destructive.d.ts +18 -4
  9. package/dist/cjs/cli/destructive.js +230 -122
  10. package/dist/cjs/cli/index.d.ts +21 -4
  11. package/dist/cjs/cli/index.js +119 -22
  12. package/dist/cjs/cli/mcp.d.ts +28 -8
  13. package/dist/cjs/cli/mcp.js +170 -127
  14. package/dist/cjs/cli/migrate.d.ts +134 -13
  15. package/dist/cjs/cli/migrate.js +349 -241
  16. package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
  17. package/dist/cjs/cli/pii-predicate-guard.js +390 -0
  18. package/dist/cjs/cli/prisma-resolve.js +75 -4
  19. package/dist/cjs/cli/prisma-schema.d.ts +17 -1
  20. package/dist/cjs/cli/prisma-schema.js +83 -17
  21. package/dist/cjs/cli/sql-statements.d.ts +125 -0
  22. package/dist/cjs/cli/sql-statements.js +378 -0
  23. package/dist/cjs/cli/studio.js +49 -118
  24. package/dist/cjs/cli/ui.d.ts +1 -1
  25. package/dist/cjs/client.d.ts +43 -0
  26. package/dist/cjs/client.js +125 -6
  27. package/dist/cjs/dialect.d.ts +123 -0
  28. package/dist/cjs/dialect.js +33 -0
  29. package/dist/cjs/errors.d.ts +74 -1
  30. package/dist/cjs/errors.js +239 -25
  31. package/dist/cjs/index-advisor.d.ts +33 -1
  32. package/dist/cjs/index-advisor.js +32 -1
  33. package/dist/cjs/introspect.d.ts +48 -0
  34. package/dist/cjs/introspect.js +222 -91
  35. package/dist/cjs/mssql.js +43 -1
  36. package/dist/cjs/mysql.d.ts +5 -2
  37. package/dist/cjs/mysql.js +202 -17
  38. package/dist/cjs/nested-write.js +6 -1
  39. package/dist/cjs/pipeline-submittable.js +17 -3
  40. package/dist/cjs/pipeline.js +75 -9
  41. package/dist/cjs/powdb.d.ts +23 -0
  42. package/dist/cjs/powdb.js +33 -1
  43. package/dist/cjs/powql.d.ts +61 -9
  44. package/dist/cjs/powql.js +186 -49
  45. package/dist/cjs/prisma-compat.js +160 -41
  46. package/dist/cjs/query/aggregates.d.ts +1 -1
  47. package/dist/cjs/query/aggregates.js +80 -18
  48. package/dist/cjs/query/batched-loader.d.ts +10 -0
  49. package/dist/cjs/query/batched-loader.js +268 -7
  50. package/dist/cjs/query/builder.d.ts +73 -0
  51. package/dist/cjs/query/builder.js +225 -28
  52. package/dist/cjs/query/filters.d.ts +162 -0
  53. package/dist/cjs/query/filters.js +250 -1
  54. package/dist/cjs/query/relations.d.ts +10 -10
  55. package/dist/cjs/query/relations.js +93 -12
  56. package/dist/cjs/query/types.d.ts +14 -1
  57. package/dist/cjs/query/utils.d.ts +146 -2
  58. package/dist/cjs/query/utils.js +210 -4
  59. package/dist/cjs/query/warn-registry.d.ts +10 -0
  60. package/dist/cjs/query/warn-registry.js +10 -0
  61. package/dist/cjs/query/where-compile.d.ts +30 -0
  62. package/dist/cjs/query/where-compile.js +41 -0
  63. package/dist/cjs/query/where.d.ts +128 -13
  64. package/dist/cjs/query/where.js +215 -77
  65. package/dist/cjs/query/writes.d.ts +1 -1
  66. package/dist/cjs/query/writes.js +39 -15
  67. package/dist/cjs/schema-builder.d.ts +2 -1
  68. package/dist/cjs/schema-sql.d.ts +94 -4
  69. package/dist/cjs/schema-sql.js +506 -30
  70. package/dist/cjs/schema.d.ts +3 -1
  71. package/dist/cjs/sqlite.d.ts +6 -0
  72. package/dist/cjs/sqlite.js +151 -10
  73. package/dist/cjs/typed-sql.d.ts +29 -1
  74. package/dist/cjs/typed-sql.js +30 -12
  75. package/dist/cli/destructive.d.ts +18 -4
  76. package/dist/cli/destructive.js +229 -121
  77. package/dist/cli/index.d.ts +21 -4
  78. package/dist/cli/index.js +120 -24
  79. package/dist/cli/mcp.d.ts +28 -8
  80. package/dist/cli/mcp.js +172 -129
  81. package/dist/cli/migrate.d.ts +134 -13
  82. package/dist/cli/migrate.js +347 -238
  83. package/dist/cli/pii-predicate-guard.d.ts +112 -0
  84. package/dist/cli/pii-predicate-guard.js +386 -0
  85. package/dist/cli/prisma-resolve.js +75 -4
  86. package/dist/cli/prisma-schema.d.ts +17 -1
  87. package/dist/cli/prisma-schema.js +83 -17
  88. package/dist/cli/sql-statements.d.ts +125 -0
  89. package/dist/cli/sql-statements.js +373 -0
  90. package/dist/cli/studio.js +49 -118
  91. package/dist/cli/ui.d.ts +1 -1
  92. package/dist/client.d.ts +43 -0
  93. package/dist/client.js +126 -7
  94. package/dist/dialect.d.ts +123 -0
  95. package/dist/dialect.js +33 -0
  96. package/dist/errors.d.ts +74 -1
  97. package/dist/errors.js +228 -19
  98. package/dist/index-advisor.d.ts +33 -1
  99. package/dist/index-advisor.js +31 -1
  100. package/dist/introspect.d.ts +48 -0
  101. package/dist/introspect.js +221 -91
  102. package/dist/mssql.js +44 -2
  103. package/dist/mysql.d.ts +5 -2
  104. package/dist/mysql.js +203 -18
  105. package/dist/nested-write.js +7 -2
  106. package/dist/pipeline-submittable.js +18 -4
  107. package/dist/pipeline.js +76 -10
  108. package/dist/powdb.d.ts +23 -0
  109. package/dist/powdb.js +33 -2
  110. package/dist/powql.d.ts +61 -9
  111. package/dist/powql.js +187 -50
  112. package/dist/prisma-compat.js +160 -41
  113. package/dist/query/aggregates.d.ts +1 -1
  114. package/dist/query/aggregates.js +82 -20
  115. package/dist/query/batched-loader.d.ts +10 -0
  116. package/dist/query/batched-loader.js +270 -9
  117. package/dist/query/builder.d.ts +73 -0
  118. package/dist/query/builder.js +226 -30
  119. package/dist/query/filters.d.ts +162 -0
  120. package/dist/query/filters.js +246 -1
  121. package/dist/query/relations.d.ts +10 -10
  122. package/dist/query/relations.js +94 -14
  123. package/dist/query/types.d.ts +14 -1
  124. package/dist/query/utils.d.ts +146 -2
  125. package/dist/query/utils.js +204 -3
  126. package/dist/query/warn-registry.d.ts +10 -0
  127. package/dist/query/warn-registry.js +10 -0
  128. package/dist/query/where-compile.d.ts +30 -0
  129. package/dist/query/where-compile.js +40 -1
  130. package/dist/query/where.d.ts +128 -13
  131. package/dist/query/where.js +216 -80
  132. package/dist/query/writes.d.ts +1 -1
  133. package/dist/query/writes.js +40 -16
  134. package/dist/schema-builder.d.ts +2 -1
  135. package/dist/schema-sql.d.ts +94 -4
  136. package/dist/schema-sql.js +505 -30
  137. package/dist/schema.d.ts +3 -1
  138. package/dist/sqlite.d.ts +6 -0
  139. package/dist/sqlite.js +151 -10
  140. package/dist/typed-sql.d.ts +29 -1
  141. package/dist/typed-sql.js +30 -12
  142. package/package.json +6 -4
@@ -78,6 +78,147 @@ const utils_js_1 = require("./utils.js");
78
78
  const MAX_RELATION_KEYS = 32_000;
79
79
  /** Nesting cap, parity with the join strategy's depth-10 guard. */
80
80
  const MAX_DEPTH = 10;
81
+ /**
82
+ * Column the {@link Dialect.buildPartitionLimit} wrapper adds to carry the
83
+ * per-key row number. It is part of that statement's projection, so the loader
84
+ * removes it from every raw row before the child's own transform parses them,
85
+ * or it would surface as an extra field on every entity and break output
86
+ * equality with the join strategy.
87
+ */
88
+ const PARTITION_RANK_COLUMN = '__turbine_rn';
89
+ /**
90
+ * The relation `orderBy` expressed as plain column/direction/nulls triples for
91
+ * the window, or `null` when this pushdown cannot PROVE it would pick the same
92
+ * rows the join plan picks. `null` sends the whole relation back to the
93
+ * client-side slice, which is what every engine without the dialect hook does.
94
+ *
95
+ * ## Only a TOTAL order is eligible, and that is not a conservatism
96
+ *
97
+ * The join plan runs one correlated `… WHERE fk = parent ORDER BY … LIMIT n`
98
+ * per parent; the pushdown runs one flat statement over every parent and ranks
99
+ * with `ROW_NUMBER()`. When the ordering leaves TIES, "the first n" is not a
100
+ * defined set, so the two plans are each free to return different tied rows,
101
+ * and measured on PostgreSQL 16 (5 parents, 32 children, ties and NULLs on the
102
+ * sort column, `limit: 2`) they do:
103
+ *
104
+ * relation shape old (client slice) == join window == join
105
+ * `orderBy: { sortKey: 'asc' }` w/ ties N N
106
+ * no `orderBy` Y N
107
+ * `{ sortKey: { sort:'asc', nulls:'first' } }` N N
108
+ * `orderBy: [{ sortKey }, { id }]` (total) Y Y
109
+ *
110
+ * So the rule is not "the window is wrong", it is that WITHOUT A TOTAL ORDER
111
+ * neither implementation can be right, and the only shape where the pushdown
112
+ * demonstrably regressed something that used to hold is the unordered one. The
113
+ * bound is only taken where the answer is forced: the resolved column list must
114
+ * cover a NOT NULL unique key of the target (see {@link totallyOrdered}), which
115
+ * makes "the n smallest keys per parent" a single set in a single order that
116
+ * both plans must return. Everything else keeps the pre-existing behaviour
117
+ * rather than a silently different row set, because a wrong answer is worse
118
+ * than a slower query.
119
+ *
120
+ * A caller who wants the bound on an unordered relation has an existing,
121
+ * plan-symmetric way to ask for it: `stableRelationOrder` fills a primary-key
122
+ * ascending order into every to-many relation that declares none, on the join
123
+ * plan and the batched plan alike, which makes the shape totally ordered and
124
+ * therefore eligible here.
125
+ *
126
+ * ## What else disqualifies a shape
127
+ *
128
+ * Only the plain direction and {@link OrderBySpec} forms are accepted. A
129
+ * JSON-path, vector-distance, relation `_count` or pick-row ordering compiles
130
+ * to an expression (sometimes with its own bound params) that the loader cannot
131
+ * re-emit here. Same for a key that names no column: the child query build is
132
+ * what reports that, with the proper E003, so this must not throw its own error
133
+ * first.
134
+ *
135
+ * The entry list is DEDUPED first, with the same rule and the same metadata the
136
+ * inner statement's own `orderBy` goes through (the follow-up is compiled as a
137
+ * top-level `findMany` on the child table, which dedupes). Reading the RAW args
138
+ * here made the wrapper's text vary while the statement it wraps did not, which
139
+ * is a correctness divergence (`[{a:'asc'},{a:'desc'}]` sorts by `a ASC` inside
140
+ * and would have ranked by `a ASC, a DESC` outside) and, because the wrapper is
141
+ * named after its own text, an unbounded set of server-side prepared
142
+ * statements: measured, 25 requests that were semantically one sort key left 26
143
+ * named statements on one connection.
144
+ */
145
+ function partitionOrderBy(meta, orderBy) {
146
+ // A child column spelled like the wrapper's rank alias would make the outer
147
+ // `WHERE … <= $n` reference ambiguous (Postgres 42702) and the strip below
148
+ // would delete a real value. It fails closed, but with a raw driver error
149
+ // carrying no TURBINE_ code, on a query the join plan serves fine, so the
150
+ // relation declines the pushdown instead.
151
+ if (meta.allColumns.includes(PARTITION_RANK_COLUMN))
152
+ return null;
153
+ const raw = (0, filters_js_1.orderByEntries)(orderBy);
154
+ // `meta.name` is the table the child statement is compiled against, so a
155
+ // direction this refuses on a dropped term reads exactly as it does when the
156
+ // child's own compile path refuses it a few lines later.
157
+ const entries = (0, filters_js_1.dedupeOrderEntries)(meta, raw, meta.name)?.entries ?? raw;
158
+ // No ordering at all is the extreme case of the tie rule above: an unordered
159
+ // window numbers each partition arbitrarily and the join plan's ORDER-BY-less
160
+ // `LIMIT` takes arbitrary rows, and those arbitrary choices are made by
161
+ // different plans over different row sets.
162
+ if (entries.length === 0)
163
+ return null;
164
+ const out = [];
165
+ for (const [key, value] of entries) {
166
+ const column = (0, utils_js_1.ownLookup)(meta.columnMap, key);
167
+ if (!column || !meta.allColumns.includes(column))
168
+ return null;
169
+ let sort;
170
+ let nulls;
171
+ if ((0, filters_js_1.isOrderBySpec)(value)) {
172
+ // EXACTLY `{ sort, nulls? }` and nothing else. `isOrderBySpec` only tests
173
+ // for a `sort` key, and a JSON-path ordering can carry one too
174
+ // (`{ path: ['a'], sort: 'asc' }`), which would be read here as a plain
175
+ // column ordering and rank by the wrong expression. Anything with a key
176
+ // outside the pair falls back to the client-side slice.
177
+ for (const k of Object.keys(value))
178
+ if (k !== 'sort' && k !== 'nulls')
179
+ return null;
180
+ sort = value.sort;
181
+ if (value.nulls === 'first')
182
+ nulls = 'FIRST';
183
+ else if (value.nulls === 'last')
184
+ nulls = 'LAST';
185
+ else if (value.nulls !== undefined)
186
+ return null;
187
+ }
188
+ else if (typeof value === 'object' && value !== null) {
189
+ // Relation `_count` / pick-row / vector orderings are objects too.
190
+ return null;
191
+ }
192
+ else {
193
+ sort = value;
194
+ }
195
+ if (sort !== 'asc' && sort !== 'desc')
196
+ return null;
197
+ out.push({ column, direction: sort === 'asc' ? 'ASC' : 'DESC', nulls });
198
+ }
199
+ return totallyOrdered(meta, out.map((o) => o.column))
200
+ ? out
201
+ : null;
202
+ }
203
+ /**
204
+ * True when sorting by `columns` can leave no two rows of `meta` tied: the list
205
+ * covers every column of the primary key, or of some unique constraint, and
206
+ * every column of that key is NOT NULL.
207
+ *
208
+ * The NOT NULL half is not decoration. A UNIQUE constraint over a nullable
209
+ * column admits any number of NULL rows in PostgreSQL (they are all distinct to
210
+ * the constraint and all equal to a sort), so such a key orders the non-null
211
+ * rows and leaves the NULL ones tied with each other, which is exactly the
212
+ * shape this rule exists to refuse. Primary-key columns are NOT NULL by
213
+ * definition, and are checked anyway: metadata that says otherwise is metadata
214
+ * this proof cannot rest on, and declining costs a bound rather than an answer.
215
+ */
216
+ function totallyOrdered(meta, columns) {
217
+ const sorted = new Set(columns);
218
+ const nonNullable = (column) => meta.columns.some((c) => c.name === column && c.nullable === false);
219
+ const covered = (key) => key !== undefined && key.length > 0 && key.every((c) => sorted.has(c) && nonNullable(c));
220
+ return covered(meta.primaryKey) || (meta.uniqueColumns ?? []).some(covered);
221
+ }
81
222
  /**
82
223
  * The default projection of `meta` expressed in FIELD names: which fields the
83
224
  * default (no `select`/`omit`) projection hides, and which it returns. Today the
@@ -466,7 +607,33 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
466
607
  // to-many with a to-one inside it, which is why `include` and `join` were both
467
608
  // clean and seventeen rounds of parity capture missed it.
468
609
  assertProjectionShape(targetMeta.name, options.select, options.omit);
469
- const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
610
+ // Decide the per-parent pushdown BEFORE resolving the projection: when it is
611
+ // on, the window's ORDER BY reads the order columns out of the derived table,
612
+ // so they have to be projected (and, like the correlation keys, stripped
613
+ // again afterwards). `null` means this relation keeps the client-side slice,
614
+ // which is the case for every shape whose ordering does not force WHICH rows
615
+ // the limit keeps. See {@link partitionOrderBy} and {@link boundedChildQuery}.
616
+ const windowOrder = single || options.limit === undefined ? null : partitionOrderBy(targetMeta, options.orderBy);
617
+ const pushDownLimit = windowOrder !== null && ctx.buildPartitionLimit !== undefined;
618
+ // THE ONE PII EXCEPTION IN THE READ PATH, recorded here because the contract
619
+ // in schema.ts says a PII column is excluded from every default projection
620
+ // "at the SQL level". Ordering a LIMITED relation by a PII column force-adds
621
+ // that column to this follow-up's SELECT list (the window reads it out of the
622
+ // derived table), and it is removed from the entities by `proj.strip` before
623
+ // anything returns. The join plan does the same thing in the same case: its
624
+ // wrapped subquery projects `targetMeta.allColumns` into the derived table
625
+ // whenever a relation carries a `limit` or an `orderBy`, and its
626
+ // `json_build_object` then emits only the resolved, PII-free column list. So
627
+ // the SQL-level statement is parity, and no PII value reaches a caller on
628
+ // either plan. What is NOT identical, and is the honest cost of the pushdown:
629
+ // on the join plan the value never leaves the server, while here it crosses
630
+ // the wire and is dropped client-side. A caller who cannot accept that should
631
+ // not order a limited relation by a PII column, which is a shape that already
632
+ // reveals the column's ordering.
633
+ const orderFields = pushDownLimit
634
+ ? (windowOrder ?? []).map((o) => targetMeta.reverseColumnMap[o.column] ?? o.column)
635
+ : [];
636
+ const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...orderFields, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
470
637
  const child = ctx.makeChild(rel.to);
471
638
  const buildChunk = (chunk) => child.buildFindMany({
472
639
  where: mergeChildWhere(options.where, childKeyField, chunk),
@@ -488,13 +655,20 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
488
655
  const chunks = [];
489
656
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
490
657
  chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
491
- // Chunks run concurrently, results concatenated in chunk order. Per-relation
492
- // `limit` is NOT pushed down here: `LIMIT` on a `fk = ANY($1)` query over the
493
- // whole batch would cap TOTAL children, not children-per-parent. It is applied
494
- // client-side per group after stitching (below).
658
+ // Chunks run concurrently, results concatenated in chunk order. A per-relation
659
+ // `limit` is bounded IN THE DATABASE when the dialect can express "at most N
660
+ // rows per correlation key" AND the relation's ordering forces which N those
661
+ // are (see partitionOrderBy / boundedChildQuery); a plain trailing `LIMIT`
662
+ // never can, because this one statement covers every parent. The client-side
663
+ // slice below still runs either way.
495
664
  const chunkResults = await Promise.all(chunks.map(async (chunk) => {
496
665
  const deferred = buildChunk(chunk);
497
- const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
666
+ const bounded = pushDownLimit
667
+ ? boundedChildQuery(ctx, deferred, childKeyCol, windowOrder ?? [], options.limit)
668
+ : deferred;
669
+ const result = await ctx.exec(bounded.sql, bounded.params, bounded.preparedName);
670
+ if (bounded !== deferred)
671
+ stripRankColumn(result);
498
672
  return deferred.transform(result);
499
673
  }));
500
674
  const allChildren = chunkResults.flat();
@@ -524,6 +698,77 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
524
698
  }
525
699
  stripFields(allChildren, proj.strip);
526
700
  }
701
+ /**
702
+ * Rewrite a compiled child follow-up so the ENGINE returns at most `limit` rows
703
+ * per correlation key, instead of returning every matching child and letting the
704
+ * loader throw most of them away.
705
+ *
706
+ * WHY IT IS WORTH THE WRAPPER. The follow-up is one flat statement covering
707
+ * every parent, so a trailing `LIMIT n` would cap the TOTAL, not the per-parent
708
+ * count, and would starve most parents; that is exactly why the limit was
709
+ * applied client-side and why the comment above used to say it could not be
710
+ * pushed down. What it could not do was push down a `LIMIT`. A window function
711
+ * expresses the actual requirement. Measured (200 posts, ~505 comments each,
712
+ * `with: { comments: { limit: 3 } }`, 600 rows kept):
713
+ *
714
+ * strategy rows over the wire peak heap
715
+ * join 200 +0.5 MB
716
+ * batched (before) 101,000 +52.9 MB
717
+ *
718
+ * and this is not an opt-in-only path: `'auto'`, the default since 0.41, routes
719
+ * a relation to the batched loader whenever its probe column is provably
720
+ * unindexed, so on a "posts with 10K comments each" shape the old behaviour is
721
+ * an OOM rather than a slowdown.
722
+ *
723
+ * WHAT IS DELIBERATELY NOT CHANGED. The mirror case is why this is a bound and
724
+ * not a strategy switch: with NO per-relation limit the batched plan beat the
725
+ * join plan 83 ms to 631 ms on the same data, so nothing here touches the
726
+ * unlimited path. The client-side slice also stays: it is a no-op once the
727
+ * engine has bounded each partition, and it is still the whole mechanism on an
728
+ * engine with no {@link Dialect.buildPartitionLimit}, and on every relation
729
+ * whose ordering leaves the choice of rows open ({@link partitionOrderBy}).
730
+ *
731
+ * WHAT THE CALLER GUARANTEES. `orderBy` is non-empty and totally orders the
732
+ * target, so the window's rank and the outer sort agree by construction and the
733
+ * n ranked rows per key are the n rows the join plan's per-parent
734
+ * `ORDER BY … LIMIT n` returns, in the same order. That is the whole reason
735
+ * this rewrite is allowed to change which rows come back over the wire.
736
+ *
737
+ * The prepared-statement name is REDERIVED from the wrapped text, never reused:
738
+ * the child's name is the hash of the INNER statement, and sending different
739
+ * text under a name the connection has already parsed would execute the old
740
+ * statement with these params. An unnamed child (a variable-arity where) stays
741
+ * unnamed.
742
+ */
743
+ function boundedChildQuery(ctx, deferred, partitionColumn, orderBy, limit) {
744
+ const wrap = ctx.buildPartitionLimit;
745
+ if (!wrap)
746
+ return deferred;
747
+ const sql = wrap({
748
+ innerSql: deferred.sql,
749
+ partitionColumn,
750
+ orderBy,
751
+ limitPlaceholder: ctx.paramPlaceholder(deferred.params.length + 1),
752
+ rankColumn: PARTITION_RANK_COLUMN,
753
+ });
754
+ return {
755
+ sql,
756
+ params: [...deferred.params, limit],
757
+ preparedName: deferred.preparedName ? (0, utils_js_1.sqlToPreparedName)(sql) : deferred.preparedName,
758
+ transform: deferred.transform,
759
+ };
760
+ }
761
+ /**
762
+ * Remove the window wrapper's rank column from every raw row, in place, before
763
+ * the child's own transform parses them. It is the LAST column of the wrapper's
764
+ * projection, so deleting it leaves the remaining key order untouched, which
765
+ * matters: object key order is observable output here (callers stringify
766
+ * results into HTTP bodies, ETags and cache keys).
767
+ */
768
+ function stripRankColumn(result) {
769
+ for (const row of result.rows)
770
+ delete row[PARTITION_RANK_COLUMN];
771
+ }
527
772
  /**
528
773
  * manyToMany: a three-hop batched loader (no join pushdown):
529
774
  * (1) read junction rows for all parents (`sourceKey = ANY($1)` chunks),
@@ -628,6 +873,18 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
628
873
  // (3) Stitch. Iterate `targetsInOrder` (already ordered by the relation's
629
874
  // orderBy) and pick the ones each parent links to, so per-parent order honours
630
875
  // orderBy; then apply the per-relation `limit` client-side.
876
+ //
877
+ // CLIENT-SIDE ON PURPOSE, and not an oversight of the per-parent pushdown the
878
+ // to-one/to-many loader uses. That pushdown ranks the follow-up rows with
879
+ // `ROW_NUMBER() OVER (PARTITION BY <correlation column>)`, and this query has
880
+ // no such column: step (2) reads TARGET rows by their own primary key, the
881
+ // parent correlation lives one hop back in the junction, and a single target
882
+ // row is legitimately linked to many parents, so it must be fetched once and
883
+ // attached several times. Partitioning by parent would mean joining the
884
+ // junction into the follow-up and returning one copy of the target row per
885
+ // link, which spends exactly the bytes the pushdown exists to save; and the
886
+ // bound would still have to respect a `limit` measured per parent, not per
887
+ // fetched row. So the m2m loader keeps the slice below, on every engine.
631
888
  const limit = options.limit;
632
889
  for (const parent of parents) {
633
890
  const linked = new Set((targetsBySource.get(keyOf(parent[parentRefField])) ?? []).map(keyOf));
@@ -776,8 +1033,12 @@ function mergeChildWhere(where, keyField, chunk) {
776
1033
  const correlation = { [keyField]: { in: chunk } };
777
1034
  if (!where)
778
1035
  return correlation;
1036
+ // Branded: this `AND` is TURBINE's, always exactly two branches and decided
1037
+ // here rather than by the caller, so it must not put the follow-up on the
1038
+ // variable-arity (unnamed) prepared-statement path. Same rule and same
1039
+ // reason as the global-filter merge in where.ts.
779
1040
  if (Object.hasOwn(where, keyField))
780
- return { AND: [where, correlation] };
1041
+ return (0, utils_js_1.markInternalCombinator)({ AND: [where, correlation] });
781
1042
  return { ...where, ...correlation };
782
1043
  }
783
1044
  /**
@@ -13,6 +13,15 @@
13
13
  import type pg from 'pg';
14
14
  import type { SchemaMetadata } from '../schema.js';
15
15
  import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, GroupByResult, QueryResult, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause } from './types.js';
16
+ /**
17
+ * Discard the memoized cross-check environment so the next cache hit re-reads
18
+ * `process.env`.
19
+ *
20
+ * @internal Exposed for the tests that toggle these variables in-process (see
21
+ * {@link crossCheckEnv}). Production code sets them before the first query and
22
+ * never again.
23
+ */
24
+ export declare function resetCacheCrossCheckEnv(): void;
16
25
  /**
17
26
  * Marginal cost of keeping a to-one relation on the JOIN plan, per parent row.
18
27
  *
@@ -199,6 +208,16 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
199
208
  * inline, not through the top-level cache).
200
209
  */
201
210
  private lastCacheHit;
211
+ /**
212
+ * Set while a build closure runs when the statement's WHERE (or HAVING)
213
+ * carries a caller-written `AND`/`OR` combinator array, i.e. when its SQL text
214
+ * is a function of an arity the caller chose. Read (and reset) by
215
+ * {@link acquireSql}, which brackets the single `build()` call, so the flag
216
+ * has no lifetime outside that synchronous window and cannot leak between
217
+ * queries. See {@link BuilderCtx.markVariableArity} for the unbounded
218
+ * server-side prepared-statement growth this exists to stop.
219
+ */
220
+ private variableArityShape;
202
221
  private readonly middlewares;
203
222
  private readonly defaultLimit?;
204
223
  private readonly warnOnUnlimited;
@@ -285,6 +304,17 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
285
304
  * to Date as well (otherwise nested dates leak through as strings).
286
305
  */
287
306
  private readonly camelDateFieldCache;
307
+ /**
308
+ * Per-table memo of `Object.entries(meta.relations)`, consumed by the nested
309
+ * row parser (see `getRelationEntries` in relations.ts). Same rationale and
310
+ * same lifetime as {@link camelDateFieldCache}: the metadata is immutable, and
311
+ * the parser reads it once per row.
312
+ */
313
+ private readonly relationEntryCache;
314
+ /** Per-table memo of batched-loader child readers (see {@link batchedChild}). */
315
+ private readonly batchedChildCache;
316
+ /** The (option-invariant) options every batched child is built with. */
317
+ private batchedChildOptions?;
288
318
  /** True when this QI runs inside an active transaction (set via _txScoped option). */
289
319
  private readonly txScoped;
290
320
  /** Original options reference, forwarded to child QIs in nested writes. */
@@ -668,6 +698,28 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
668
698
  * child, and the per-relation `limit` is applied client-side by the loader.
669
699
  */
670
700
  private batchedContext;
701
+ /**
702
+ * The child {@link QueryInterface} the batched loader uses for `table`,
703
+ * memoized per table for the lifetime of this accessor.
704
+ *
705
+ * It used to be constructed fresh for every relation of every query, which
706
+ * threw away that child's SQL template cache each time: a relation follow-up
707
+ * therefore MISSED the cache on every single request and rebuilt its SQL,
708
+ * which is the one thing the template cache exists to avoid, and it allocated
709
+ * a whole QueryInterface (column type maps, camel-date memo, the ctx literal)
710
+ * per relation per query. `childOptions` was likewise a fresh spread per
711
+ * query although it is a pure function of this instance's options.
712
+ *
713
+ * Safe to share across queries: a QueryInterface holds no per-query state
714
+ * beyond the transient build fields, which live and die inside one
715
+ * synchronous `build*` call, and every child is bound to THIS instance's pool
716
+ * (so it still joins an active transaction) with `defaultLimit` cleared and
717
+ * unlimited-warnings silenced, because a relation load must fetch every
718
+ * matching child. Per-query values (`skipGlobalFilters`, `includePii`,
719
+ * `timeout`, `forceCustomPlan`) are passed as ARGUMENTS by the loader, never
720
+ * baked into the child, which is what makes the memo sound.
721
+ */
722
+ private batchedChild;
671
723
  /**
672
724
  * Run a findMany with the batched strategy: execute the base query WITHOUT
673
725
  * relation subqueries (all other clauses intact), then load each relation via
@@ -710,6 +762,27 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
710
762
  * cross-check is warranted.
711
763
  */
712
764
  private acquireSql;
765
+ /**
766
+ * Run one build closure and pair its SQL with the prepared-statement name it
767
+ * should execute under.
768
+ *
769
+ * The `variableArityShape` flag is reset immediately BEFORE the build and read
770
+ * immediately AFTER it, so this method is the flag's entire lifetime: the
771
+ * build walk (`buildWhereClause` / `buildScopedWhere` / the HAVING combinator)
772
+ * is the only thing that can set it, nothing can observe a stale value, and a
773
+ * throw out of `build()` leaves nothing behind to affect the next query.
774
+ *
775
+ * An EMPTY name is how "send this unnamed" travels: `queryWithTimeout` tests
776
+ * the name for truthiness, so `''` takes the plain `(text, values)` form the
777
+ * driver never registers a named statement for. Doing it HERE rather than at
778
+ * the execute seam is what makes it survive the cache: the entry is created
779
+ * once, on the miss, and a later HIT reuses the same (empty) name, so a
780
+ * variable-arity shape can never acquire a name from a warmed template.
781
+ *
782
+ * See {@link BuilderCtx.markVariableArity} for what makes a shape
783
+ * variable-arity and the measured reason it must not be named.
784
+ */
785
+ private buildCacheEntry;
713
786
  /**
714
787
  * Dev-mode SQL-cache lockstep cross-check (see {@link cacheCrossCheckEnabled}).
715
788
  *