turbine-orm 0.49.0 → 0.50.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 (157) hide show
  1. package/README.md +122 -39
  2. package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
  3. package/dist/cjs/adapters/index.d.ts +110 -0
  4. package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
  5. package/dist/cjs/cli/config.d.ts +181 -0
  6. package/dist/cjs/cli/config.js +32 -6
  7. package/dist/cjs/cli/destructive.d.ts +38 -0
  8. package/dist/cjs/cli/index.d.ts +359 -0
  9. package/dist/cjs/cli/index.js +228 -56
  10. package/dist/cjs/cli/loader.d.ts +61 -0
  11. package/dist/cjs/cli/mcp.d.ts +42 -0
  12. package/dist/cjs/cli/migrate.d.ts +356 -0
  13. package/dist/cjs/cli/migrate.js +131 -40
  14. package/dist/cjs/cli/observe-ui.d.ts +1 -0
  15. package/dist/cjs/cli/observe-ui.js +14 -5
  16. package/dist/cjs/cli/observe.d.ts +25 -0
  17. package/dist/cjs/cli/observe.js +49 -12
  18. package/dist/cjs/cli/pii-tags.d.ts +53 -0
  19. package/dist/cjs/cli/prisma-report.d.ts +33 -0
  20. package/dist/cjs/cli/prisma-report.js +73 -0
  21. package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
  22. package/dist/cjs/cli/prisma-resolve.js +1 -0
  23. package/dist/cjs/cli/prisma-schema.d.ts +176 -0
  24. package/dist/cjs/cli/prisma-schema.js +82 -4
  25. package/dist/cjs/cli/rate-limit.d.ts +32 -0
  26. package/dist/cjs/cli/rate-limit.js +45 -0
  27. package/dist/cjs/cli/studio-demo.d.ts +43 -0
  28. package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
  29. package/dist/cjs/cli/studio.d.ts +207 -0
  30. package/dist/cjs/cli/studio.js +136 -71
  31. package/dist/cjs/cli/ui.d.ts +73 -0
  32. package/dist/cjs/cli/ui.js +51 -9
  33. package/dist/cjs/client.d.ts +837 -0
  34. package/dist/cjs/client.js +3 -0
  35. package/dist/cjs/dialect.d.ts +516 -0
  36. package/dist/cjs/dialect.js +37 -12
  37. package/dist/cjs/errors.d.ts +370 -0
  38. package/dist/cjs/generate.d.ts +137 -0
  39. package/dist/cjs/generate.js +39 -6
  40. package/dist/cjs/index-advisor.d.ts +153 -0
  41. package/dist/cjs/index-stats.d.ts +384 -0
  42. package/dist/cjs/index.d.ts +55 -0
  43. package/dist/cjs/index.js +7 -2
  44. package/dist/cjs/introspect.d.ts +269 -0
  45. package/dist/cjs/mssql.d.ts +232 -0
  46. package/dist/cjs/mssql.js +6 -0
  47. package/dist/cjs/mysql.d.ts +173 -0
  48. package/dist/cjs/mysql.js +16 -0
  49. package/dist/cjs/nested-write.d.ts +96 -0
  50. package/dist/cjs/nested-write.js +414 -24
  51. package/dist/cjs/observe.d.ts +115 -0
  52. package/dist/cjs/optional-peer-import.d.cts +72 -0
  53. package/dist/cjs/pipeline-submittable.d.ts +93 -0
  54. package/dist/cjs/pipeline.d.ts +71 -0
  55. package/dist/cjs/powdb-introspect.d.ts +84 -0
  56. package/dist/cjs/powdb.d.ts +931 -0
  57. package/dist/cjs/powdb.js +106 -21
  58. package/dist/cjs/powql.d.ts +592 -0
  59. package/dist/cjs/powql.js +42 -6
  60. package/dist/cjs/prisma-compat.d.ts +283 -0
  61. package/dist/cjs/prisma-compat.js +167 -9
  62. package/dist/cjs/query/aggregates.d.ts +92 -0
  63. package/dist/cjs/query/aggregates.js +7 -3
  64. package/dist/cjs/query/batched-loader.d.ts +193 -0
  65. package/dist/cjs/query/builder.d.ts +849 -0
  66. package/dist/cjs/query/builder.js +571 -65
  67. package/dist/cjs/query/compound-unique.d.ts +51 -0
  68. package/dist/cjs/query/deferred.d.ts +223 -0
  69. package/dist/cjs/query/filters.d.ts +201 -0
  70. package/dist/cjs/query/index.d.ts +14 -0
  71. package/dist/cjs/query/index.js +6 -1
  72. package/dist/cjs/query/relations.d.ts +609 -0
  73. package/dist/cjs/query/relations.js +693 -46
  74. package/dist/cjs/query/types.d.ts +1300 -0
  75. package/dist/cjs/query/utils.d.ts +209 -0
  76. package/dist/cjs/query/utils.js +208 -1
  77. package/dist/cjs/query/warn-registry.d.ts +68 -0
  78. package/dist/cjs/query/warn-registry.js +9 -0
  79. package/dist/cjs/query/where-compile.d.ts +139 -0
  80. package/dist/cjs/query/where.d.ts +548 -0
  81. package/dist/cjs/query/where.js +58 -22
  82. package/dist/cjs/query/writes.d.ts +172 -0
  83. package/dist/cjs/query/writes.js +105 -12
  84. package/dist/cjs/realtime.d.ts +70 -0
  85. package/dist/cjs/schema-builder.d.ts +354 -0
  86. package/dist/cjs/schema-metadata.d.ts +83 -0
  87. package/dist/cjs/schema-sql.d.ts +217 -0
  88. package/dist/cjs/schema-sql.js +23 -5
  89. package/dist/cjs/schema.d.ts +356 -0
  90. package/dist/cjs/schema.js +125 -0
  91. package/dist/cjs/seed.d.ts +15 -0
  92. package/dist/cjs/serverless.d.ts +142 -0
  93. package/dist/cjs/sqlite.d.ts +143 -0
  94. package/dist/cjs/sqlite.js +4 -0
  95. package/dist/cjs/typed-sql.d.ts +102 -0
  96. package/dist/cli/config.d.ts +18 -4
  97. package/dist/cli/config.js +31 -6
  98. package/dist/cli/index.d.ts +123 -0
  99. package/dist/cli/index.js +223 -58
  100. package/dist/cli/migrate.d.ts +59 -10
  101. package/dist/cli/migrate.js +128 -41
  102. package/dist/cli/observe-ui.d.ts +1 -1
  103. package/dist/cli/observe-ui.js +14 -5
  104. package/dist/cli/observe.d.ts +7 -1
  105. package/dist/cli/observe.js +48 -12
  106. package/dist/cli/prisma-report.d.ts +14 -0
  107. package/dist/cli/prisma-report.js +72 -0
  108. package/dist/cli/prisma-resolve.d.ts +6 -0
  109. package/dist/cli/prisma-resolve.js +1 -0
  110. package/dist/cli/prisma-schema.d.ts +62 -2
  111. package/dist/cli/prisma-schema.js +81 -4
  112. package/dist/cli/rate-limit.d.ts +32 -0
  113. package/dist/cli/rate-limit.js +40 -0
  114. package/dist/cli/studio.d.ts +5 -5
  115. package/dist/cli/studio.js +135 -70
  116. package/dist/cli/ui.d.ts +1 -1
  117. package/dist/cli/ui.js +51 -9
  118. package/dist/client.d.ts +40 -0
  119. package/dist/client.js +3 -0
  120. package/dist/dialect.d.ts +17 -1
  121. package/dist/dialect.js +37 -12
  122. package/dist/generate.js +40 -7
  123. package/dist/index.d.ts +1 -1
  124. package/dist/index.js +1 -1
  125. package/dist/mssql.js +6 -0
  126. package/dist/mysql.js +16 -0
  127. package/dist/nested-write.d.ts +2 -0
  128. package/dist/nested-write.js +415 -25
  129. package/dist/powdb.d.ts +4 -2
  130. package/dist/powdb.js +106 -21
  131. package/dist/powql.d.ts +5 -0
  132. package/dist/powql.js +42 -6
  133. package/dist/prisma-compat.d.ts +2 -0
  134. package/dist/prisma-compat.js +166 -8
  135. package/dist/query/aggregates.js +7 -3
  136. package/dist/query/builder.d.ts +292 -21
  137. package/dist/query/builder.js +570 -64
  138. package/dist/query/deferred.d.ts +39 -0
  139. package/dist/query/index.d.ts +1 -1
  140. package/dist/query/index.js +1 -1
  141. package/dist/query/relations.d.ts +173 -5
  142. package/dist/query/relations.js +688 -47
  143. package/dist/query/types.d.ts +123 -39
  144. package/dist/query/utils.d.ts +116 -0
  145. package/dist/query/utils.js +198 -0
  146. package/dist/query/warn-registry.d.ts +9 -0
  147. package/dist/query/warn-registry.js +9 -0
  148. package/dist/query/where.d.ts +38 -1
  149. package/dist/query/where.js +58 -23
  150. package/dist/query/writes.d.ts +42 -1
  151. package/dist/query/writes.js +104 -13
  152. package/dist/schema-sql.d.ts +14 -0
  153. package/dist/schema-sql.js +23 -5
  154. package/dist/schema.d.ts +38 -0
  155. package/dist/schema.js +123 -0
  156. package/dist/sqlite.js +4 -0
  157. package/package.json +77 -28
@@ -45,7 +45,7 @@ var __importStar = (this && this.__importStar) || (function () {
45
45
  };
46
46
  })();
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
- exports.QueryInterface = void 0;
48
+ exports.QueryInterface = exports.AUTO_TO_ONE_JOIN_ROWS_MAX = exports.AUTO_TO_ONE_JOIN_ROWS_MIN = exports.AUTO_TO_ONE_JOIN_MAX_ROWS = exports.AUTO_ASSUMED_ROUND_TRIP_MS = exports.AUTO_JOIN_PENALTY_MS_PER_ROW = void 0;
49
49
  const dialect_js_1 = require("../dialect.js");
50
50
  const errors_js_1 = require("../errors.js");
51
51
  const index_advisor_js_1 = require("../index-advisor.js");
@@ -105,6 +105,71 @@ function cacheCrossCheckMode() {
105
105
  * so this set is only ever touched on the `'sampled'` path.
106
106
  */
107
107
  const loggedCacheMismatchFingerprints = new Set();
108
+ /**
109
+ * Marginal cost of keeping a to-one relation on the JOIN plan, per parent row.
110
+ *
111
+ * A to-one relation compiled into the join plan is a CORRELATED subquery: the
112
+ * engine re-evaluates it once per parent row, so the join plan costs roughly
113
+ * `AUTO_JOIN_PENALTY_MS_PER_ROW * parentRows` more CPU than one flat follow-up
114
+ * query, no matter how well indexed the correlation column is. The batched plan
115
+ * pays that back as a second statement, i.e. one extra round trip.
116
+ *
117
+ * Break-even is therefore, to a first approximation:
118
+ *
119
+ * parentRows = roundTripMs / AUTO_JOIN_PENALTY_MS_PER_ROW
120
+ *
121
+ * Measured (PostgreSQL 17, hasOne over a UNIQUE FK, 10K-row parent table,
122
+ * median of 15 reps per point) at two very different link speeds:
123
+ *
124
+ * link RTT penalty/row break-even observed crossover
125
+ * ─────────────────── ──────── ───────────── ──────────── ──────────────────
126
+ * loopback TCP 0.118ms 0.000711ms ~166-236 between 200 and 400
127
+ * +1ms/direction 2.683ms 0.000717ms ~3744-3993 between 3000 and 5000
128
+ *
129
+ * The two things that matters most in that table: the per-row penalty is
130
+ * essentially IDENTICAL across the two links (it is a property of the plan, not
131
+ * the wire), while the break-even moved by 17x. So the break-even is a function
132
+ * of the deployment's round-trip time and NOTHING ELSE that is knowable at plan
133
+ * time. That is why this is expressed as a per-row cost and a round-trip time
134
+ * rather than as a hard-coded row count: a row count tuned on a Unix socket is
135
+ * off by ~20x for a cross-region deployment, and vice versa. Concretely, the
136
+ * previously shipped flat `1000` was simultaneously too HIGH on loopback
137
+ * (up to 1.44x slower than the better plan just under the cliff) and too LOW
138
+ * over a 2.7ms link (1.26x slower just above it).
139
+ */
140
+ exports.AUTO_JOIN_PENALTY_MS_PER_ROW = 0.0007;
141
+ /**
142
+ * Round-trip time assumed before this process has observed a real one, chosen
143
+ * as a typical same-region managed-Postgres latency (app and database in one
144
+ * region over TCP). It is stated as a LATENCY rather than a row count so the
145
+ * assumption is visible and re-derivable: at
146
+ * {@link AUTO_JOIN_PENALTY_MS_PER_ROW} it yields exactly the 1000-row default
147
+ * this heuristic has always shipped, so an unmeasured process behaves exactly
148
+ * as before.
149
+ */
150
+ exports.AUTO_ASSUMED_ROUND_TRIP_MS = 0.7;
151
+ /**
152
+ * Default parent-row ceiling under which `'auto'` keeps a to-one relation on
153
+ * the single-statement join plan: {@link AUTO_ASSUMED_ROUND_TRIP_MS} divided by
154
+ * {@link AUTO_JOIN_PENALTY_MS_PER_ROW}. Used until the process has measured its
155
+ * own round-trip time, and whenever measurement is unavailable.
156
+ */
157
+ exports.AUTO_TO_ONE_JOIN_MAX_ROWS = Math.round(exports.AUTO_ASSUMED_ROUND_TRIP_MS / exports.AUTO_JOIN_PENALTY_MS_PER_ROW);
158
+ /**
159
+ * Clamps on the MEASURED threshold (an explicit `autoToOneJoinMaxRows` is an
160
+ * instruction, not an estimate, and bypasses both).
161
+ *
162
+ * The lower clamp matters: on a very fast link the formula can drop the
163
+ * threshold to a few dozen rows, and the sweep shows the join plan winning by
164
+ * up to 1.83x on a handful of parent rows, where the second statement's fixed
165
+ * cost dwarfs everything. Holding the floor at 100 rows keeps those small
166
+ * queries on the join plan; the cost of doing so, in the band where batched has
167
+ * just started to win, is under 1.2x. The upper clamp is a sanity bound for a
168
+ * pathological latency reading (a 70ms measurement would otherwise ask for
169
+ * 100K rows).
170
+ */
171
+ exports.AUTO_TO_ONE_JOIN_ROWS_MIN = 100;
172
+ exports.AUTO_TO_ONE_JOIN_ROWS_MAX = 100_000;
108
173
  /**
109
174
  * Strict structural equality for a single SQL parameter value. Handles the
110
175
  * value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
@@ -253,6 +318,23 @@ class QueryInterface {
253
318
  relationLoadStrategy;
254
319
  /** Client-level default for {@link applyStableRelationOrder} (off unless configured). */
255
320
  stableRelationOrder;
321
+ /**
322
+ * Client-level opt-in: apply an implicit primary-key ascending `ORDER BY` to a
323
+ * `findMany` that paginates (`limit`/`take`/`offset`) but declares no
324
+ * `orderBy`. OFF by default in core, see {@link applyImplicitPkOrdering}.
325
+ */
326
+ implicitPkOrdering;
327
+ /**
328
+ * Explicitly configured parent-row ceiling for the `'auto'` to-one rule, or
329
+ * `undefined` to derive it from the observed round-trip time. See
330
+ * {@link autoToOneThreshold}.
331
+ */
332
+ autoToOneJoinMaxRowsOption;
333
+ /**
334
+ * Deployment round-trip time in milliseconds, from which the to-one threshold
335
+ * is derived. See {@link autoToOneThreshold}.
336
+ */
337
+ autoRoundTripMs;
256
338
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
257
339
  jsonEncoding;
258
340
  /**
@@ -374,6 +456,12 @@ class QueryInterface {
374
456
  this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
375
457
  this.relationLoadStrategy = options?.relationLoadStrategy ?? 'auto';
376
458
  this.stableRelationOrder = options?.stableRelationOrder === true;
459
+ this.implicitPkOrdering = options?.implicitPkOrdering === true;
460
+ const autoToOne = options?.autoToOneJoinMaxRows;
461
+ this.autoToOneJoinMaxRowsOption =
462
+ autoToOne !== undefined && Number.isFinite(autoToOne) && autoToOne >= 0 ? Math.floor(autoToOne) : undefined;
463
+ const rtt = options?.autoRoundTripMs;
464
+ this.autoRoundTripMs = rtt !== undefined && Number.isFinite(rtt) && rtt > 0 ? rtt : undefined;
377
465
  this.jsonEncoding = options?.jsonEncoding ?? 'object';
378
466
  // Only retain the map when it has at least one entry, so `globalFilters`
379
467
  // stays `undefined` (and every merge path a no-op) for the common case.
@@ -440,7 +528,8 @@ class QueryInterface {
440
528
  camelDateFieldCache: this.camelDateFieldCache,
441
529
  limitOneClause: () => this.limitOneClause(),
442
530
  buildPagination: (limitPh, offsetPh, hasOrderBy) => this.buildPagination(limitPh, offsetPh, hasOrderBy),
443
- paginationRef: (value, params) => this.paginationRef(value, params),
531
+ paginationRef: (value, params, arg) => this.paginationRef(value, params, arg),
532
+ paginationValue: (value, arg) => this.paginationValue(value, arg),
444
533
  };
445
534
  }
446
535
  /** Quote an identifier through the active SQL dialect. */
@@ -491,19 +580,33 @@ class QueryInterface {
491
580
  }
492
581
  return ' LIMIT 1';
493
582
  }
583
+ /**
584
+ * Coerce a LIMIT/OFFSET argument and validate it as a non-negative safe
585
+ * integer. Numeric strings (`'5'`) coerce; everything else (`NaN`, a
586
+ * non-numeric string, a negative, a fractional or out-of-safe-range number)
587
+ * throws {@link ValidationError} (E003) naming the argument and the table.
588
+ *
589
+ * This runs on EVERY pagination path, parameterized as well as inlined: a
590
+ * bound `NaN` serializes as SQL NULL, and Postgres reads `LIMIT NULL` as
591
+ * "no limit", so an unvalidated value silently turns a paginated query into
592
+ * a full-table read (and a bad OFFSET silently disappears).
593
+ */
594
+ paginationValue(value, arg) {
595
+ const n = Number(value);
596
+ if (!Number.isSafeInteger(n) || n < 0) {
597
+ throw new errors_js_1.ValidationError(`[turbine] ${arg} on "${this.table}" must be a non-negative integer, received: ${String(value)}`);
598
+ }
599
+ return n;
600
+ }
494
601
  /**
495
602
  * Validate a LIMIT/OFFSET value as a non-negative integer and return it as an
496
603
  * inline SQL literal. Used only on `dialect.inlineLimitOffset` engines (MySQL).
497
604
  * The input is always a Turbine-controlled pagination value, never a raw user
498
- * string and this guard guarantees the output is `String` of a validated
605
+ * string, and this guard guarantees the output is `String` of a validated
499
606
  * integer, so inlining cannot inject SQL.
500
607
  */
501
- limitOffsetLiteral(value) {
502
- const n = Number(value);
503
- if (!Number.isInteger(n) || n < 0) {
504
- throw new errors_js_1.ValidationError(`LIMIT/OFFSET must be a non-negative integer, received: ${String(value)}`);
505
- }
506
- return String(n);
608
+ limitOffsetLiteral(value, arg) {
609
+ return String(this.paginationValue(value, arg));
507
610
  }
508
611
  /**
509
612
  * Resolve a LIMIT/OFFSET value to either an inline literal (no param pushed, on
@@ -512,11 +615,11 @@ class QueryInterface {
512
615
  * the param order stays mirrored; PG/SQLite/SQL Server keep parameterizing and
513
616
  * stay byte-identical.
514
617
  */
515
- paginationRef(value, params) {
618
+ paginationRef(value, params, arg) {
516
619
  if (this.dialect.inlineLimitOffset) {
517
- return this.limitOffsetLiteral(value);
620
+ return this.limitOffsetLiteral(value, arg);
518
621
  }
519
- params.push(Number(value));
622
+ params.push(this.paginationValue(value, arg));
520
623
  return this.p(params.length);
521
624
  }
522
625
  /**
@@ -605,6 +708,180 @@ class QueryInterface {
605
708
  return out ?? withClause;
606
709
  }
607
710
  // -------------------------------------------------------------------------
711
+ // Deterministic pagination (unordered LIMIT/OFFSET)
712
+ // -------------------------------------------------------------------------
713
+ /**
714
+ * The primary key of this table as an ascending `orderBy`, in DECLARATION
715
+ * order (a composite PK orders on every column), or `undefined` for a PK-less
716
+ * table. Field names are the camelCase accessor names, so the emitted SQL goes
717
+ * through the normal column mapping.
718
+ */
719
+ pkOrderBy() {
720
+ const pk = this.tableMeta.primaryKey ?? [];
721
+ if (pk.length === 0)
722
+ return undefined;
723
+ const fields = pk.map((c) => this.tableMeta.reverseColumnMap[c] ?? c);
724
+ return fields.length === 1 ? { [fields[0]]: 'asc' } : fields.map((f) => ({ [f]: 'asc' }));
725
+ }
726
+ /**
727
+ * The field names a `cursor` actually seeks on (its own keys with a defined
728
+ * value), in the canonical sorted order the cursor conditions are built in.
729
+ * Empty for a missing cursor or one whose every value is `undefined` (which
730
+ * emits no seek condition at all, so it does not paginate).
731
+ */
732
+ cursorFields(cursor) {
733
+ if (!cursor || typeof cursor !== 'object')
734
+ return [];
735
+ return Object.entries(cursor)
736
+ .filter(([, v]) => v !== undefined)
737
+ .map(([k]) => k)
738
+ .sort();
739
+ }
740
+ /**
741
+ * The ascending ordering implied by a `cursor`, or `undefined` when the shape
742
+ * is too ambiguous to order safely.
743
+ *
744
+ * A cursor seek emits `col > $n` per field (`<` when the orderBy says desc),
745
+ * so the ONLY ordering coherent with it is on the cursor's own field: ordering
746
+ * a seek on column X by column Y walks the table in an order the seek does not
747
+ * follow, which skips and repeats rows just as badly as no order at all. That
748
+ * is why this orders on the cursor field rather than blindly on the primary
749
+ * key when the two differ.
750
+ *
751
+ * Returns `undefined` (warn, inject nothing) for two shapes:
752
+ * - a MULTI-field cursor. `a > $1 AND b > $2` is a conjunction, not a proper
753
+ * composite keyset seek (`(a, b) > ($1, $2)`), so no single ORDER BY makes
754
+ * it correct. Injecting `(a asc, b asc)` would dress a broken seek up as a
755
+ * sound one.
756
+ * - a field that does not resolve to a real column. Column validation belongs
757
+ * to the normal build path, which raises a precise error; synthesizing an
758
+ * ORDER BY on it here would only change which error the caller sees.
759
+ */
760
+ cursorOrderBy(cursor) {
761
+ const fields = this.cursorFields(cursor);
762
+ if (fields.length !== 1)
763
+ return undefined;
764
+ const field = fields[0];
765
+ try {
766
+ this.toColumn(field);
767
+ }
768
+ catch {
769
+ return undefined;
770
+ }
771
+ return { [field]: 'asc' };
772
+ }
773
+ /**
774
+ * Whether a findMany paginates (`limit` / `take` / `offset` / `cursor`) but
775
+ * declares no ordering, which makes the returned page NON-DETERMINISTIC:
776
+ * Postgres is free to return different rows for the same unordered `LIMIT`
777
+ * once the heap changes underneath it, so a row can appear on two pages or on
778
+ * none.
779
+ *
780
+ * `cursor` counts, and is the worst case rather than an exception: a keyset
781
+ * seek with no ORDER BY is exactly this bug (`WHERE id > $1 LIMIT $2` walks
782
+ * the heap in whatever order the plan happens to produce). An empty orderBy
783
+ * (`[]`, or an object whose every value is `undefined`) counts as absent,
784
+ * because it emits no ordering.
785
+ *
786
+ * `distinct` is still excluded: that path re-orders in an outer wrapper around
787
+ * a `DISTINCT ON` whose ordering picks the representative row, so an implicit
788
+ * key would change which rows come back, not just their order.
789
+ */
790
+ isUnorderedPage(args) {
791
+ if (!args)
792
+ return false;
793
+ if (args.distinct !== undefined)
794
+ return false;
795
+ if (!isEmptyOrderBy(args.orderBy))
796
+ return false;
797
+ if (args.limit !== undefined || args.take !== undefined || args.offset !== undefined)
798
+ return true;
799
+ return this.cursorFields(args.cursor).length > 0;
800
+ }
801
+ /**
802
+ * Opt-in (`implicitPkOrdering`) primary-key ascending ordering for a paginating
803
+ * findMany that declares no `orderBy`, making its pages deterministic.
804
+ *
805
+ * OFF by default in CORE, deliberately: turning it on would add an `ORDER BY`
806
+ * to SQL that existing applications already emit, changing both the rows a
807
+ * given page returns and the plan the engine picks. That is a breaking change
808
+ * in everything but the type signature, so it waits for a major. The
809
+ * `turbine-orm/prisma-compat` layer defaults it ON instead, because reproducing
810
+ * Prisma's semantics is that layer's whole contract.
811
+ *
812
+ * An explicit `orderBy` always wins, a PK-less table is left alone (nothing
813
+ * stable to order by), and a composite PK orders on every column in
814
+ * declaration order. A `cursor` query orders on the CURSOR's field instead
815
+ * (see {@link cursorOrderBy}), and is left alone when that shape is ambiguous.
816
+ * `distinct` shapes are skipped (see {@link isUnorderedPage}). With the flag
817
+ * off this returns `undefined` before touching anything, so the emitted SQL is
818
+ * byte-identical to before.
819
+ */
820
+ implicitPkOrderBy(args) {
821
+ if (!this.implicitPkOrdering)
822
+ return undefined;
823
+ if (!this.isUnorderedPage(args))
824
+ return undefined;
825
+ if (this.cursorFields(args?.cursor).length > 0)
826
+ return this.cursorOrderBy(args?.cursor);
827
+ return this.pkOrderBy();
828
+ }
829
+ /**
830
+ * Dev-only, once per query shape: an unordered paginating findMany returns a
831
+ * non-deterministic page (see {@link isUnorderedPage}), and on real data it is
832
+ * also usually the slower plan (an unordered `LIMIT` can discard tens of
833
+ * thousands of heap rows that an index scan on the key would have skipped).
834
+ *
835
+ * Gated exactly like the other dev diagnostics (silent under
836
+ * `NODE_ENV=production`) and consistent with `warnOnUnlimited`: a per-call
837
+ * `warnOnUnlimited: false` silences it, `true` forces it past a config-level
838
+ * opt-out, and a config/per-table `warnOnUnlimited: false` silences it. Deduped
839
+ * process-wide through the shared warn registry, so it can never spam.
840
+ *
841
+ * Suppressed only when `implicitPkOrdering` will ACTUALLY order this query.
842
+ * The flag being on is not enough: a PK-less table and an ambiguous
843
+ * multi-field cursor both get no injected ordering, and those are precisely
844
+ * the shapes that still need saying out loud.
845
+ */
846
+ maybeWarnUnorderedPage(args) {
847
+ if (process.env.NODE_ENV === 'production')
848
+ return;
849
+ if (this.implicitPkOrdering && this.implicitPkOrderBy(args) !== undefined)
850
+ return;
851
+ const perCall = args?.warnOnUnlimited;
852
+ if (perCall === false)
853
+ return;
854
+ if (perCall === undefined && !this.warnOnUnlimited)
855
+ return;
856
+ if (!this.isUnorderedPage(args))
857
+ return;
858
+ const cursorFields = this.cursorFields(args?.cursor);
859
+ const shape = [
860
+ cursorFields.length > 0 ? 'cursor' : '',
861
+ args?.limit !== undefined ? 'limit' : '',
862
+ args?.take !== undefined ? 'take' : '',
863
+ args?.offset !== undefined ? 'offset' : '',
864
+ ]
865
+ .filter(Boolean)
866
+ .join('+');
867
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.unorderedPage, `${this.table}|${shape}`))
868
+ return;
869
+ const asOrderBy = (fields) => fields.length === 1 ? `{ ${fields[0]}: 'asc' }` : `[${fields.map((f) => `{ ${f}: 'asc' }`).join(', ')}]`;
870
+ const pk = (this.tableMeta.primaryKey ?? []).map((c) => this.tableMeta.reverseColumnMap[c] ?? c);
871
+ // A cursor seeks on its own field, so that is the ordering to recommend:
872
+ // the primary key would be the wrong advice whenever the two differ.
873
+ const orderFields = cursorFields.length > 0 ? cursorFields : pk;
874
+ const suggestion = orderFields.length > 0 ? `Add \`orderBy: ${asOrderBy(orderFields)}\`` : 'Add an `orderBy` on a unique column';
875
+ const seek = cursorFields.length > 0
876
+ ? 'a `cursor` seek with no orderBy compares against rows the engine is free to return in any order, so ' +
877
+ 'paging with it skips and repeats rows: '
878
+ : '';
879
+ console.warn(`[turbine] findMany on "${this.table}" paginates (${shape}) with no orderBy: ${seek}the page is NOT ` +
880
+ 'deterministic (the same query can return different rows as the table changes, so a row may appear ' +
881
+ `on two pages or on none). ${suggestion}, or set \`implicitPkOrdering: true\` in the client config ` +
882
+ 'to order paginated queries automatically (by the cursor field, or by the primary key).');
883
+ }
884
+ // -------------------------------------------------------------------------
608
885
  // relationLoadStrategy: 'auto', per-relation batched fallback when the
609
886
  // introspected metadata proves a probe is unindexed (finding 13).
610
887
  // -------------------------------------------------------------------------
@@ -692,13 +969,69 @@ class QueryInterface {
692
969
  return { unindexed, eligible, miss };
693
970
  }
694
971
  /**
695
- * Partition a top-level `with` clause under `'auto'`: each relation whose
696
- * subtree has a PROVEN unindexed probe AND is fully batched-eligible routes to
697
- * `batchedWith`; everything else (indexed, composite-key, unknown) stays in
698
- * `joinWith` (byte-identical join). The reserved `_count` key partitions the
699
- * same way. Also returns the engaged relations for the dev note.
972
+ * Whether a query's parent set is potentially large at plan time, which is the
973
+ * only cardinality signal available before the base query runs. A `findMany`
974
+ * with no `limit`/`take` (or one above {@link autoToOneJoinMaxRows}) can return
975
+ * an arbitrary number of parent rows; a small `limit` bounds it. `findUnique` /
976
+ * `findFirst` pass `false` explicitly (their parent set is one row).
977
+ */
978
+ autoParentSetLarge(args) {
979
+ const limit = args?.take ?? args?.limit ?? this.defaultLimit;
980
+ return limit === undefined || limit > this.autoToOneThreshold();
981
+ }
982
+ /**
983
+ * The parent-row count at which `'auto'` stops preferring the single-statement
984
+ * join for a to-one relation.
985
+ *
986
+ * Resolution order:
987
+ * 1. an explicit `autoToOneJoinMaxRows` — an instruction, used verbatim
988
+ * (no clamping: the caller has measured their own workload);
989
+ * 2. the configured `autoRoundTripMs` divided by
990
+ * {@link AUTO_JOIN_PENALTY_MS_PER_ROW}, clamped to
991
+ * [{@link AUTO_TO_ONE_JOIN_ROWS_MIN}, {@link AUTO_TO_ONE_JOIN_ROWS_MAX}];
992
+ * 3. {@link AUTO_TO_ONE_JOIN_MAX_ROWS}, which is that same division applied
993
+ * to {@link AUTO_ASSUMED_ROUND_TRIP_MS}.
994
+ *
995
+ * Deriving it rather than hard-coding a row count is the whole point: the
996
+ * sweep in {@link AUTO_JOIN_PENALTY_MS_PER_ROW} shows the break-even moving
997
+ * 17x between a loopback link and a 2.7ms one while the per-row penalty stays
998
+ * put, so any single constant is wrong for someone by more than the margin it
999
+ * is trying to save. Placing the switch AT the break-even is also what removes
1000
+ * the old cliff: two plans that cost the same at the boundary make the regret
1001
+ * there ~1.0x, rising only as the true row count moves away from it — where
1002
+ * the previous fixed 1000 put its WORST case (1.44x measured) immediately
1003
+ * below its own switch point.
1004
+ *
1005
+ * Cheap enough to recompute per call (a division and two comparisons over
1006
+ * readonly fields), so there is no cached copy to invalidate.
1007
+ */
1008
+ autoToOneThreshold() {
1009
+ if (this.autoToOneJoinMaxRowsOption !== undefined)
1010
+ return this.autoToOneJoinMaxRowsOption;
1011
+ if (this.autoRoundTripMs === undefined)
1012
+ return exports.AUTO_TO_ONE_JOIN_MAX_ROWS;
1013
+ const rows = Math.round(this.autoRoundTripMs / exports.AUTO_JOIN_PENALTY_MS_PER_ROW);
1014
+ return Math.min(exports.AUTO_TO_ONE_JOIN_ROWS_MAX, Math.max(exports.AUTO_TO_ONE_JOIN_ROWS_MIN, rows));
1015
+ }
1016
+ /**
1017
+ * Partition a top-level `with` clause under `'auto'`. A relation routes to
1018
+ * `batchedWith` when it is fully batched-eligible AND either
1019
+ *
1020
+ * 1. its subtree has a PROVEN unindexed probe (index metadata only), or
1021
+ * 2. it is TO-ONE and the parent set is potentially large
1022
+ * ({@link AUTO_TO_ONE_JOIN_MAX_ROWS}), since a correlated to-one subquery
1023
+ * is re-evaluated per parent row no matter how well indexed it is.
1024
+ *
1025
+ * Everything else (indexed to-many, composite-key, unknown) stays in `joinWith`
1026
+ * (byte-identical join). The reserved `_count` key falls back on rule 1 only,
1027
+ * and only for a large parent set: an inline `_count` is one correlated
1028
+ * `COUNT(*)` per parent row, so the grouped follow-up wins exactly when there
1029
+ * are many parents, while for a handful of parents the extra round-trip costs
1030
+ * more than the repeated (small) scans. Also returns the engaged relations for
1031
+ * the dev note.
700
1032
  */
701
- partitionWithForAuto(withClause) {
1033
+ partitionWithForAuto(withClause, parentSetLarge) {
1034
+ const hasIndexInfo = (0, index_advisor_js_1.schemaHasIndexInfo)(this.schema);
702
1035
  const joinWith = {};
703
1036
  const batchedWith = {};
704
1037
  const engaged = [];
@@ -707,9 +1040,9 @@ class QueryInterface {
707
1040
  continue;
708
1041
  if (key === '_count') {
709
1042
  const cv = this.autoCountVerdict(spec, this.tableMeta);
710
- if (cv.unindexed && cv.eligible) {
1043
+ if (hasIndexInfo && parentSetLarge && cv.unindexed && cv.eligible) {
711
1044
  batchedWith[key] = spec;
712
- engaged.push({ relation: '_count', miss: cv.miss });
1045
+ engaged.push({ relation: '_count', reason: 'unindexed', miss: cv.miss });
713
1046
  }
714
1047
  else {
715
1048
  joinWith[key] = spec;
@@ -722,9 +1055,16 @@ class QueryInterface {
722
1055
  continue;
723
1056
  }
724
1057
  const v = this.autoSubtreeVerdict(rel, spec, 0);
725
- if (v.unindexed && v.eligible) {
1058
+ const unindexedFallback = hasIndexInfo && v.unindexed;
1059
+ const toOne = rel.type === 'belongsTo' || rel.type === 'hasOne';
1060
+ const cardinalityFallback = toOne && parentSetLarge;
1061
+ if (v.eligible && (unindexedFallback || cardinalityFallback)) {
726
1062
  batchedWith[key] = spec;
727
- engaged.push({ relation: key, miss: v.miss });
1063
+ engaged.push({
1064
+ relation: key,
1065
+ reason: unindexedFallback ? 'unindexed' : 'to-one-cardinality',
1066
+ miss: unindexedFallback ? v.miss : undefined,
1067
+ });
728
1068
  }
729
1069
  else {
730
1070
  joinWith[key] = spec;
@@ -736,17 +1076,18 @@ class QueryInterface {
736
1076
  * Plan the `'auto'` split for a query's `with` clause: normalize stable order,
737
1077
  * partition, and return the split ONLY when at least one relation falls back
738
1078
  * to batched. Returns `null` (→ run the plain join path, byte-identical, same
739
- * cache keys) when there is no DB-backed index metadata or nothing qualifies.
1079
+ * cache keys) when nothing qualifies.
740
1080
  */
741
- planAuto(withArg, stableFlag) {
742
- // No DB-backed index info (code-first / defineSchema-only) cannot PROVE any
743
- // probe is unindexed, so 'auto' behaves exactly like 'join'.
744
- if (!(0, index_advisor_js_1.schemaHasIndexInfo)(this.schema))
1081
+ planAuto(withArg, stableFlag, parentSetLarge) {
1082
+ // Without DB-backed index info (code-first / defineSchema-only) no probe can
1083
+ // be PROVEN unindexed; the to-one cardinality rule does not depend on index
1084
+ // metadata, so it still applies.
1085
+ if (!(0, index_advisor_js_1.schemaHasIndexInfo)(this.schema) && !parentSetLarge)
745
1086
  return null;
746
1087
  const withClause = this.resolveStableOrder(stableFlag)
747
1088
  ? this.applyStableRelationOrder(withArg, this.table)
748
1089
  : withArg;
749
- const split = this.partitionWithForAuto(withClause);
1090
+ const split = this.partitionWithForAuto(withClause, parentSetLarge);
750
1091
  if (Object.keys(split.batchedWith).length === 0)
751
1092
  return null;
752
1093
  return split;
@@ -758,6 +1099,13 @@ class QueryInterface {
758
1099
  for (const e of engaged) {
759
1100
  if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.autoStrategy, `${this.table}.${e.relation}`))
760
1101
  continue;
1102
+ if (e.reason === 'to-one-cardinality') {
1103
+ console.warn(`[turbine] auto strategy: to-one relation "${e.relation}" on "${this.table}" loads batched ` +
1104
+ `(the query is unbounded or its limit exceeds ${this.autoToOneThreshold()} rows, and a correlated ` +
1105
+ 'to-one subquery is re-evaluated per parent row). Bound the query with a smaller `limit`, tune ' +
1106
+ "`autoToOneJoinMaxRows`, or set `relationLoadStrategy: 'join'` to force the single-statement plan.");
1107
+ continue;
1108
+ }
761
1109
  const probe = e.miss
762
1110
  ? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
763
1111
  : 'a probe in its subtree has no covering index';
@@ -1176,7 +1524,9 @@ class QueryInterface {
1176
1524
  if (strategy === 'batched')
1177
1525
  return this.runFindUniqueBatched(args);
1178
1526
  if (strategy === 'auto') {
1179
- const split = this.planAuto(args.with, args.stableRelationOrder);
1527
+ // findUnique's parent set is a single row: the join plan's correlated
1528
+ // subqueries run once, so the cardinality rule never applies here.
1529
+ const split = this.planAuto(args.with, args.stableRelationOrder, false);
1180
1530
  if (split)
1181
1531
  return this.runAutoSplit(args, split, true);
1182
1532
  }
@@ -1225,6 +1575,12 @@ class QueryInterface {
1225
1575
  args = { ...args, with: normalized };
1226
1576
  }
1227
1577
  const includePii = args.includePii === true;
1578
+ // findUnique is never flatten-planned: it reads ONE parent row, so the
1579
+ // correlated subquery already runs exactly once and a join buys nothing.
1580
+ // Say so, because the caller did ask for a strategy that is not running.
1581
+ if (args.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'flatten') {
1582
+ this.warnFlattenBlocked('findUnique reads a single parent row, where the correlated subquery already runs once');
1583
+ }
1228
1584
  const columnsList = this.resolveColumns(args.select, args.omit, includePii);
1229
1585
  // A global filter turns the where into `{ AND: [...] }`, which the
1230
1586
  // `isSimpleWhere` test below rejects → the general (buildWhereClause) path
@@ -1337,6 +1693,7 @@ class QueryInterface {
1337
1693
  // -------------------------------------------------------------------------
1338
1694
  async findMany(args) {
1339
1695
  this.maybeWarnUnlimited(args);
1696
+ this.maybeWarnUnorderedPage(args);
1340
1697
  // Dev-only: warn on deeply nested with clauses
1341
1698
  if (process.env.NODE_ENV !== 'production') {
1342
1699
  if (args?.with) {
@@ -1353,7 +1710,7 @@ class QueryInterface {
1353
1710
  if (strategy === 'batched')
1354
1711
  return this.runFindManyBatched(args);
1355
1712
  if (strategy === 'auto') {
1356
- const split = this.planAuto(args.with, args.stableRelationOrder);
1713
+ const split = this.planAuto(args.with, args.stableRelationOrder, this.autoParentSetLarge(args));
1357
1714
  if (split)
1358
1715
  return this.runAutoSplit(args, split, false);
1359
1716
  }
@@ -1473,6 +1830,23 @@ class QueryInterface {
1473
1830
  if (normalized !== args.with)
1474
1831
  args = { ...args, with: normalized };
1475
1832
  }
1833
+ // An empty orderBy carries no ordering, so treat it as ABSENT rather than
1834
+ // emitting a bare `ORDER BY` with nothing after it (a syntax error at the
1835
+ // following LIMIT). This matters most for the documented escape hatch from
1836
+ // implicit ordering, "pass an explicit orderBy": callers who assemble that
1837
+ // array conditionally end up passing `[]`. Normalized here, before the
1838
+ // implicit-ordering and fingerprinting steps, so every downstream path sees
1839
+ // one shape.
1840
+ if (args?.orderBy !== undefined && isEmptyOrderBy(args.orderBy)) {
1841
+ args = { ...args, orderBy: undefined };
1842
+ }
1843
+ // Deterministic pagination (opt-in): fill a PK-asc orderBy into a paginating
1844
+ // query that declares none, BEFORE fingerprinting so the ordered and
1845
+ // unordered shapes get distinct cache entries. No-op unless
1846
+ // `implicitPkOrdering` is enabled (see implicitPkOrderBy).
1847
+ const implicitOrder = this.implicitPkOrderBy(args);
1848
+ if (implicitOrder)
1849
+ args = { ...args, orderBy: implicitOrder };
1476
1850
  // `distinct` + relation orderBy is refused up front (E003): the distinct
1477
1851
  // path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
1478
1852
  // <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
@@ -1535,7 +1909,16 @@ class QueryInterface {
1535
1909
  // `withFp` does NOT capture; it is projection-invariant). So it MUST be its
1536
1910
  // own cache-key segment: a cached no-PII statement must never serve an
1537
1911
  // `includePii` call, nor vice versa.
1538
- const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}|pii=${includePii ? 1 : 0}${this.globalFilterCacheSegment()}`;
1912
+ // `relationLoadStrategy: 'flatten'` compiles eligible to-one relations to
1913
+ // LEFT JOINs instead of correlated subqueries — a completely different
1914
+ // statement for the same `with` shape, which `withFp` (strategy-blind)
1915
+ // does not distinguish. So the plan gets its own cache-key segment, exactly
1916
+ // like `pii=`: a join-planned template must never serve a flatten-planned
1917
+ // call. Absent (the default), the segment is empty and every existing cache
1918
+ // key is byte-identical to before.
1919
+ const flattenPlan = this.planFlatten(args, includePii);
1920
+ const flattenFp = flattenPlan ? `|fl=${flattenPlan.signature}` : '';
1921
+ const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}|pii=${includePii ? 1 : 0}${flattenFp}${this.globalFilterCacheSegment()}`;
1539
1922
  const params = [];
1540
1923
  const buildSql = (freshParams) => {
1541
1924
  // Fresh build: generates SQL and populates freshParams
@@ -1552,9 +1935,15 @@ class QueryInterface {
1552
1935
  distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
1553
1936
  distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
1554
1937
  }
1938
+ // Join-sink for `relationLoadStrategy: 'flatten'`. Filled while the SELECT
1939
+ // list is built (so the flattened relations' ON-clause params interleave
1940
+ // with the `with` params in one traversal, which the collect path
1941
+ // mirrors) and spliced into the FROM clause at assembly. Stays empty for
1942
+ // every other plan → byte-identical SQL.
1943
+ const relationJoins = [];
1555
1944
  let selectClause;
1556
1945
  if (args?.with) {
1557
- selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList, undefined, undefined, includePii);
1946
+ selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList, undefined, undefined, includePii, flattenPlan ? { plan: flattenPlan, joinSink: relationJoins } : undefined);
1558
1947
  }
1559
1948
  else if (columnsList) {
1560
1949
  selectClause = columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ');
@@ -1620,7 +2009,7 @@ class QueryInterface {
1620
2009
  const orderBySql = args?.orderBy
1621
2010
  ? ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams, lateralJoins)}`
1622
2011
  : '';
1623
- sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${lateralJoins.join('')}${tail}${orderBySql}`;
2012
+ sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${relationJoins.join('')}${lateralJoins.join('')}${tail}${orderBySql}`;
1624
2013
  }
1625
2014
  // Pagination — push params in the same order the collect path mirrors
1626
2015
  // (limit before offset); the SQL TEXT shape is dialect-owned via
@@ -1628,10 +2017,10 @@ class QueryInterface {
1628
2017
  let limitPh;
1629
2018
  let offsetPh;
1630
2019
  if (effectiveLimit !== undefined) {
1631
- limitPh = this.paginationRef(effectiveLimit, freshParams);
2020
+ limitPh = this.paginationRef(effectiveLimit, freshParams, 'limit');
1632
2021
  }
1633
2022
  if (args?.offset !== undefined) {
1634
- offsetPh = this.paginationRef(args.offset, freshParams);
2023
+ offsetPh = this.paginationRef(args.offset, freshParams, 'skip/offset');
1635
2024
  }
1636
2025
  sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
1637
2026
  return sql;
@@ -1644,7 +2033,7 @@ class QueryInterface {
1644
2033
  }
1645
2034
  // 2. WITH relation params
1646
2035
  if (args?.with) {
1647
- this.collectWithParams(args.with, params);
2036
+ this.collectWithParams(args.with, params, undefined, flattenPlan);
1648
2037
  }
1649
2038
  // 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
1650
2039
  if (args?.cursor) {
@@ -1661,15 +2050,18 @@ class QueryInterface {
1661
2050
  // 5. LIMIT param — skipped when the dialect inlines pagination (build path
1662
2051
  // mirrors via paginationRef → no placeholder, no param).
1663
2052
  if (effectiveLimit !== undefined && !this.dialect.inlineLimitOffset) {
1664
- params.push(Number(effectiveLimit));
2053
+ // Validate here too: on a cache HIT the build path never runs, and a
2054
+ // warmed template would otherwise bind an unvalidated NaN (= SQL NULL,
2055
+ // i.e. no limit at all).
2056
+ params.push(this.paginationValue(effectiveLimit, 'limit'));
1665
2057
  }
1666
2058
  // 6. OFFSET param — same inline gate as LIMIT above.
1667
2059
  if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
1668
- params.push(Number(args.offset));
2060
+ params.push(this.paginationValue(args.offset, 'skip/offset'));
1669
2061
  }
1670
2062
  this.crossCheckCache('findMany', ck, entry, buildSql, params);
1671
2063
  // Build the row parser once (positional shapes are computed here, not per row).
1672
- const parseWith = args?.with ? this.makeNestedParser(args.with, includePii) : null;
2064
+ const parseWith = args?.with ? this.makeNestedParser(args.with, includePii, flattenPlan) : null;
1673
2065
  return {
1674
2066
  sql: entry.sql,
1675
2067
  params,
@@ -1692,14 +2084,19 @@ class QueryInterface {
1692
2084
  * method fall back to the full cursor path.
1693
2085
  *
1694
2086
  * **Cursor path:** Uses DECLARE CURSOR within a dedicated transaction on a
1695
- * single pooled connection. The cursor is automatically closed and the
1696
- * connection released when iteration completes or is terminated early
1697
- * (e.g. `break` from `for await`).
2087
+ * single pooled connection. The cursor is CLOSEd (in the dialect's `finally`)
2088
+ * and the connection released both when iteration completes normally and when
2089
+ * it ends early (`break` from `for await`). An error mid-stream skips the
2090
+ * CLOSE and rolls back instead, which drops the cursor with the transaction.
1698
2091
  *
1699
- * **Snapshot semantics note:** The speculative fast-path runs outside a
1700
- * transaction. If the result overflows and the cursor path is opened, the
1701
- * cursor runs in its own transaction — spanning two separate snapshots.
1702
- * For strict single-snapshot semantics, wrap the call in `$transaction`.
2092
+ * **Snapshot semantics note:** Outside a transaction the speculative
2093
+ * fast-path runs unwrapped, and an overflow opens the cursor in its own
2094
+ * transaction, so the two fetches span two separate snapshots. Wrapping the
2095
+ * call in `$transaction` gives strict single-snapshot semantics: both the
2096
+ * speculative fetch and the cursor then run on the caller's connection
2097
+ * inside the caller's transaction (the cursor path issues no BEGIN/COMMIT of
2098
+ * its own and releases nothing, so the caller's transaction is intact when
2099
+ * iteration finishes).
1703
2100
  *
1704
2101
  * @example
1705
2102
  * ```ts
@@ -1712,7 +2109,14 @@ class QueryInterface {
1712
2109
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
1713
2110
  const hasRelations = !!args?.with;
1714
2111
  // Build the positional-aware relation parser once for the whole stream.
1715
- const parseWith = hasRelations ? this.makeNestedParser(args.with, args?.includePii === true) : null;
2112
+ // Same flatten plan buildFindMany compiles below. The plan is a pure
2113
+ // function of the schema, the `with` shape and `includePii` — never of
2114
+ // `limit` — so the batch-size override the speculative fetch applies cannot
2115
+ // change it, and the stream's parser matches the emitted SQL.
2116
+ const streamFlattenPlan = hasRelations ? this.planFlatten(args, args?.includePii === true) : null;
2117
+ const parseWith = hasRelations
2118
+ ? this.makeNestedParser(args.with, args?.includePii === true, streamFlattenPlan)
2119
+ : null;
1716
2120
  // --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
1717
2121
  const speculativeDeferred = this.buildFindMany({
1718
2122
  ...args,
@@ -1729,13 +2133,24 @@ class QueryInterface {
1729
2133
  }
1730
2134
  // --- Overflow: fall back to cursor path from scratch ---
1731
2135
  const deferred = this.buildFindMany(args);
1732
- // Acquire a dedicated connection cursors require a single connection in a
2136
+ // Acquire a dedicated connection: cursors require a single connection in a
1733
2137
  // transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
1734
2138
  // … NO SCROLL CURSOR FOR → FETCH n → CLOSE → COMMIT, ROLLBACK on error); we
1735
2139
  // just parse + yield the row batches it produces.
1736
- const client = await this.pool.connect();
2140
+ //
2141
+ // Inside a caller-owned transaction there is nothing to check out: the
2142
+ // transaction-scoped pool pins every query to the transaction's own
2143
+ // connection, so `pool.query` already IS that connection. The stream rides
2144
+ // on it, is never released here, and the dialect is told to emit no
2145
+ // transaction control of its own (`ambientTransaction`).
2146
+ const client = this.txScoped ? null : await this.pool.connect();
2147
+ const conn = client ?? {
2148
+ query: async (text, values) => (await this.pool.query(text, values)),
2149
+ };
1737
2150
  try {
1738
- for await (const batch of this.dialect.openStream(client, deferred.sql, deferred.params, batchSize)) {
2151
+ for await (const batch of this.dialect.openStream(conn, deferred.sql, deferred.params, batchSize, {
2152
+ ambientTransaction: this.txScoped,
2153
+ })) {
1739
2154
  for (const row of batch) {
1740
2155
  yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
1741
2156
  }
@@ -1746,7 +2161,7 @@ class QueryInterface {
1746
2161
  throw (0, errors_js_1.wrapPgError)(err);
1747
2162
  }
1748
2163
  finally {
1749
- client.release();
2164
+ client?.release();
1750
2165
  }
1751
2166
  }
1752
2167
  // -------------------------------------------------------------------------
@@ -1762,7 +2177,8 @@ class QueryInterface {
1762
2177
  return (rows[0] ?? null);
1763
2178
  }
1764
2179
  if (strategy === 'auto') {
1765
- const split = this.planAuto(args.with, args.stableRelationOrder);
2180
+ // findFirst is findMany + LIMIT 1: a one-row parent set.
2181
+ const split = this.planAuto(args.with, args.stableRelationOrder, false);
1766
2182
  if (split) {
1767
2183
  const rows = (await this.runAutoSplit({ ...args, limit: 1 }, split, false));
1768
2184
  return (rows[0] ?? null);
@@ -1911,11 +2327,22 @@ class QueryInterface {
1911
2327
  }
1912
2328
  async runInImplicitTx(fn) {
1913
2329
  const client = await this.pool.connect();
2330
+ let began = false;
1914
2331
  try {
1915
2332
  await client.query(this.dialect.beginStatement());
2333
+ began = true;
1916
2334
  const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('../client.js')));
2335
+ const tx = new TransactionClient(
1917
2336
  // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
1918
- const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
2337
+ client, this.schema,
2338
+ // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
2339
+ this.middlewares, this.options,
2340
+ // Pass the source pool so its read-only guard + capabilities carry into
2341
+ // the transaction-scoped proxy pool (see createTxPool). Without it a
2342
+ // read-only client's nested writes bypass the E018 guard and an
2343
+ // older-engine client falls back to the full capability set inside the
2344
+ // implicit transaction.
2345
+ this.pool);
1919
2346
  // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
1920
2347
  const ctx = { schema: this.schema, tx: tx };
1921
2348
  const result = await fn(ctx);
@@ -1923,11 +2350,15 @@ class QueryInterface {
1923
2350
  return result;
1924
2351
  }
1925
2352
  catch (err) {
1926
- try {
1927
- await client.query(this.dialect.rollbackStatement());
1928
- }
1929
- catch {
1930
- // Best-effort rollback — connection may have died.
2353
+ // Only roll back a transaction we actually opened: a failed BEGIN must
2354
+ // not emit a stray ROLLBACK on a connection that never began one.
2355
+ if (began) {
2356
+ try {
2357
+ await client.query(this.dialect.rollbackStatement());
2358
+ }
2359
+ catch {
2360
+ // Best-effort rollback: connection may have died.
2361
+ }
1931
2362
  }
1932
2363
  throw err;
1933
2364
  }
@@ -2074,8 +2505,8 @@ class QueryInterface {
2074
2505
  withFingerprint(withClause, table, depth = 0) {
2075
2506
  return relationsMod.withFingerprint(this.ctx, withClause, table, depth);
2076
2507
  }
2077
- collectWithParams(withClause, params, table) {
2078
- relationsMod.collectWithParams(this.ctx, withClause, params, table);
2508
+ collectWithParams(withClause, params, table, flattenPlan) {
2509
+ relationsMod.collectWithParams(this.ctx, withClause, params, table, flattenPlan);
2079
2510
  }
2080
2511
  orderByEntryFingerprint(d, targetTable) {
2081
2512
  return relationsMod.orderByEntryFingerprint(this.ctx, d, targetTable);
@@ -2107,11 +2538,75 @@ class QueryInterface {
2107
2538
  getCamelDateFields(table, meta) {
2108
2539
  return relationsMod.getCamelDateFields(this.ctx, table, meta);
2109
2540
  }
2110
- makeNestedParser(withClause, includePii) {
2111
- return relationsMod.makeNestedParser(this.ctx, withClause, includePii);
2541
+ makeNestedParser(withClause, includePii, flattenPlan) {
2542
+ return relationsMod.makeNestedParser(this.ctx, withClause, includePii, flattenPlan);
2543
+ }
2544
+ buildSelectWithRelations(table, withClause, params, columnsList, depth, path, includePii, flatten) {
2545
+ return relationsMod.buildSelectWithRelations(this.ctx, table, withClause, params, columnsList, depth, path, includePii, flatten);
2112
2546
  }
2113
- buildSelectWithRelations(table, withClause, params, columnsList, depth, path, includePii) {
2114
- return relationsMod.buildSelectWithRelations(this.ctx, table, withClause, params, columnsList, depth, path, includePii);
2547
+ /**
2548
+ * Compile the `relationLoadStrategy: 'flatten'` plan for a findMany-shaped
2549
+ * query, or `null` to emit exactly the SQL (and cache key) the default
2550
+ * strategy emits.
2551
+ *
2552
+ * `'flatten'` compiles an eligible to-one relation to a `LEFT JOIN` with a
2553
+ * prefixed scalar projection instead of a correlated `json_build_object`
2554
+ * subquery. The correlated form is re-evaluated once per parent row, so its
2555
+ * cost scales with the parent set no matter how well the FK is indexed; the
2556
+ * join does not, and unlike `'batched'` it stays a single round trip.
2557
+ *
2558
+ * It is an EXPLICIT opt-in: `'auto'` is unchanged and never selects it.
2559
+ *
2560
+ * Query-shape gates (any of these routes the WHOLE query back to the default
2561
+ * strategy, silently and byte-identically):
2562
+ * - the resolved strategy is not `'flatten'`;
2563
+ * - `jsonEncoding: 'positional'` (a flattened relation emits no JSON at all,
2564
+ * so the two encodings are not composed in this version);
2565
+ * - the dialect owns relation-subquery generation
2566
+ * (`dialect.buildRelationSubquery`, i.e. SQL Server's `FOR JSON PATH`);
2567
+ * - `distinct` (the `DISTINCT ON` rewrite re-orders in an outer wrapper, and
2568
+ * the extra projected columns have not been proven safe there).
2569
+ *
2570
+ * `limit` / `offset` / `cursor` / `orderBy` need no gate: every flattened join
2571
+ * is over a PROVABLY UNIQUE target key, so it matches at most one row per
2572
+ * parent and cannot change the parent row count that pagination applies to.
2573
+ *
2574
+ * Per-relation eligibility lives in `planFlattenWith` / `planFlattenNode`.
2575
+ * Only the findMany family is planned (`findMany`, `findFirst`,
2576
+ * `findManyStream`, and pipelined `buildFindMany`); `findUnique` reads a single
2577
+ * parent row, where the correlated subquery runs exactly once, so it stays on
2578
+ * the default path.
2579
+ */
2580
+ planFlatten(args, includePii) {
2581
+ const withClause = args?.with;
2582
+ if (!withClause)
2583
+ return null;
2584
+ if (this.resolveLoadStrategy(args?.relationLoadStrategy) !== 'flatten')
2585
+ return null;
2586
+ // Query-level refusals: the whole plan is off, so name the reason once for
2587
+ // the query rather than once per relation (relations.ts warns per relation
2588
+ // for the eligibility rules it owns).
2589
+ const queryLevelBlock = this.jsonEncoding === 'positional'
2590
+ ? "`jsonEncoding: 'positional'` is active, and a flattened relation emits no JSON to encode"
2591
+ : this.dialect.buildRelationSubquery
2592
+ ? `the ${this.dialect.name} dialect generates relation subqueries itself`
2593
+ : args?.distinct && args.distinct.length > 0
2594
+ ? 'the query uses `distinct`'
2595
+ : undefined;
2596
+ if (queryLevelBlock) {
2597
+ this.warnFlattenBlocked(queryLevelBlock);
2598
+ return null;
2599
+ }
2600
+ return relationsMod.planFlattenWith(this.ctx, this.table, withClause, includePii);
2601
+ }
2602
+ /** Dev-only once-only note that `'flatten'` was refused for the whole query. */
2603
+ warnFlattenBlocked(reason) {
2604
+ if (process.env.NODE_ENV === 'production')
2605
+ return;
2606
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.flattenFallback, `${this.table}|query|${reason}`))
2607
+ return;
2608
+ console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage on "${this.table}": ${reason}. ` +
2609
+ 'Every relation loads via the correlated subquery instead (same rows, same values, different plan).');
2115
2610
  }
2116
2611
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
2117
2612
  toColumn(field) {
@@ -2302,7 +2797,18 @@ class QueryInterface {
2302
2797
  const value = row[col];
2303
2798
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
2304
2799
  // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
2305
- if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
2800
+ //
2801
+ // An ARRAY value is excluded: `dateColumns` includes array-of-date
2802
+ // columns (`date[]`, `timestamp[]`, `timestamptz[]`), for which the
2803
+ // driver already hands back a `Date[]`. Coercing it ran
2804
+ // `new Date(String(theArray))` and replaced the whole array with a
2805
+ // single Invalid Date — the column was unreadable on every strategy.
2806
+ // The join strategy's string arrays are handled upstream instead, by
2807
+ // the JSON-wire decode in relations.ts.
2808
+ if ((dateCols.has(col) || camelDateFields.has(field)) &&
2809
+ value !== null &&
2810
+ !(value instanceof Date) &&
2811
+ !Array.isArray(value)) {
2306
2812
  // Offset-less strings (Postgres `timestamp`, json_agg output) are
2307
2813
  // pinned to UTC so results don't depend on the server's time zone.
2308
2814
  parsed[field] = this.utcTimestamps ? (0, utils_js_1.parseDbDate)(String(value)) : new Date(value);