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
@@ -55,8 +55,8 @@
55
55
  */
56
56
  import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
57
57
  import { normalizeKeyColumns } from '../schema.js';
58
- import { isRelationPickOrderBy, sortedEntries } from './filters.js';
59
- import { ownLookup, selectNamesNothingMessage, selectOmitExclusiveMessage } from './utils.js';
58
+ import { dedupeOrderEntries, isOrderBySpec, isRelationPickOrderBy, orderByEntries, sortedEntries } from './filters.js';
59
+ import { markInternalCombinator, ownLookup, selectNamesNothingMessage, selectOmitExclusiveMessage, sqlToPreparedName, } from './utils.js';
60
60
  /**
61
61
  * Max parent keys per follow-up query. On Postgres the whole key set travels as
62
62
  * ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit, it
@@ -68,6 +68,147 @@ import { ownLookup, selectNamesNothingMessage, selectOmitExclusiveMessage } from
68
68
  const MAX_RELATION_KEYS = 32_000;
69
69
  /** Nesting cap, parity with the join strategy's depth-10 guard. */
70
70
  const MAX_DEPTH = 10;
71
+ /**
72
+ * Column the {@link Dialect.buildPartitionLimit} wrapper adds to carry the
73
+ * per-key row number. It is part of that statement's projection, so the loader
74
+ * removes it from every raw row before the child's own transform parses them,
75
+ * or it would surface as an extra field on every entity and break output
76
+ * equality with the join strategy.
77
+ */
78
+ const PARTITION_RANK_COLUMN = '__turbine_rn';
79
+ /**
80
+ * The relation `orderBy` expressed as plain column/direction/nulls triples for
81
+ * the window, or `null` when this pushdown cannot PROVE it would pick the same
82
+ * rows the join plan picks. `null` sends the whole relation back to the
83
+ * client-side slice, which is what every engine without the dialect hook does.
84
+ *
85
+ * ## Only a TOTAL order is eligible, and that is not a conservatism
86
+ *
87
+ * The join plan runs one correlated `… WHERE fk = parent ORDER BY … LIMIT n`
88
+ * per parent; the pushdown runs one flat statement over every parent and ranks
89
+ * with `ROW_NUMBER()`. When the ordering leaves TIES, "the first n" is not a
90
+ * defined set, so the two plans are each free to return different tied rows,
91
+ * and measured on PostgreSQL 16 (5 parents, 32 children, ties and NULLs on the
92
+ * sort column, `limit: 2`) they do:
93
+ *
94
+ * relation shape old (client slice) == join window == join
95
+ * `orderBy: { sortKey: 'asc' }` w/ ties N N
96
+ * no `orderBy` Y N
97
+ * `{ sortKey: { sort:'asc', nulls:'first' } }` N N
98
+ * `orderBy: [{ sortKey }, { id }]` (total) Y Y
99
+ *
100
+ * So the rule is not "the window is wrong", it is that WITHOUT A TOTAL ORDER
101
+ * neither implementation can be right, and the only shape where the pushdown
102
+ * demonstrably regressed something that used to hold is the unordered one. The
103
+ * bound is only taken where the answer is forced: the resolved column list must
104
+ * cover a NOT NULL unique key of the target (see {@link totallyOrdered}), which
105
+ * makes "the n smallest keys per parent" a single set in a single order that
106
+ * both plans must return. Everything else keeps the pre-existing behaviour
107
+ * rather than a silently different row set, because a wrong answer is worse
108
+ * than a slower query.
109
+ *
110
+ * A caller who wants the bound on an unordered relation has an existing,
111
+ * plan-symmetric way to ask for it: `stableRelationOrder` fills a primary-key
112
+ * ascending order into every to-many relation that declares none, on the join
113
+ * plan and the batched plan alike, which makes the shape totally ordered and
114
+ * therefore eligible here.
115
+ *
116
+ * ## What else disqualifies a shape
117
+ *
118
+ * Only the plain direction and {@link OrderBySpec} forms are accepted. A
119
+ * JSON-path, vector-distance, relation `_count` or pick-row ordering compiles
120
+ * to an expression (sometimes with its own bound params) that the loader cannot
121
+ * re-emit here. Same for a key that names no column: the child query build is
122
+ * what reports that, with the proper E003, so this must not throw its own error
123
+ * first.
124
+ *
125
+ * The entry list is DEDUPED first, with the same rule and the same metadata the
126
+ * inner statement's own `orderBy` goes through (the follow-up is compiled as a
127
+ * top-level `findMany` on the child table, which dedupes). Reading the RAW args
128
+ * here made the wrapper's text vary while the statement it wraps did not, which
129
+ * is a correctness divergence (`[{a:'asc'},{a:'desc'}]` sorts by `a ASC` inside
130
+ * and would have ranked by `a ASC, a DESC` outside) and, because the wrapper is
131
+ * named after its own text, an unbounded set of server-side prepared
132
+ * statements: measured, 25 requests that were semantically one sort key left 26
133
+ * named statements on one connection.
134
+ */
135
+ function partitionOrderBy(meta, orderBy) {
136
+ // A child column spelled like the wrapper's rank alias would make the outer
137
+ // `WHERE … <= $n` reference ambiguous (Postgres 42702) and the strip below
138
+ // would delete a real value. It fails closed, but with a raw driver error
139
+ // carrying no TURBINE_ code, on a query the join plan serves fine, so the
140
+ // relation declines the pushdown instead.
141
+ if (meta.allColumns.includes(PARTITION_RANK_COLUMN))
142
+ return null;
143
+ const raw = orderByEntries(orderBy);
144
+ // `meta.name` is the table the child statement is compiled against, so a
145
+ // direction this refuses on a dropped term reads exactly as it does when the
146
+ // child's own compile path refuses it a few lines later.
147
+ const entries = dedupeOrderEntries(meta, raw, meta.name)?.entries ?? raw;
148
+ // No ordering at all is the extreme case of the tie rule above: an unordered
149
+ // window numbers each partition arbitrarily and the join plan's ORDER-BY-less
150
+ // `LIMIT` takes arbitrary rows, and those arbitrary choices are made by
151
+ // different plans over different row sets.
152
+ if (entries.length === 0)
153
+ return null;
154
+ const out = [];
155
+ for (const [key, value] of entries) {
156
+ const column = ownLookup(meta.columnMap, key);
157
+ if (!column || !meta.allColumns.includes(column))
158
+ return null;
159
+ let sort;
160
+ let nulls;
161
+ if (isOrderBySpec(value)) {
162
+ // EXACTLY `{ sort, nulls? }` and nothing else. `isOrderBySpec` only tests
163
+ // for a `sort` key, and a JSON-path ordering can carry one too
164
+ // (`{ path: ['a'], sort: 'asc' }`), which would be read here as a plain
165
+ // column ordering and rank by the wrong expression. Anything with a key
166
+ // outside the pair falls back to the client-side slice.
167
+ for (const k of Object.keys(value))
168
+ if (k !== 'sort' && k !== 'nulls')
169
+ return null;
170
+ sort = value.sort;
171
+ if (value.nulls === 'first')
172
+ nulls = 'FIRST';
173
+ else if (value.nulls === 'last')
174
+ nulls = 'LAST';
175
+ else if (value.nulls !== undefined)
176
+ return null;
177
+ }
178
+ else if (typeof value === 'object' && value !== null) {
179
+ // Relation `_count` / pick-row / vector orderings are objects too.
180
+ return null;
181
+ }
182
+ else {
183
+ sort = value;
184
+ }
185
+ if (sort !== 'asc' && sort !== 'desc')
186
+ return null;
187
+ out.push({ column, direction: sort === 'asc' ? 'ASC' : 'DESC', nulls });
188
+ }
189
+ return totallyOrdered(meta, out.map((o) => o.column))
190
+ ? out
191
+ : null;
192
+ }
193
+ /**
194
+ * True when sorting by `columns` can leave no two rows of `meta` tied: the list
195
+ * covers every column of the primary key, or of some unique constraint, and
196
+ * every column of that key is NOT NULL.
197
+ *
198
+ * The NOT NULL half is not decoration. A UNIQUE constraint over a nullable
199
+ * column admits any number of NULL rows in PostgreSQL (they are all distinct to
200
+ * the constraint and all equal to a sort), so such a key orders the non-null
201
+ * rows and leaves the NULL ones tied with each other, which is exactly the
202
+ * shape this rule exists to refuse. Primary-key columns are NOT NULL by
203
+ * definition, and are checked anyway: metadata that says otherwise is metadata
204
+ * this proof cannot rest on, and declining costs a bound rather than an answer.
205
+ */
206
+ function totallyOrdered(meta, columns) {
207
+ const sorted = new Set(columns);
208
+ const nonNullable = (column) => meta.columns.some((c) => c.name === column && c.nullable === false);
209
+ const covered = (key) => key !== undefined && key.length > 0 && key.every((c) => sorted.has(c) && nonNullable(c));
210
+ return covered(meta.primaryKey) || (meta.uniqueColumns ?? []).some(covered);
211
+ }
71
212
  /**
72
213
  * The default projection of `meta` expressed in FIELD names: which fields the
73
214
  * default (no `select`/`omit`) projection hides, and which it returns. Today the
@@ -456,7 +597,33 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
456
597
  // to-many with a to-one inside it, which is why `include` and `join` were both
457
598
  // clean and seventeen rounds of parity capture missed it.
458
599
  assertProjectionShape(targetMeta.name, options.select, options.omit);
459
- const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
600
+ // Decide the per-parent pushdown BEFORE resolving the projection: when it is
601
+ // on, the window's ORDER BY reads the order columns out of the derived table,
602
+ // so they have to be projected (and, like the correlation keys, stripped
603
+ // again afterwards). `null` means this relation keeps the client-side slice,
604
+ // which is the case for every shape whose ordering does not force WHICH rows
605
+ // the limit keeps. See {@link partitionOrderBy} and {@link boundedChildQuery}.
606
+ const windowOrder = single || options.limit === undefined ? null : partitionOrderBy(targetMeta, options.orderBy);
607
+ const pushDownLimit = windowOrder !== null && ctx.buildPartitionLimit !== undefined;
608
+ // THE ONE PII EXCEPTION IN THE READ PATH, recorded here because the contract
609
+ // in schema.ts says a PII column is excluded from every default projection
610
+ // "at the SQL level". Ordering a LIMITED relation by a PII column force-adds
611
+ // that column to this follow-up's SELECT list (the window reads it out of the
612
+ // derived table), and it is removed from the entities by `proj.strip` before
613
+ // anything returns. The join plan does the same thing in the same case: its
614
+ // wrapped subquery projects `targetMeta.allColumns` into the derived table
615
+ // whenever a relation carries a `limit` or an `orderBy`, and its
616
+ // `json_build_object` then emits only the resolved, PII-free column list. So
617
+ // the SQL-level statement is parity, and no PII value reaches a caller on
618
+ // either plan. What is NOT identical, and is the honest cost of the pushdown:
619
+ // on the join plan the value never leaves the server, while here it crosses
620
+ // the wire and is dropped client-side. A caller who cannot accept that should
621
+ // not order a limited relation by a PII column, which is a shape that already
622
+ // reveals the column's ordering.
623
+ const orderFields = pushDownLimit
624
+ ? (windowOrder ?? []).map((o) => targetMeta.reverseColumnMap[o.column] ?? o.column)
625
+ : [];
626
+ const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...orderFields, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
460
627
  const child = ctx.makeChild(rel.to);
461
628
  const buildChunk = (chunk) => child.buildFindMany({
462
629
  where: mergeChildWhere(options.where, childKeyField, chunk),
@@ -478,13 +645,20 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
478
645
  const chunks = [];
479
646
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
480
647
  chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
481
- // Chunks run concurrently, results concatenated in chunk order. Per-relation
482
- // `limit` is NOT pushed down here: `LIMIT` on a `fk = ANY($1)` query over the
483
- // whole batch would cap TOTAL children, not children-per-parent. It is applied
484
- // client-side per group after stitching (below).
648
+ // Chunks run concurrently, results concatenated in chunk order. A per-relation
649
+ // `limit` is bounded IN THE DATABASE when the dialect can express "at most N
650
+ // rows per correlation key" AND the relation's ordering forces which N those
651
+ // are (see partitionOrderBy / boundedChildQuery); a plain trailing `LIMIT`
652
+ // never can, because this one statement covers every parent. The client-side
653
+ // slice below still runs either way.
485
654
  const chunkResults = await Promise.all(chunks.map(async (chunk) => {
486
655
  const deferred = buildChunk(chunk);
487
- const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
656
+ const bounded = pushDownLimit
657
+ ? boundedChildQuery(ctx, deferred, childKeyCol, windowOrder ?? [], options.limit)
658
+ : deferred;
659
+ const result = await ctx.exec(bounded.sql, bounded.params, bounded.preparedName);
660
+ if (bounded !== deferred)
661
+ stripRankColumn(result);
488
662
  return deferred.transform(result);
489
663
  }));
490
664
  const allChildren = chunkResults.flat();
@@ -514,6 +688,77 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
514
688
  }
515
689
  stripFields(allChildren, proj.strip);
516
690
  }
691
+ /**
692
+ * Rewrite a compiled child follow-up so the ENGINE returns at most `limit` rows
693
+ * per correlation key, instead of returning every matching child and letting the
694
+ * loader throw most of them away.
695
+ *
696
+ * WHY IT IS WORTH THE WRAPPER. The follow-up is one flat statement covering
697
+ * every parent, so a trailing `LIMIT n` would cap the TOTAL, not the per-parent
698
+ * count, and would starve most parents; that is exactly why the limit was
699
+ * applied client-side and why the comment above used to say it could not be
700
+ * pushed down. What it could not do was push down a `LIMIT`. A window function
701
+ * expresses the actual requirement. Measured (200 posts, ~505 comments each,
702
+ * `with: { comments: { limit: 3 } }`, 600 rows kept):
703
+ *
704
+ * strategy rows over the wire peak heap
705
+ * join 200 +0.5 MB
706
+ * batched (before) 101,000 +52.9 MB
707
+ *
708
+ * and this is not an opt-in-only path: `'auto'`, the default since 0.41, routes
709
+ * a relation to the batched loader whenever its probe column is provably
710
+ * unindexed, so on a "posts with 10K comments each" shape the old behaviour is
711
+ * an OOM rather than a slowdown.
712
+ *
713
+ * WHAT IS DELIBERATELY NOT CHANGED. The mirror case is why this is a bound and
714
+ * not a strategy switch: with NO per-relation limit the batched plan beat the
715
+ * join plan 83 ms to 631 ms on the same data, so nothing here touches the
716
+ * unlimited path. The client-side slice also stays: it is a no-op once the
717
+ * engine has bounded each partition, and it is still the whole mechanism on an
718
+ * engine with no {@link Dialect.buildPartitionLimit}, and on every relation
719
+ * whose ordering leaves the choice of rows open ({@link partitionOrderBy}).
720
+ *
721
+ * WHAT THE CALLER GUARANTEES. `orderBy` is non-empty and totally orders the
722
+ * target, so the window's rank and the outer sort agree by construction and the
723
+ * n ranked rows per key are the n rows the join plan's per-parent
724
+ * `ORDER BY … LIMIT n` returns, in the same order. That is the whole reason
725
+ * this rewrite is allowed to change which rows come back over the wire.
726
+ *
727
+ * The prepared-statement name is REDERIVED from the wrapped text, never reused:
728
+ * the child's name is the hash of the INNER statement, and sending different
729
+ * text under a name the connection has already parsed would execute the old
730
+ * statement with these params. An unnamed child (a variable-arity where) stays
731
+ * unnamed.
732
+ */
733
+ function boundedChildQuery(ctx, deferred, partitionColumn, orderBy, limit) {
734
+ const wrap = ctx.buildPartitionLimit;
735
+ if (!wrap)
736
+ return deferred;
737
+ const sql = wrap({
738
+ innerSql: deferred.sql,
739
+ partitionColumn,
740
+ orderBy,
741
+ limitPlaceholder: ctx.paramPlaceholder(deferred.params.length + 1),
742
+ rankColumn: PARTITION_RANK_COLUMN,
743
+ });
744
+ return {
745
+ sql,
746
+ params: [...deferred.params, limit],
747
+ preparedName: deferred.preparedName ? sqlToPreparedName(sql) : deferred.preparedName,
748
+ transform: deferred.transform,
749
+ };
750
+ }
751
+ /**
752
+ * Remove the window wrapper's rank column from every raw row, in place, before
753
+ * the child's own transform parses them. It is the LAST column of the wrapper's
754
+ * projection, so deleting it leaves the remaining key order untouched, which
755
+ * matters: object key order is observable output here (callers stringify
756
+ * results into HTTP bodies, ETags and cache keys).
757
+ */
758
+ function stripRankColumn(result) {
759
+ for (const row of result.rows)
760
+ delete row[PARTITION_RANK_COLUMN];
761
+ }
517
762
  /**
518
763
  * manyToMany: a three-hop batched loader (no join pushdown):
519
764
  * (1) read junction rows for all parents (`sourceKey = ANY($1)` chunks),
@@ -618,6 +863,18 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
618
863
  // (3) Stitch. Iterate `targetsInOrder` (already ordered by the relation's
619
864
  // orderBy) and pick the ones each parent links to, so per-parent order honours
620
865
  // orderBy; then apply the per-relation `limit` client-side.
866
+ //
867
+ // CLIENT-SIDE ON PURPOSE, and not an oversight of the per-parent pushdown the
868
+ // to-one/to-many loader uses. That pushdown ranks the follow-up rows with
869
+ // `ROW_NUMBER() OVER (PARTITION BY <correlation column>)`, and this query has
870
+ // no such column: step (2) reads TARGET rows by their own primary key, the
871
+ // parent correlation lives one hop back in the junction, and a single target
872
+ // row is legitimately linked to many parents, so it must be fetched once and
873
+ // attached several times. Partitioning by parent would mean joining the
874
+ // junction into the follow-up and returning one copy of the target row per
875
+ // link, which spends exactly the bytes the pushdown exists to save; and the
876
+ // bound would still have to respect a `limit` measured per parent, not per
877
+ // fetched row. So the m2m loader keeps the slice below, on every engine.
621
878
  const limit = options.limit;
622
879
  for (const parent of parents) {
623
880
  const linked = new Set((targetsBySource.get(keyOf(parent[parentRefField])) ?? []).map(keyOf));
@@ -766,8 +1023,12 @@ function mergeChildWhere(where, keyField, chunk) {
766
1023
  const correlation = { [keyField]: { in: chunk } };
767
1024
  if (!where)
768
1025
  return correlation;
1026
+ // Branded: this `AND` is TURBINE's, always exactly two branches and decided
1027
+ // here rather than by the caller, so it must not put the follow-up on the
1028
+ // variable-arity (unnamed) prepared-statement path. Same rule and same
1029
+ // reason as the global-filter merge in where.ts.
769
1030
  if (Object.hasOwn(where, keyField))
770
- return { AND: [where, correlation] };
1031
+ return markInternalCombinator({ AND: [where, correlation] });
771
1032
  return { ...where, ...correlation };
772
1033
  }
773
1034
  /**
@@ -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
  *