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
@@ -69,6 +69,71 @@ function cacheCrossCheckMode() {
69
69
  * so this set is only ever touched on the `'sampled'` path.
70
70
  */
71
71
  const loggedCacheMismatchFingerprints = new Set();
72
+ /**
73
+ * Marginal cost of keeping a to-one relation on the JOIN plan, per parent row.
74
+ *
75
+ * A to-one relation compiled into the join plan is a CORRELATED subquery: the
76
+ * engine re-evaluates it once per parent row, so the join plan costs roughly
77
+ * `AUTO_JOIN_PENALTY_MS_PER_ROW * parentRows` more CPU than one flat follow-up
78
+ * query, no matter how well indexed the correlation column is. The batched plan
79
+ * pays that back as a second statement, i.e. one extra round trip.
80
+ *
81
+ * Break-even is therefore, to a first approximation:
82
+ *
83
+ * parentRows = roundTripMs / AUTO_JOIN_PENALTY_MS_PER_ROW
84
+ *
85
+ * Measured (PostgreSQL 17, hasOne over a UNIQUE FK, 10K-row parent table,
86
+ * median of 15 reps per point) at two very different link speeds:
87
+ *
88
+ * link RTT penalty/row break-even observed crossover
89
+ * ─────────────────── ──────── ───────────── ──────────── ──────────────────
90
+ * loopback TCP 0.118ms 0.000711ms ~166-236 between 200 and 400
91
+ * +1ms/direction 2.683ms 0.000717ms ~3744-3993 between 3000 and 5000
92
+ *
93
+ * The two things that matters most in that table: the per-row penalty is
94
+ * essentially IDENTICAL across the two links (it is a property of the plan, not
95
+ * the wire), while the break-even moved by 17x. So the break-even is a function
96
+ * of the deployment's round-trip time and NOTHING ELSE that is knowable at plan
97
+ * time. That is why this is expressed as a per-row cost and a round-trip time
98
+ * rather than as a hard-coded row count: a row count tuned on a Unix socket is
99
+ * off by ~20x for a cross-region deployment, and vice versa. Concretely, the
100
+ * previously shipped flat `1000` was simultaneously too HIGH on loopback
101
+ * (up to 1.44x slower than the better plan just under the cliff) and too LOW
102
+ * over a 2.7ms link (1.26x slower just above it).
103
+ */
104
+ export const AUTO_JOIN_PENALTY_MS_PER_ROW = 0.0007;
105
+ /**
106
+ * Round-trip time assumed before this process has observed a real one, chosen
107
+ * as a typical same-region managed-Postgres latency (app and database in one
108
+ * region over TCP). It is stated as a LATENCY rather than a row count so the
109
+ * assumption is visible and re-derivable: at
110
+ * {@link AUTO_JOIN_PENALTY_MS_PER_ROW} it yields exactly the 1000-row default
111
+ * this heuristic has always shipped, so an unmeasured process behaves exactly
112
+ * as before.
113
+ */
114
+ export const AUTO_ASSUMED_ROUND_TRIP_MS = 0.7;
115
+ /**
116
+ * Default parent-row ceiling under which `'auto'` keeps a to-one relation on
117
+ * the single-statement join plan: {@link AUTO_ASSUMED_ROUND_TRIP_MS} divided by
118
+ * {@link AUTO_JOIN_PENALTY_MS_PER_ROW}. Used until the process has measured its
119
+ * own round-trip time, and whenever measurement is unavailable.
120
+ */
121
+ export const AUTO_TO_ONE_JOIN_MAX_ROWS = Math.round(AUTO_ASSUMED_ROUND_TRIP_MS / AUTO_JOIN_PENALTY_MS_PER_ROW);
122
+ /**
123
+ * Clamps on the MEASURED threshold (an explicit `autoToOneJoinMaxRows` is an
124
+ * instruction, not an estimate, and bypasses both).
125
+ *
126
+ * The lower clamp matters: on a very fast link the formula can drop the
127
+ * threshold to a few dozen rows, and the sweep shows the join plan winning by
128
+ * up to 1.83x on a handful of parent rows, where the second statement's fixed
129
+ * cost dwarfs everything. Holding the floor at 100 rows keeps those small
130
+ * queries on the join plan; the cost of doing so, in the band where batched has
131
+ * just started to win, is under 1.2x. The upper clamp is a sanity bound for a
132
+ * pathological latency reading (a 70ms measurement would otherwise ask for
133
+ * 100K rows).
134
+ */
135
+ export const AUTO_TO_ONE_JOIN_ROWS_MIN = 100;
136
+ export const AUTO_TO_ONE_JOIN_ROWS_MAX = 100_000;
72
137
  /**
73
138
  * Strict structural equality for a single SQL parameter value. Handles the
74
139
  * value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
@@ -217,6 +282,23 @@ export class QueryInterface {
217
282
  relationLoadStrategy;
218
283
  /** Client-level default for {@link applyStableRelationOrder} (off unless configured). */
219
284
  stableRelationOrder;
285
+ /**
286
+ * Client-level opt-in: apply an implicit primary-key ascending `ORDER BY` to a
287
+ * `findMany` that paginates (`limit`/`take`/`offset`) but declares no
288
+ * `orderBy`. OFF by default in core, see {@link applyImplicitPkOrdering}.
289
+ */
290
+ implicitPkOrdering;
291
+ /**
292
+ * Explicitly configured parent-row ceiling for the `'auto'` to-one rule, or
293
+ * `undefined` to derive it from the observed round-trip time. See
294
+ * {@link autoToOneThreshold}.
295
+ */
296
+ autoToOneJoinMaxRowsOption;
297
+ /**
298
+ * Deployment round-trip time in milliseconds, from which the to-one threshold
299
+ * is derived. See {@link autoToOneThreshold}.
300
+ */
301
+ autoRoundTripMs;
220
302
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
221
303
  jsonEncoding;
222
304
  /**
@@ -338,6 +420,12 @@ export class QueryInterface {
338
420
  this.dialect = options?.dialect ?? postgresDialect;
339
421
  this.relationLoadStrategy = options?.relationLoadStrategy ?? 'auto';
340
422
  this.stableRelationOrder = options?.stableRelationOrder === true;
423
+ this.implicitPkOrdering = options?.implicitPkOrdering === true;
424
+ const autoToOne = options?.autoToOneJoinMaxRows;
425
+ this.autoToOneJoinMaxRowsOption =
426
+ autoToOne !== undefined && Number.isFinite(autoToOne) && autoToOne >= 0 ? Math.floor(autoToOne) : undefined;
427
+ const rtt = options?.autoRoundTripMs;
428
+ this.autoRoundTripMs = rtt !== undefined && Number.isFinite(rtt) && rtt > 0 ? rtt : undefined;
341
429
  this.jsonEncoding = options?.jsonEncoding ?? 'object';
342
430
  // Only retain the map when it has at least one entry, so `globalFilters`
343
431
  // stays `undefined` (and every merge path a no-op) for the common case.
@@ -404,7 +492,8 @@ export class QueryInterface {
404
492
  camelDateFieldCache: this.camelDateFieldCache,
405
493
  limitOneClause: () => this.limitOneClause(),
406
494
  buildPagination: (limitPh, offsetPh, hasOrderBy) => this.buildPagination(limitPh, offsetPh, hasOrderBy),
407
- paginationRef: (value, params) => this.paginationRef(value, params),
495
+ paginationRef: (value, params, arg) => this.paginationRef(value, params, arg),
496
+ paginationValue: (value, arg) => this.paginationValue(value, arg),
408
497
  };
409
498
  }
410
499
  /** Quote an identifier through the active SQL dialect. */
@@ -455,19 +544,33 @@ export class QueryInterface {
455
544
  }
456
545
  return ' LIMIT 1';
457
546
  }
547
+ /**
548
+ * Coerce a LIMIT/OFFSET argument and validate it as a non-negative safe
549
+ * integer. Numeric strings (`'5'`) coerce; everything else (`NaN`, a
550
+ * non-numeric string, a negative, a fractional or out-of-safe-range number)
551
+ * throws {@link ValidationError} (E003) naming the argument and the table.
552
+ *
553
+ * This runs on EVERY pagination path, parameterized as well as inlined: a
554
+ * bound `NaN` serializes as SQL NULL, and Postgres reads `LIMIT NULL` as
555
+ * "no limit", so an unvalidated value silently turns a paginated query into
556
+ * a full-table read (and a bad OFFSET silently disappears).
557
+ */
558
+ paginationValue(value, arg) {
559
+ const n = Number(value);
560
+ if (!Number.isSafeInteger(n) || n < 0) {
561
+ throw new ValidationError(`[turbine] ${arg} on "${this.table}" must be a non-negative integer, received: ${String(value)}`);
562
+ }
563
+ return n;
564
+ }
458
565
  /**
459
566
  * Validate a LIMIT/OFFSET value as a non-negative integer and return it as an
460
567
  * inline SQL literal. Used only on `dialect.inlineLimitOffset` engines (MySQL).
461
568
  * The input is always a Turbine-controlled pagination value, never a raw user
462
- * string and this guard guarantees the output is `String` of a validated
569
+ * string, and this guard guarantees the output is `String` of a validated
463
570
  * integer, so inlining cannot inject SQL.
464
571
  */
465
- limitOffsetLiteral(value) {
466
- const n = Number(value);
467
- if (!Number.isInteger(n) || n < 0) {
468
- throw new ValidationError(`LIMIT/OFFSET must be a non-negative integer, received: ${String(value)}`);
469
- }
470
- return String(n);
572
+ limitOffsetLiteral(value, arg) {
573
+ return String(this.paginationValue(value, arg));
471
574
  }
472
575
  /**
473
576
  * Resolve a LIMIT/OFFSET value to either an inline literal (no param pushed, on
@@ -476,11 +579,11 @@ export class QueryInterface {
476
579
  * the param order stays mirrored; PG/SQLite/SQL Server keep parameterizing and
477
580
  * stay byte-identical.
478
581
  */
479
- paginationRef(value, params) {
582
+ paginationRef(value, params, arg) {
480
583
  if (this.dialect.inlineLimitOffset) {
481
- return this.limitOffsetLiteral(value);
584
+ return this.limitOffsetLiteral(value, arg);
482
585
  }
483
- params.push(Number(value));
586
+ params.push(this.paginationValue(value, arg));
484
587
  return this.p(params.length);
485
588
  }
486
589
  /**
@@ -569,6 +672,180 @@ export class QueryInterface {
569
672
  return out ?? withClause;
570
673
  }
571
674
  // -------------------------------------------------------------------------
675
+ // Deterministic pagination (unordered LIMIT/OFFSET)
676
+ // -------------------------------------------------------------------------
677
+ /**
678
+ * The primary key of this table as an ascending `orderBy`, in DECLARATION
679
+ * order (a composite PK orders on every column), or `undefined` for a PK-less
680
+ * table. Field names are the camelCase accessor names, so the emitted SQL goes
681
+ * through the normal column mapping.
682
+ */
683
+ pkOrderBy() {
684
+ const pk = this.tableMeta.primaryKey ?? [];
685
+ if (pk.length === 0)
686
+ return undefined;
687
+ const fields = pk.map((c) => this.tableMeta.reverseColumnMap[c] ?? c);
688
+ return fields.length === 1 ? { [fields[0]]: 'asc' } : fields.map((f) => ({ [f]: 'asc' }));
689
+ }
690
+ /**
691
+ * The field names a `cursor` actually seeks on (its own keys with a defined
692
+ * value), in the canonical sorted order the cursor conditions are built in.
693
+ * Empty for a missing cursor or one whose every value is `undefined` (which
694
+ * emits no seek condition at all, so it does not paginate).
695
+ */
696
+ cursorFields(cursor) {
697
+ if (!cursor || typeof cursor !== 'object')
698
+ return [];
699
+ return Object.entries(cursor)
700
+ .filter(([, v]) => v !== undefined)
701
+ .map(([k]) => k)
702
+ .sort();
703
+ }
704
+ /**
705
+ * The ascending ordering implied by a `cursor`, or `undefined` when the shape
706
+ * is too ambiguous to order safely.
707
+ *
708
+ * A cursor seek emits `col > $n` per field (`<` when the orderBy says desc),
709
+ * so the ONLY ordering coherent with it is on the cursor's own field: ordering
710
+ * a seek on column X by column Y walks the table in an order the seek does not
711
+ * follow, which skips and repeats rows just as badly as no order at all. That
712
+ * is why this orders on the cursor field rather than blindly on the primary
713
+ * key when the two differ.
714
+ *
715
+ * Returns `undefined` (warn, inject nothing) for two shapes:
716
+ * - a MULTI-field cursor. `a > $1 AND b > $2` is a conjunction, not a proper
717
+ * composite keyset seek (`(a, b) > ($1, $2)`), so no single ORDER BY makes
718
+ * it correct. Injecting `(a asc, b asc)` would dress a broken seek up as a
719
+ * sound one.
720
+ * - a field that does not resolve to a real column. Column validation belongs
721
+ * to the normal build path, which raises a precise error; synthesizing an
722
+ * ORDER BY on it here would only change which error the caller sees.
723
+ */
724
+ cursorOrderBy(cursor) {
725
+ const fields = this.cursorFields(cursor);
726
+ if (fields.length !== 1)
727
+ return undefined;
728
+ const field = fields[0];
729
+ try {
730
+ this.toColumn(field);
731
+ }
732
+ catch {
733
+ return undefined;
734
+ }
735
+ return { [field]: 'asc' };
736
+ }
737
+ /**
738
+ * Whether a findMany paginates (`limit` / `take` / `offset` / `cursor`) but
739
+ * declares no ordering, which makes the returned page NON-DETERMINISTIC:
740
+ * Postgres is free to return different rows for the same unordered `LIMIT`
741
+ * once the heap changes underneath it, so a row can appear on two pages or on
742
+ * none.
743
+ *
744
+ * `cursor` counts, and is the worst case rather than an exception: a keyset
745
+ * seek with no ORDER BY is exactly this bug (`WHERE id > $1 LIMIT $2` walks
746
+ * the heap in whatever order the plan happens to produce). An empty orderBy
747
+ * (`[]`, or an object whose every value is `undefined`) counts as absent,
748
+ * because it emits no ordering.
749
+ *
750
+ * `distinct` is still excluded: that path re-orders in an outer wrapper around
751
+ * a `DISTINCT ON` whose ordering picks the representative row, so an implicit
752
+ * key would change which rows come back, not just their order.
753
+ */
754
+ isUnorderedPage(args) {
755
+ if (!args)
756
+ return false;
757
+ if (args.distinct !== undefined)
758
+ return false;
759
+ if (!isEmptyOrderBy(args.orderBy))
760
+ return false;
761
+ if (args.limit !== undefined || args.take !== undefined || args.offset !== undefined)
762
+ return true;
763
+ return this.cursorFields(args.cursor).length > 0;
764
+ }
765
+ /**
766
+ * Opt-in (`implicitPkOrdering`) primary-key ascending ordering for a paginating
767
+ * findMany that declares no `orderBy`, making its pages deterministic.
768
+ *
769
+ * OFF by default in CORE, deliberately: turning it on would add an `ORDER BY`
770
+ * to SQL that existing applications already emit, changing both the rows a
771
+ * given page returns and the plan the engine picks. That is a breaking change
772
+ * in everything but the type signature, so it waits for a major. The
773
+ * `turbine-orm/prisma-compat` layer defaults it ON instead, because reproducing
774
+ * Prisma's semantics is that layer's whole contract.
775
+ *
776
+ * An explicit `orderBy` always wins, a PK-less table is left alone (nothing
777
+ * stable to order by), and a composite PK orders on every column in
778
+ * declaration order. A `cursor` query orders on the CURSOR's field instead
779
+ * (see {@link cursorOrderBy}), and is left alone when that shape is ambiguous.
780
+ * `distinct` shapes are skipped (see {@link isUnorderedPage}). With the flag
781
+ * off this returns `undefined` before touching anything, so the emitted SQL is
782
+ * byte-identical to before.
783
+ */
784
+ implicitPkOrderBy(args) {
785
+ if (!this.implicitPkOrdering)
786
+ return undefined;
787
+ if (!this.isUnorderedPage(args))
788
+ return undefined;
789
+ if (this.cursorFields(args?.cursor).length > 0)
790
+ return this.cursorOrderBy(args?.cursor);
791
+ return this.pkOrderBy();
792
+ }
793
+ /**
794
+ * Dev-only, once per query shape: an unordered paginating findMany returns a
795
+ * non-deterministic page (see {@link isUnorderedPage}), and on real data it is
796
+ * also usually the slower plan (an unordered `LIMIT` can discard tens of
797
+ * thousands of heap rows that an index scan on the key would have skipped).
798
+ *
799
+ * Gated exactly like the other dev diagnostics (silent under
800
+ * `NODE_ENV=production`) and consistent with `warnOnUnlimited`: a per-call
801
+ * `warnOnUnlimited: false` silences it, `true` forces it past a config-level
802
+ * opt-out, and a config/per-table `warnOnUnlimited: false` silences it. Deduped
803
+ * process-wide through the shared warn registry, so it can never spam.
804
+ *
805
+ * Suppressed only when `implicitPkOrdering` will ACTUALLY order this query.
806
+ * The flag being on is not enough: a PK-less table and an ambiguous
807
+ * multi-field cursor both get no injected ordering, and those are precisely
808
+ * the shapes that still need saying out loud.
809
+ */
810
+ maybeWarnUnorderedPage(args) {
811
+ if (process.env.NODE_ENV === 'production')
812
+ return;
813
+ if (this.implicitPkOrdering && this.implicitPkOrderBy(args) !== undefined)
814
+ return;
815
+ const perCall = args?.warnOnUnlimited;
816
+ if (perCall === false)
817
+ return;
818
+ if (perCall === undefined && !this.warnOnUnlimited)
819
+ return;
820
+ if (!this.isUnorderedPage(args))
821
+ return;
822
+ const cursorFields = this.cursorFields(args?.cursor);
823
+ const shape = [
824
+ cursorFields.length > 0 ? 'cursor' : '',
825
+ args?.limit !== undefined ? 'limit' : '',
826
+ args?.take !== undefined ? 'take' : '',
827
+ args?.offset !== undefined ? 'offset' : '',
828
+ ]
829
+ .filter(Boolean)
830
+ .join('+');
831
+ if (!shouldWarnOnce(WARN_NS.unorderedPage, `${this.table}|${shape}`))
832
+ return;
833
+ const asOrderBy = (fields) => fields.length === 1 ? `{ ${fields[0]}: 'asc' }` : `[${fields.map((f) => `{ ${f}: 'asc' }`).join(', ')}]`;
834
+ const pk = (this.tableMeta.primaryKey ?? []).map((c) => this.tableMeta.reverseColumnMap[c] ?? c);
835
+ // A cursor seeks on its own field, so that is the ordering to recommend:
836
+ // the primary key would be the wrong advice whenever the two differ.
837
+ const orderFields = cursorFields.length > 0 ? cursorFields : pk;
838
+ const suggestion = orderFields.length > 0 ? `Add \`orderBy: ${asOrderBy(orderFields)}\`` : 'Add an `orderBy` on a unique column';
839
+ const seek = cursorFields.length > 0
840
+ ? 'a `cursor` seek with no orderBy compares against rows the engine is free to return in any order, so ' +
841
+ 'paging with it skips and repeats rows: '
842
+ : '';
843
+ console.warn(`[turbine] findMany on "${this.table}" paginates (${shape}) with no orderBy: ${seek}the page is NOT ` +
844
+ 'deterministic (the same query can return different rows as the table changes, so a row may appear ' +
845
+ `on two pages or on none). ${suggestion}, or set \`implicitPkOrdering: true\` in the client config ` +
846
+ 'to order paginated queries automatically (by the cursor field, or by the primary key).');
847
+ }
848
+ // -------------------------------------------------------------------------
572
849
  // relationLoadStrategy: 'auto', per-relation batched fallback when the
573
850
  // introspected metadata proves a probe is unindexed (finding 13).
574
851
  // -------------------------------------------------------------------------
@@ -656,13 +933,69 @@ export class QueryInterface {
656
933
  return { unindexed, eligible, miss };
657
934
  }
658
935
  /**
659
- * Partition a top-level `with` clause under `'auto'`: each relation whose
660
- * subtree has a PROVEN unindexed probe AND is fully batched-eligible routes to
661
- * `batchedWith`; everything else (indexed, composite-key, unknown) stays in
662
- * `joinWith` (byte-identical join). The reserved `_count` key partitions the
663
- * same way. Also returns the engaged relations for the dev note.
936
+ * Whether a query's parent set is potentially large at plan time, which is the
937
+ * only cardinality signal available before the base query runs. A `findMany`
938
+ * with no `limit`/`take` (or one above {@link autoToOneJoinMaxRows}) can return
939
+ * an arbitrary number of parent rows; a small `limit` bounds it. `findUnique` /
940
+ * `findFirst` pass `false` explicitly (their parent set is one row).
941
+ */
942
+ autoParentSetLarge(args) {
943
+ const limit = args?.take ?? args?.limit ?? this.defaultLimit;
944
+ return limit === undefined || limit > this.autoToOneThreshold();
945
+ }
946
+ /**
947
+ * The parent-row count at which `'auto'` stops preferring the single-statement
948
+ * join for a to-one relation.
949
+ *
950
+ * Resolution order:
951
+ * 1. an explicit `autoToOneJoinMaxRows` — an instruction, used verbatim
952
+ * (no clamping: the caller has measured their own workload);
953
+ * 2. the configured `autoRoundTripMs` divided by
954
+ * {@link AUTO_JOIN_PENALTY_MS_PER_ROW}, clamped to
955
+ * [{@link AUTO_TO_ONE_JOIN_ROWS_MIN}, {@link AUTO_TO_ONE_JOIN_ROWS_MAX}];
956
+ * 3. {@link AUTO_TO_ONE_JOIN_MAX_ROWS}, which is that same division applied
957
+ * to {@link AUTO_ASSUMED_ROUND_TRIP_MS}.
958
+ *
959
+ * Deriving it rather than hard-coding a row count is the whole point: the
960
+ * sweep in {@link AUTO_JOIN_PENALTY_MS_PER_ROW} shows the break-even moving
961
+ * 17x between a loopback link and a 2.7ms one while the per-row penalty stays
962
+ * put, so any single constant is wrong for someone by more than the margin it
963
+ * is trying to save. Placing the switch AT the break-even is also what removes
964
+ * the old cliff: two plans that cost the same at the boundary make the regret
965
+ * there ~1.0x, rising only as the true row count moves away from it — where
966
+ * the previous fixed 1000 put its WORST case (1.44x measured) immediately
967
+ * below its own switch point.
968
+ *
969
+ * Cheap enough to recompute per call (a division and two comparisons over
970
+ * readonly fields), so there is no cached copy to invalidate.
971
+ */
972
+ autoToOneThreshold() {
973
+ if (this.autoToOneJoinMaxRowsOption !== undefined)
974
+ return this.autoToOneJoinMaxRowsOption;
975
+ if (this.autoRoundTripMs === undefined)
976
+ return AUTO_TO_ONE_JOIN_MAX_ROWS;
977
+ const rows = Math.round(this.autoRoundTripMs / AUTO_JOIN_PENALTY_MS_PER_ROW);
978
+ return Math.min(AUTO_TO_ONE_JOIN_ROWS_MAX, Math.max(AUTO_TO_ONE_JOIN_ROWS_MIN, rows));
979
+ }
980
+ /**
981
+ * Partition a top-level `with` clause under `'auto'`. A relation routes to
982
+ * `batchedWith` when it is fully batched-eligible AND either
983
+ *
984
+ * 1. its subtree has a PROVEN unindexed probe (index metadata only), or
985
+ * 2. it is TO-ONE and the parent set is potentially large
986
+ * ({@link AUTO_TO_ONE_JOIN_MAX_ROWS}), since a correlated to-one subquery
987
+ * is re-evaluated per parent row no matter how well indexed it is.
988
+ *
989
+ * Everything else (indexed to-many, composite-key, unknown) stays in `joinWith`
990
+ * (byte-identical join). The reserved `_count` key falls back on rule 1 only,
991
+ * and only for a large parent set: an inline `_count` is one correlated
992
+ * `COUNT(*)` per parent row, so the grouped follow-up wins exactly when there
993
+ * are many parents, while for a handful of parents the extra round-trip costs
994
+ * more than the repeated (small) scans. Also returns the engaged relations for
995
+ * the dev note.
664
996
  */
665
- partitionWithForAuto(withClause) {
997
+ partitionWithForAuto(withClause, parentSetLarge) {
998
+ const hasIndexInfo = schemaHasIndexInfo(this.schema);
666
999
  const joinWith = {};
667
1000
  const batchedWith = {};
668
1001
  const engaged = [];
@@ -671,9 +1004,9 @@ export class QueryInterface {
671
1004
  continue;
672
1005
  if (key === '_count') {
673
1006
  const cv = this.autoCountVerdict(spec, this.tableMeta);
674
- if (cv.unindexed && cv.eligible) {
1007
+ if (hasIndexInfo && parentSetLarge && cv.unindexed && cv.eligible) {
675
1008
  batchedWith[key] = spec;
676
- engaged.push({ relation: '_count', miss: cv.miss });
1009
+ engaged.push({ relation: '_count', reason: 'unindexed', miss: cv.miss });
677
1010
  }
678
1011
  else {
679
1012
  joinWith[key] = spec;
@@ -686,9 +1019,16 @@ export class QueryInterface {
686
1019
  continue;
687
1020
  }
688
1021
  const v = this.autoSubtreeVerdict(rel, spec, 0);
689
- if (v.unindexed && v.eligible) {
1022
+ const unindexedFallback = hasIndexInfo && v.unindexed;
1023
+ const toOne = rel.type === 'belongsTo' || rel.type === 'hasOne';
1024
+ const cardinalityFallback = toOne && parentSetLarge;
1025
+ if (v.eligible && (unindexedFallback || cardinalityFallback)) {
690
1026
  batchedWith[key] = spec;
691
- engaged.push({ relation: key, miss: v.miss });
1027
+ engaged.push({
1028
+ relation: key,
1029
+ reason: unindexedFallback ? 'unindexed' : 'to-one-cardinality',
1030
+ miss: unindexedFallback ? v.miss : undefined,
1031
+ });
692
1032
  }
693
1033
  else {
694
1034
  joinWith[key] = spec;
@@ -700,17 +1040,18 @@ export class QueryInterface {
700
1040
  * Plan the `'auto'` split for a query's `with` clause: normalize stable order,
701
1041
  * partition, and return the split ONLY when at least one relation falls back
702
1042
  * to batched. Returns `null` (→ run the plain join path, byte-identical, same
703
- * cache keys) when there is no DB-backed index metadata or nothing qualifies.
1043
+ * cache keys) when nothing qualifies.
704
1044
  */
705
- planAuto(withArg, stableFlag) {
706
- // No DB-backed index info (code-first / defineSchema-only) cannot PROVE any
707
- // probe is unindexed, so 'auto' behaves exactly like 'join'.
708
- if (!schemaHasIndexInfo(this.schema))
1045
+ planAuto(withArg, stableFlag, parentSetLarge) {
1046
+ // Without DB-backed index info (code-first / defineSchema-only) no probe can
1047
+ // be PROVEN unindexed; the to-one cardinality rule does not depend on index
1048
+ // metadata, so it still applies.
1049
+ if (!schemaHasIndexInfo(this.schema) && !parentSetLarge)
709
1050
  return null;
710
1051
  const withClause = this.resolveStableOrder(stableFlag)
711
1052
  ? this.applyStableRelationOrder(withArg, this.table)
712
1053
  : withArg;
713
- const split = this.partitionWithForAuto(withClause);
1054
+ const split = this.partitionWithForAuto(withClause, parentSetLarge);
714
1055
  if (Object.keys(split.batchedWith).length === 0)
715
1056
  return null;
716
1057
  return split;
@@ -722,6 +1063,13 @@ export class QueryInterface {
722
1063
  for (const e of engaged) {
723
1064
  if (!shouldWarnOnce(WARN_NS.autoStrategy, `${this.table}.${e.relation}`))
724
1065
  continue;
1066
+ if (e.reason === 'to-one-cardinality') {
1067
+ console.warn(`[turbine] auto strategy: to-one relation "${e.relation}" on "${this.table}" loads batched ` +
1068
+ `(the query is unbounded or its limit exceeds ${this.autoToOneThreshold()} rows, and a correlated ` +
1069
+ 'to-one subquery is re-evaluated per parent row). Bound the query with a smaller `limit`, tune ' +
1070
+ "`autoToOneJoinMaxRows`, or set `relationLoadStrategy: 'join'` to force the single-statement plan.");
1071
+ continue;
1072
+ }
725
1073
  const probe = e.miss
726
1074
  ? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
727
1075
  : 'a probe in its subtree has no covering index';
@@ -1140,7 +1488,9 @@ export class QueryInterface {
1140
1488
  if (strategy === 'batched')
1141
1489
  return this.runFindUniqueBatched(args);
1142
1490
  if (strategy === 'auto') {
1143
- const split = this.planAuto(args.with, args.stableRelationOrder);
1491
+ // findUnique's parent set is a single row: the join plan's correlated
1492
+ // subqueries run once, so the cardinality rule never applies here.
1493
+ const split = this.planAuto(args.with, args.stableRelationOrder, false);
1144
1494
  if (split)
1145
1495
  return this.runAutoSplit(args, split, true);
1146
1496
  }
@@ -1189,6 +1539,12 @@ export class QueryInterface {
1189
1539
  args = { ...args, with: normalized };
1190
1540
  }
1191
1541
  const includePii = args.includePii === true;
1542
+ // findUnique is never flatten-planned: it reads ONE parent row, so the
1543
+ // correlated subquery already runs exactly once and a join buys nothing.
1544
+ // Say so, because the caller did ask for a strategy that is not running.
1545
+ if (args.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'flatten') {
1546
+ this.warnFlattenBlocked('findUnique reads a single parent row, where the correlated subquery already runs once');
1547
+ }
1192
1548
  const columnsList = this.resolveColumns(args.select, args.omit, includePii);
1193
1549
  // A global filter turns the where into `{ AND: [...] }`, which the
1194
1550
  // `isSimpleWhere` test below rejects → the general (buildWhereClause) path
@@ -1301,6 +1657,7 @@ export class QueryInterface {
1301
1657
  // -------------------------------------------------------------------------
1302
1658
  async findMany(args) {
1303
1659
  this.maybeWarnUnlimited(args);
1660
+ this.maybeWarnUnorderedPage(args);
1304
1661
  // Dev-only: warn on deeply nested with clauses
1305
1662
  if (process.env.NODE_ENV !== 'production') {
1306
1663
  if (args?.with) {
@@ -1317,7 +1674,7 @@ export class QueryInterface {
1317
1674
  if (strategy === 'batched')
1318
1675
  return this.runFindManyBatched(args);
1319
1676
  if (strategy === 'auto') {
1320
- const split = this.planAuto(args.with, args.stableRelationOrder);
1677
+ const split = this.planAuto(args.with, args.stableRelationOrder, this.autoParentSetLarge(args));
1321
1678
  if (split)
1322
1679
  return this.runAutoSplit(args, split, false);
1323
1680
  }
@@ -1437,6 +1794,23 @@ export class QueryInterface {
1437
1794
  if (normalized !== args.with)
1438
1795
  args = { ...args, with: normalized };
1439
1796
  }
1797
+ // An empty orderBy carries no ordering, so treat it as ABSENT rather than
1798
+ // emitting a bare `ORDER BY` with nothing after it (a syntax error at the
1799
+ // following LIMIT). This matters most for the documented escape hatch from
1800
+ // implicit ordering, "pass an explicit orderBy": callers who assemble that
1801
+ // array conditionally end up passing `[]`. Normalized here, before the
1802
+ // implicit-ordering and fingerprinting steps, so every downstream path sees
1803
+ // one shape.
1804
+ if (args?.orderBy !== undefined && isEmptyOrderBy(args.orderBy)) {
1805
+ args = { ...args, orderBy: undefined };
1806
+ }
1807
+ // Deterministic pagination (opt-in): fill a PK-asc orderBy into a paginating
1808
+ // query that declares none, BEFORE fingerprinting so the ordered and
1809
+ // unordered shapes get distinct cache entries. No-op unless
1810
+ // `implicitPkOrdering` is enabled (see implicitPkOrderBy).
1811
+ const implicitOrder = this.implicitPkOrderBy(args);
1812
+ if (implicitOrder)
1813
+ args = { ...args, orderBy: implicitOrder };
1440
1814
  // `distinct` + relation orderBy is refused up front (E003): the distinct
1441
1815
  // path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
1442
1816
  // <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
@@ -1499,7 +1873,16 @@ export class QueryInterface {
1499
1873
  // `withFp` does NOT capture; it is projection-invariant). So it MUST be its
1500
1874
  // own cache-key segment: a cached no-PII statement must never serve an
1501
1875
  // `includePii` call, nor vice versa.
1502
- const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}|pii=${includePii ? 1 : 0}${this.globalFilterCacheSegment()}`;
1876
+ // `relationLoadStrategy: 'flatten'` compiles eligible to-one relations to
1877
+ // LEFT JOINs instead of correlated subqueries — a completely different
1878
+ // statement for the same `with` shape, which `withFp` (strategy-blind)
1879
+ // does not distinguish. So the plan gets its own cache-key segment, exactly
1880
+ // like `pii=`: a join-planned template must never serve a flatten-planned
1881
+ // call. Absent (the default), the segment is empty and every existing cache
1882
+ // key is byte-identical to before.
1883
+ const flattenPlan = this.planFlatten(args, includePii);
1884
+ const flattenFp = flattenPlan ? `|fl=${flattenPlan.signature}` : '';
1885
+ 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()}`;
1503
1886
  const params = [];
1504
1887
  const buildSql = (freshParams) => {
1505
1888
  // Fresh build: generates SQL and populates freshParams
@@ -1516,9 +1899,15 @@ export class QueryInterface {
1516
1899
  distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
1517
1900
  distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
1518
1901
  }
1902
+ // Join-sink for `relationLoadStrategy: 'flatten'`. Filled while the SELECT
1903
+ // list is built (so the flattened relations' ON-clause params interleave
1904
+ // with the `with` params in one traversal, which the collect path
1905
+ // mirrors) and spliced into the FROM clause at assembly. Stays empty for
1906
+ // every other plan → byte-identical SQL.
1907
+ const relationJoins = [];
1519
1908
  let selectClause;
1520
1909
  if (args?.with) {
1521
- selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList, undefined, undefined, includePii);
1910
+ selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList, undefined, undefined, includePii, flattenPlan ? { plan: flattenPlan, joinSink: relationJoins } : undefined);
1522
1911
  }
1523
1912
  else if (columnsList) {
1524
1913
  selectClause = columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ');
@@ -1584,7 +1973,7 @@ export class QueryInterface {
1584
1973
  const orderBySql = args?.orderBy
1585
1974
  ? ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams, lateralJoins)}`
1586
1975
  : '';
1587
- sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${lateralJoins.join('')}${tail}${orderBySql}`;
1976
+ sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${relationJoins.join('')}${lateralJoins.join('')}${tail}${orderBySql}`;
1588
1977
  }
1589
1978
  // Pagination — push params in the same order the collect path mirrors
1590
1979
  // (limit before offset); the SQL TEXT shape is dialect-owned via
@@ -1592,10 +1981,10 @@ export class QueryInterface {
1592
1981
  let limitPh;
1593
1982
  let offsetPh;
1594
1983
  if (effectiveLimit !== undefined) {
1595
- limitPh = this.paginationRef(effectiveLimit, freshParams);
1984
+ limitPh = this.paginationRef(effectiveLimit, freshParams, 'limit');
1596
1985
  }
1597
1986
  if (args?.offset !== undefined) {
1598
- offsetPh = this.paginationRef(args.offset, freshParams);
1987
+ offsetPh = this.paginationRef(args.offset, freshParams, 'skip/offset');
1599
1988
  }
1600
1989
  sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
1601
1990
  return sql;
@@ -1608,7 +1997,7 @@ export class QueryInterface {
1608
1997
  }
1609
1998
  // 2. WITH relation params
1610
1999
  if (args?.with) {
1611
- this.collectWithParams(args.with, params);
2000
+ this.collectWithParams(args.with, params, undefined, flattenPlan);
1612
2001
  }
1613
2002
  // 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
1614
2003
  if (args?.cursor) {
@@ -1625,15 +2014,18 @@ export class QueryInterface {
1625
2014
  // 5. LIMIT param — skipped when the dialect inlines pagination (build path
1626
2015
  // mirrors via paginationRef → no placeholder, no param).
1627
2016
  if (effectiveLimit !== undefined && !this.dialect.inlineLimitOffset) {
1628
- params.push(Number(effectiveLimit));
2017
+ // Validate here too: on a cache HIT the build path never runs, and a
2018
+ // warmed template would otherwise bind an unvalidated NaN (= SQL NULL,
2019
+ // i.e. no limit at all).
2020
+ params.push(this.paginationValue(effectiveLimit, 'limit'));
1629
2021
  }
1630
2022
  // 6. OFFSET param — same inline gate as LIMIT above.
1631
2023
  if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
1632
- params.push(Number(args.offset));
2024
+ params.push(this.paginationValue(args.offset, 'skip/offset'));
1633
2025
  }
1634
2026
  this.crossCheckCache('findMany', ck, entry, buildSql, params);
1635
2027
  // Build the row parser once (positional shapes are computed here, not per row).
1636
- const parseWith = args?.with ? this.makeNestedParser(args.with, includePii) : null;
2028
+ const parseWith = args?.with ? this.makeNestedParser(args.with, includePii, flattenPlan) : null;
1637
2029
  return {
1638
2030
  sql: entry.sql,
1639
2031
  params,
@@ -1656,14 +2048,19 @@ export class QueryInterface {
1656
2048
  * method fall back to the full cursor path.
1657
2049
  *
1658
2050
  * **Cursor path:** Uses DECLARE CURSOR within a dedicated transaction on a
1659
- * single pooled connection. The cursor is automatically closed and the
1660
- * connection released when iteration completes or is terminated early
1661
- * (e.g. `break` from `for await`).
2051
+ * single pooled connection. The cursor is CLOSEd (in the dialect's `finally`)
2052
+ * and the connection released both when iteration completes normally and when
2053
+ * it ends early (`break` from `for await`). An error mid-stream skips the
2054
+ * CLOSE and rolls back instead, which drops the cursor with the transaction.
1662
2055
  *
1663
- * **Snapshot semantics note:** The speculative fast-path runs outside a
1664
- * transaction. If the result overflows and the cursor path is opened, the
1665
- * cursor runs in its own transaction — spanning two separate snapshots.
1666
- * For strict single-snapshot semantics, wrap the call in `$transaction`.
2056
+ * **Snapshot semantics note:** Outside a transaction the speculative
2057
+ * fast-path runs unwrapped, and an overflow opens the cursor in its own
2058
+ * transaction, so the two fetches span two separate snapshots. Wrapping the
2059
+ * call in `$transaction` gives strict single-snapshot semantics: both the
2060
+ * speculative fetch and the cursor then run on the caller's connection
2061
+ * inside the caller's transaction (the cursor path issues no BEGIN/COMMIT of
2062
+ * its own and releases nothing, so the caller's transaction is intact when
2063
+ * iteration finishes).
1667
2064
  *
1668
2065
  * @example
1669
2066
  * ```ts
@@ -1676,7 +2073,14 @@ export class QueryInterface {
1676
2073
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
1677
2074
  const hasRelations = !!args?.with;
1678
2075
  // Build the positional-aware relation parser once for the whole stream.
1679
- const parseWith = hasRelations ? this.makeNestedParser(args.with, args?.includePii === true) : null;
2076
+ // Same flatten plan buildFindMany compiles below. The plan is a pure
2077
+ // function of the schema, the `with` shape and `includePii` — never of
2078
+ // `limit` — so the batch-size override the speculative fetch applies cannot
2079
+ // change it, and the stream's parser matches the emitted SQL.
2080
+ const streamFlattenPlan = hasRelations ? this.planFlatten(args, args?.includePii === true) : null;
2081
+ const parseWith = hasRelations
2082
+ ? this.makeNestedParser(args.with, args?.includePii === true, streamFlattenPlan)
2083
+ : null;
1680
2084
  // --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
1681
2085
  const speculativeDeferred = this.buildFindMany({
1682
2086
  ...args,
@@ -1693,13 +2097,24 @@ export class QueryInterface {
1693
2097
  }
1694
2098
  // --- Overflow: fall back to cursor path from scratch ---
1695
2099
  const deferred = this.buildFindMany(args);
1696
- // Acquire a dedicated connection cursors require a single connection in a
2100
+ // Acquire a dedicated connection: cursors require a single connection in a
1697
2101
  // transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
1698
2102
  // … NO SCROLL CURSOR FOR → FETCH n → CLOSE → COMMIT, ROLLBACK on error); we
1699
2103
  // just parse + yield the row batches it produces.
1700
- const client = await this.pool.connect();
2104
+ //
2105
+ // Inside a caller-owned transaction there is nothing to check out: the
2106
+ // transaction-scoped pool pins every query to the transaction's own
2107
+ // connection, so `pool.query` already IS that connection. The stream rides
2108
+ // on it, is never released here, and the dialect is told to emit no
2109
+ // transaction control of its own (`ambientTransaction`).
2110
+ const client = this.txScoped ? null : await this.pool.connect();
2111
+ const conn = client ?? {
2112
+ query: async (text, values) => (await this.pool.query(text, values)),
2113
+ };
1701
2114
  try {
1702
- for await (const batch of this.dialect.openStream(client, deferred.sql, deferred.params, batchSize)) {
2115
+ for await (const batch of this.dialect.openStream(conn, deferred.sql, deferred.params, batchSize, {
2116
+ ambientTransaction: this.txScoped,
2117
+ })) {
1703
2118
  for (const row of batch) {
1704
2119
  yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
1705
2120
  }
@@ -1710,7 +2125,7 @@ export class QueryInterface {
1710
2125
  throw wrapPgError(err);
1711
2126
  }
1712
2127
  finally {
1713
- client.release();
2128
+ client?.release();
1714
2129
  }
1715
2130
  }
1716
2131
  // -------------------------------------------------------------------------
@@ -1726,7 +2141,8 @@ export class QueryInterface {
1726
2141
  return (rows[0] ?? null);
1727
2142
  }
1728
2143
  if (strategy === 'auto') {
1729
- const split = this.planAuto(args.with, args.stableRelationOrder);
2144
+ // findFirst is findMany + LIMIT 1: a one-row parent set.
2145
+ const split = this.planAuto(args.with, args.stableRelationOrder, false);
1730
2146
  if (split) {
1731
2147
  const rows = (await this.runAutoSplit({ ...args, limit: 1 }, split, false));
1732
2148
  return (rows[0] ?? null);
@@ -1875,11 +2291,22 @@ export class QueryInterface {
1875
2291
  }
1876
2292
  async runInImplicitTx(fn) {
1877
2293
  const client = await this.pool.connect();
2294
+ let began = false;
1878
2295
  try {
1879
2296
  await client.query(this.dialect.beginStatement());
2297
+ began = true;
1880
2298
  const { TransactionClient } = await import('../client.js');
2299
+ const tx = new TransactionClient(
1881
2300
  // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
1882
- const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
2301
+ client, this.schema,
2302
+ // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
2303
+ this.middlewares, this.options,
2304
+ // Pass the source pool so its read-only guard + capabilities carry into
2305
+ // the transaction-scoped proxy pool (see createTxPool). Without it a
2306
+ // read-only client's nested writes bypass the E018 guard and an
2307
+ // older-engine client falls back to the full capability set inside the
2308
+ // implicit transaction.
2309
+ this.pool);
1883
2310
  // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
1884
2311
  const ctx = { schema: this.schema, tx: tx };
1885
2312
  const result = await fn(ctx);
@@ -1887,11 +2314,15 @@ export class QueryInterface {
1887
2314
  return result;
1888
2315
  }
1889
2316
  catch (err) {
1890
- try {
1891
- await client.query(this.dialect.rollbackStatement());
1892
- }
1893
- catch {
1894
- // Best-effort rollback — connection may have died.
2317
+ // Only roll back a transaction we actually opened: a failed BEGIN must
2318
+ // not emit a stray ROLLBACK on a connection that never began one.
2319
+ if (began) {
2320
+ try {
2321
+ await client.query(this.dialect.rollbackStatement());
2322
+ }
2323
+ catch {
2324
+ // Best-effort rollback: connection may have died.
2325
+ }
1895
2326
  }
1896
2327
  throw err;
1897
2328
  }
@@ -2038,8 +2469,8 @@ export class QueryInterface {
2038
2469
  withFingerprint(withClause, table, depth = 0) {
2039
2470
  return relationsMod.withFingerprint(this.ctx, withClause, table, depth);
2040
2471
  }
2041
- collectWithParams(withClause, params, table) {
2042
- relationsMod.collectWithParams(this.ctx, withClause, params, table);
2472
+ collectWithParams(withClause, params, table, flattenPlan) {
2473
+ relationsMod.collectWithParams(this.ctx, withClause, params, table, flattenPlan);
2043
2474
  }
2044
2475
  orderByEntryFingerprint(d, targetTable) {
2045
2476
  return relationsMod.orderByEntryFingerprint(this.ctx, d, targetTable);
@@ -2071,11 +2502,75 @@ export class QueryInterface {
2071
2502
  getCamelDateFields(table, meta) {
2072
2503
  return relationsMod.getCamelDateFields(this.ctx, table, meta);
2073
2504
  }
2074
- makeNestedParser(withClause, includePii) {
2075
- return relationsMod.makeNestedParser(this.ctx, withClause, includePii);
2505
+ makeNestedParser(withClause, includePii, flattenPlan) {
2506
+ return relationsMod.makeNestedParser(this.ctx, withClause, includePii, flattenPlan);
2507
+ }
2508
+ buildSelectWithRelations(table, withClause, params, columnsList, depth, path, includePii, flatten) {
2509
+ return relationsMod.buildSelectWithRelations(this.ctx, table, withClause, params, columnsList, depth, path, includePii, flatten);
2076
2510
  }
2077
- buildSelectWithRelations(table, withClause, params, columnsList, depth, path, includePii) {
2078
- return relationsMod.buildSelectWithRelations(this.ctx, table, withClause, params, columnsList, depth, path, includePii);
2511
+ /**
2512
+ * Compile the `relationLoadStrategy: 'flatten'` plan for a findMany-shaped
2513
+ * query, or `null` to emit exactly the SQL (and cache key) the default
2514
+ * strategy emits.
2515
+ *
2516
+ * `'flatten'` compiles an eligible to-one relation to a `LEFT JOIN` with a
2517
+ * prefixed scalar projection instead of a correlated `json_build_object`
2518
+ * subquery. The correlated form is re-evaluated once per parent row, so its
2519
+ * cost scales with the parent set no matter how well the FK is indexed; the
2520
+ * join does not, and unlike `'batched'` it stays a single round trip.
2521
+ *
2522
+ * It is an EXPLICIT opt-in: `'auto'` is unchanged and never selects it.
2523
+ *
2524
+ * Query-shape gates (any of these routes the WHOLE query back to the default
2525
+ * strategy, silently and byte-identically):
2526
+ * - the resolved strategy is not `'flatten'`;
2527
+ * - `jsonEncoding: 'positional'` (a flattened relation emits no JSON at all,
2528
+ * so the two encodings are not composed in this version);
2529
+ * - the dialect owns relation-subquery generation
2530
+ * (`dialect.buildRelationSubquery`, i.e. SQL Server's `FOR JSON PATH`);
2531
+ * - `distinct` (the `DISTINCT ON` rewrite re-orders in an outer wrapper, and
2532
+ * the extra projected columns have not been proven safe there).
2533
+ *
2534
+ * `limit` / `offset` / `cursor` / `orderBy` need no gate: every flattened join
2535
+ * is over a PROVABLY UNIQUE target key, so it matches at most one row per
2536
+ * parent and cannot change the parent row count that pagination applies to.
2537
+ *
2538
+ * Per-relation eligibility lives in `planFlattenWith` / `planFlattenNode`.
2539
+ * Only the findMany family is planned (`findMany`, `findFirst`,
2540
+ * `findManyStream`, and pipelined `buildFindMany`); `findUnique` reads a single
2541
+ * parent row, where the correlated subquery runs exactly once, so it stays on
2542
+ * the default path.
2543
+ */
2544
+ planFlatten(args, includePii) {
2545
+ const withClause = args?.with;
2546
+ if (!withClause)
2547
+ return null;
2548
+ if (this.resolveLoadStrategy(args?.relationLoadStrategy) !== 'flatten')
2549
+ return null;
2550
+ // Query-level refusals: the whole plan is off, so name the reason once for
2551
+ // the query rather than once per relation (relations.ts warns per relation
2552
+ // for the eligibility rules it owns).
2553
+ const queryLevelBlock = this.jsonEncoding === 'positional'
2554
+ ? "`jsonEncoding: 'positional'` is active, and a flattened relation emits no JSON to encode"
2555
+ : this.dialect.buildRelationSubquery
2556
+ ? `the ${this.dialect.name} dialect generates relation subqueries itself`
2557
+ : args?.distinct && args.distinct.length > 0
2558
+ ? 'the query uses `distinct`'
2559
+ : undefined;
2560
+ if (queryLevelBlock) {
2561
+ this.warnFlattenBlocked(queryLevelBlock);
2562
+ return null;
2563
+ }
2564
+ return relationsMod.planFlattenWith(this.ctx, this.table, withClause, includePii);
2565
+ }
2566
+ /** Dev-only once-only note that `'flatten'` was refused for the whole query. */
2567
+ warnFlattenBlocked(reason) {
2568
+ if (process.env.NODE_ENV === 'production')
2569
+ return;
2570
+ if (!shouldWarnOnce(WARN_NS.flattenFallback, `${this.table}|query|${reason}`))
2571
+ return;
2572
+ console.warn(`[turbine] relationLoadStrategy: 'flatten' did not engage on "${this.table}": ${reason}. ` +
2573
+ 'Every relation loads via the correlated subquery instead (same rows, same values, different plan).');
2079
2574
  }
2080
2575
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
2081
2576
  toColumn(field) {
@@ -2266,7 +2761,18 @@ export class QueryInterface {
2266
2761
  const value = row[col];
2267
2762
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
2268
2763
  // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
2269
- if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
2764
+ //
2765
+ // An ARRAY value is excluded: `dateColumns` includes array-of-date
2766
+ // columns (`date[]`, `timestamp[]`, `timestamptz[]`), for which the
2767
+ // driver already hands back a `Date[]`. Coercing it ran
2768
+ // `new Date(String(theArray))` and replaced the whole array with a
2769
+ // single Invalid Date — the column was unreadable on every strategy.
2770
+ // The join strategy's string arrays are handled upstream instead, by
2771
+ // the JSON-wire decode in relations.ts.
2772
+ if ((dateCols.has(col) || camelDateFields.has(field)) &&
2773
+ value !== null &&
2774
+ !(value instanceof Date) &&
2775
+ !Array.isArray(value)) {
2270
2776
  // Offset-less strings (Postgres `timestamp`, json_agg output) are
2271
2777
  // pinned to UTC so results don't depend on the server's time zone.
2272
2778
  parsed[field] = this.utcTimestamps ? parseDbDate(String(value)) : new Date(value);