turbine-orm 0.40.1 → 0.41.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 (63) hide show
  1. package/README.md +22 -4
  2. package/dist/cjs/cli/config.js +3 -0
  3. package/dist/cjs/cli/index.js +179 -0
  4. package/dist/cjs/cli/prisma-report.js +216 -0
  5. package/dist/cjs/cli/prisma-resolve.js +335 -0
  6. package/dist/cjs/cli/prisma-schema.js +484 -0
  7. package/dist/cjs/client.js +1 -0
  8. package/dist/cjs/generate.js +279 -22
  9. package/dist/cjs/index.js +3 -2
  10. package/dist/cjs/introspect.js +203 -26
  11. package/dist/cjs/mssql.js +9 -10
  12. package/dist/cjs/mysql.js +3 -9
  13. package/dist/cjs/powdb-introspect.js +5 -10
  14. package/dist/cjs/powql.js +13 -0
  15. package/dist/cjs/prisma-compat.js +1147 -0
  16. package/dist/cjs/query/aggregates.js +67 -7
  17. package/dist/cjs/query/builder.js +388 -17
  18. package/dist/cjs/query/compound-unique.js +0 -0
  19. package/dist/cjs/query/relations.js +7 -5
  20. package/dist/cjs/query/warn-registry.js +98 -0
  21. package/dist/cjs/query/writes.js +13 -5
  22. package/dist/cjs/schema.js +47 -0
  23. package/dist/cjs/sqlite.js +4 -9
  24. package/dist/cli/config.d.ts +26 -0
  25. package/dist/cli/config.js +3 -0
  26. package/dist/cli/index.d.ts +11 -0
  27. package/dist/cli/index.js +180 -1
  28. package/dist/cli/prisma-report.d.ts +19 -0
  29. package/dist/cli/prisma-report.js +211 -0
  30. package/dist/cli/prisma-resolve.d.ts +87 -0
  31. package/dist/cli/prisma-resolve.js +330 -0
  32. package/dist/cli/prisma-schema.d.ts +116 -0
  33. package/dist/cli/prisma-schema.js +479 -0
  34. package/dist/cli/ui.d.ts +1 -1
  35. package/dist/client.d.ts +18 -2
  36. package/dist/client.js +1 -0
  37. package/dist/generate.d.ts +80 -1
  38. package/dist/generate.js +277 -25
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.js +1 -1
  41. package/dist/introspect.d.ts +92 -2
  42. package/dist/introspect.js +198 -26
  43. package/dist/mssql.js +10 -11
  44. package/dist/mysql.js +4 -10
  45. package/dist/powdb-introspect.js +5 -10
  46. package/dist/powql.js +13 -0
  47. package/dist/prisma-compat.d.ts +281 -0
  48. package/dist/prisma-compat.js +1143 -0
  49. package/dist/query/aggregates.js +67 -7
  50. package/dist/query/builder.d.ts +77 -4
  51. package/dist/query/builder.js +390 -19
  52. package/dist/query/compound-unique.d.ts +49 -0
  53. package/dist/query/compound-unique.js +0 -0
  54. package/dist/query/deferred.d.ts +18 -0
  55. package/dist/query/relations.js +7 -5
  56. package/dist/query/types.d.ts +70 -9
  57. package/dist/query/warn-registry.d.ts +57 -0
  58. package/dist/query/warn-registry.js +92 -0
  59. package/dist/query/writes.js +13 -5
  60. package/dist/schema.d.ts +75 -0
  61. package/dist/schema.js +46 -0
  62. package/dist/sqlite.js +5 -10
  63. package/package.json +6 -1
@@ -134,15 +134,37 @@ function buildGroupBy(qi, args) {
134
134
  }
135
135
  }
136
136
  // _count
137
- const countSelected = args._count === true || args._count === undefined;
138
- if (countSelected) {
139
- // default: always include count
137
+ // - `true` / omitted → scalar `_count` column (COUNT(*)), result `_count: number`.
138
+ // - record form → one column per selection: `_all` → COUNT(*) AS "_count__all"
139
+ // (double underscore, collision-proof against a real column named `all`),
140
+ // each field → COUNT(col) AS "_count_<col>", result `_count: { _all, field }`.
141
+ const countArg = args._count;
142
+ const countIsRecord = countArg !== true && countArg !== undefined && typeof countArg === 'object';
143
+ const scalarCount = countArg === true || countArg === undefined;
144
+ if (scalarCount) {
145
+ // default: always include the scalar count
140
146
  selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS _count`);
141
147
  }
148
+ else if (countIsRecord) {
149
+ for (const [field, enabled] of Object.entries(countArg)) {
150
+ if (!enabled)
151
+ continue;
152
+ if (field === '_all') {
153
+ selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS ${qi.q('_count__all')}`);
154
+ }
155
+ else {
156
+ const col = qi.toColumn(field);
157
+ selectExprs.push(`${qi.castAgg(`COUNT(${qi.q(col)})`, 'int')} AS ${qi.q(`_count_${col}`)}`);
158
+ }
159
+ }
160
+ }
142
161
  // ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
143
162
  // `_count`). Populated alongside the SELECT list below so `orderBy` can only
144
163
  // reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
145
164
  // the ordering expression (the SELECT cast is only for the returned value).
165
+ // COUNT(*) is orderable whenever it is selected: scalar `_count`, OR the
166
+ // record form containing `_all`.
167
+ const countSelected = scalarCount || (countIsRecord && countArg._all === true);
146
168
  const aggOrderExprs = new Map();
147
169
  if (countSelected)
148
170
  aggOrderExprs.set('_count', 'COUNT(*)');
@@ -234,12 +256,34 @@ function buildGroupBy(qi, args) {
234
256
  restructured[reader.resultKey] = reader.raw ? row[reader.rowKey] : parsed[reader.resultKey];
235
257
  }
236
258
  // _count
259
+ // scalar form → the plain `_count` (or driver-lowercased `count`) column.
260
+ // record form → assemble `{ _all, field, ... }` from the `_count__all`
261
+ // and `_count_<col>` columns. `_count__all` MUST be matched before the
262
+ // generic `_count_` prefix (its slice(7) would map through snakeToCamel).
237
263
  if ('_count' in row) {
238
264
  restructured._count = row._count;
239
265
  }
240
266
  else if ('count' in row) {
241
267
  restructured._count = row.count;
242
268
  }
269
+ else {
270
+ const countObj = {};
271
+ let hasCount = false;
272
+ for (const [rawKey, rawValue] of Object.entries(row)) {
273
+ if (rawKey === '_count__all') {
274
+ countObj._all = rawValue;
275
+ hasCount = true;
276
+ }
277
+ else if (rawKey.startsWith('_count_')) {
278
+ const col = rawKey.slice(7);
279
+ const field = qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
280
+ countObj[field] = rawValue;
281
+ hasCount = true;
282
+ }
283
+ }
284
+ if (hasCount)
285
+ restructured._count = countObj;
286
+ }
243
287
  // Collect aggregates into nested objects
244
288
  const sumObj = {};
245
289
  const avgObj = {};
@@ -562,6 +606,9 @@ function buildAggregate(qi, args) {
562
606
  }
563
607
  if (args._count && typeof args._count === 'object') {
564
608
  for (const key of Object.keys(args._count)) {
609
+ // `_all` is the reserved COUNT(*) selector, not a column.
610
+ if (key === '_all')
611
+ continue;
565
612
  if (!(key in meta.columnMap)) {
566
613
  throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${qi.table}"`);
567
614
  }
@@ -569,13 +616,20 @@ function buildAggregate(qi, args) {
569
616
  }
570
617
  }
571
618
  const selectExprs = [];
572
- // _count
619
+ // _count. `true` → scalar COUNT(*). Record form: reserved `_all` → COUNT(*) AS
620
+ // "_count__all" (double underscore, collision-proof against a real column named
621
+ // `all`); each field → COUNT(col) AS "_count_<col>".
573
622
  if (args._count === true) {
574
623
  selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS _count`);
575
624
  }
576
625
  else if (args._count && typeof args._count === 'object') {
577
626
  for (const [field, enabled] of Object.entries(args._count)) {
578
- if (enabled) {
627
+ if (!enabled)
628
+ continue;
629
+ if (field === '_all') {
630
+ selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS ${qi.q('_count__all')}`);
631
+ }
632
+ else {
579
633
  const col = qi.toColumn(field);
580
634
  selectExprs.push(`${qi.castAgg(`COUNT(${qi.q(col)})`, 'int')} AS ${qi.q(`_count_${col}`)}`);
581
635
  }
@@ -632,11 +686,17 @@ function buildAggregate(qi, args) {
632
686
  aggResult._count = row._count;
633
687
  }
634
688
  else {
635
- // Check for per-column counts
689
+ // Check for per-column counts. `_count__all` MUST be matched before the
690
+ // generic `_count_` prefix (its slice(7) is `_all`, which snakeToCamel
691
+ // would mangle to `All`).
636
692
  const countObj = {};
637
693
  let hasCountFields = false;
638
694
  for (const [key, val] of Object.entries(row)) {
639
- if (key.startsWith('_count_')) {
695
+ if (key === '_count__all') {
696
+ countObj._all = val;
697
+ hasCountFields = true;
698
+ }
699
+ else if (key.startsWith('_count_')) {
640
700
  const col = key.slice(7);
641
701
  const field = qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
642
702
  countObj[field] = val;
@@ -48,13 +48,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.QueryInterface = void 0;
49
49
  const dialect_js_1 = require("../dialect.js");
50
50
  const errors_js_1 = require("../errors.js");
51
+ const index_advisor_js_1 = require("../index-advisor.js");
51
52
  const nested_write_js_1 = require("../nested-write.js");
52
53
  const schema_js_1 = require("../schema.js");
53
54
  const aggMod = __importStar(require("./aggregates.js"));
54
55
  const batched_loader_js_1 = require("./batched-loader.js");
56
+ const compound_unique_js_1 = require("./compound-unique.js");
55
57
  const filters_js_1 = require("./filters.js");
56
58
  const relationsMod = __importStar(require("./relations.js"));
57
59
  const utils_js_1 = require("./utils.js");
60
+ const warn_registry_js_1 = require("./warn-registry.js");
58
61
  const whereMod = __importStar(require("./where.js"));
59
62
  const writesMod = __importStar(require("./writes.js"));
60
63
  /**
@@ -179,6 +182,34 @@ function cacheParamsEqual(a, b) {
179
182
  }
180
183
  return true;
181
184
  }
185
+ /**
186
+ * Return `args` with any Prisma compound-unique selector in `args.where`
187
+ * expanded to its column conjunction (see {@link expandCompoundUniqueWhere}).
188
+ * Returns the same `args` reference when nothing expands, so untouched queries
189
+ * are byte-identical. Generic so it serves every unique-`where` arg shape.
190
+ */
191
+ function maybeExpandCompoundUnique(meta, args) {
192
+ const where = args.where;
193
+ if (!where)
194
+ return args;
195
+ const expanded = (0, compound_unique_js_1.expandCompoundUniqueWhere)(meta, where);
196
+ return expanded === where ? args : { ...args, where: expanded };
197
+ }
198
+ /**
199
+ * Whether a relation `with`-clause `orderBy` carries no actual ordering: an
200
+ * empty array, or an object with no non-`undefined` own keys. Used by
201
+ * {@link QueryInterface.applyStableRelationOrder} so an explicit (non-empty)
202
+ * orderBy is never overwritten while an empty `{}` / `[]` still gets the
203
+ * synthesized PK order.
204
+ */
205
+ function isEmptyOrderBy(orderBy) {
206
+ if (Array.isArray(orderBy))
207
+ return orderBy.length === 0;
208
+ if (orderBy && typeof orderBy === 'object') {
209
+ return Object.values(orderBy).every((v) => v === undefined);
210
+ }
211
+ return orderBy === undefined || orderBy === null;
212
+ }
182
213
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
183
214
  class QueryInterface {
184
215
  pool;
@@ -212,8 +243,16 @@ class QueryInterface {
212
243
  */
213
244
  sqlCacheEnabled;
214
245
  dialect;
215
- /** Client-level default relation-loading strategy ('join' unless configured). */
246
+ /**
247
+ * Client-level default relation-loading strategy. When nothing is configured
248
+ * this is `'auto'` (the implicit default): per-relation, keep the single-
249
+ * statement join unless the introspected metadata proves a probe is unindexed,
250
+ * in which case that relation falls back to the batched loader. An explicit
251
+ * `'join'`/`'batched'` (client or query level) always wins.
252
+ */
216
253
  relationLoadStrategy;
254
+ /** Client-level default for {@link applyStableRelationOrder} (off unless configured). */
255
+ stableRelationOrder;
217
256
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
218
257
  jsonEncoding;
219
258
  /**
@@ -244,8 +283,6 @@ class QueryInterface {
244
283
  * must never receive this schema's `::"enum"` cast (see enumTypeForColumn).
245
284
  */
246
285
  crossSchemaTypeColumns;
247
- /** Tracks tables that have already triggered a deep-with warning (one-time) */
248
- deepWithWarned = new Set();
249
286
  /**
250
287
  * Per-table memo of date columns keyed by their camelCase FIELD name.
251
288
  * `meta.dateColumns` is keyed by raw snake_case column name, which matches
@@ -260,6 +297,15 @@ class QueryInterface {
260
297
  options;
261
298
  /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
262
299
  currentAction = 'raw';
300
+ /**
301
+ * Tags the query events of an in-flight `relationLoadStrategy: 'auto'` query
302
+ * that engaged the batched fallback (`'auto-batched'`), so observability sees
303
+ * which queries the auto default re-planned. Same transient-instance-state
304
+ * caveat as {@link currentAction}: set for the whole auto-split operation and
305
+ * cleared afterward; a concurrent unrelated query on the same accessor during
306
+ * that window could read it (a best-effort diagnostic tag, not load-bearing).
307
+ */
308
+ currentStrategyTag;
263
309
  /**
264
310
  * The active query's `skipGlobalFilters` opt-out, set at the top of each
265
311
  * `build*` method and read deep in the (synchronous) SQL-build + param-collect
@@ -326,7 +372,8 @@ class QueryInterface {
326
372
  this.sqlCacheEnabled = options?.sqlCache !== false && sqlCacheSize !== 0;
327
373
  this.sqlTemplateCache = new utils_js_1.LRUCache(sqlCacheSize !== undefined && sqlCacheSize > 0 ? Math.floor(sqlCacheSize) : 1000);
328
374
  this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
329
- this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
375
+ this.relationLoadStrategy = options?.relationLoadStrategy ?? 'auto';
376
+ this.stableRelationOrder = options?.stableRelationOrder === true;
330
377
  this.jsonEncoding = options?.jsonEncoding ?? 'object';
331
378
  // Only retain the map when it has at least one entry, so `globalFilters`
332
379
  // stays `undefined` (and every merge path a no-op) for the common case.
@@ -492,12 +539,277 @@ class QueryInterface {
492
539
  // -------------------------------------------------------------------------
493
540
  /**
494
541
  * Resolve the effective relation-loading strategy for a query: the per-query
495
- * arg wins, then the client-level default, then `'join'`. Only meaningful when
542
+ * arg wins, then the client-level default, then `'auto'`. Only meaningful when
496
543
  * a `with` clause is present; the callers gate on that.
497
544
  */
498
545
  resolveLoadStrategy(argStrategy) {
499
546
  return argStrategy ?? this.relationLoadStrategy;
500
547
  }
548
+ /**
549
+ * The effective {@link QueryInterfaceOptions.stableRelationOrder} for a query:
550
+ * the per-query arg wins, then the client-level default (off).
551
+ */
552
+ resolveStableOrder(argFlag) {
553
+ return argFlag ?? this.stableRelationOrder;
554
+ }
555
+ /**
556
+ * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
557
+ * explicit one, recursing into nested `with`. Returns a CLONED clause (user
558
+ * args are never mutated); when nothing needs filling it returns the input
559
+ * object unchanged, so the byte-identical fast path stays free. Only called
560
+ * when {@link resolveStableOrder} is true; runs BEFORE `withFingerprint`, so
561
+ * the two orderings get distinct SQL-cache entries automatically. To-one
562
+ * relations are single rows (no array to order) and PK-less targets have
563
+ * nothing stable to order by, so both are left untouched.
564
+ */
565
+ applyStableRelationOrder(withClause, table, depth = 0) {
566
+ if (depth >= 10)
567
+ return withClause; // parity with the build depth cap
568
+ const meta = this.schema.tables[table];
569
+ if (!meta)
570
+ return withClause;
571
+ let out;
572
+ for (const [relName, spec] of Object.entries(withClause)) {
573
+ if (relName === '_count' || !spec)
574
+ continue; // `_count` is a count, not a row load
575
+ const rel = (0, utils_js_1.ownLookup)(meta.relations, relName);
576
+ if (!rel)
577
+ continue; // unknown relation, let the build path surface E005
578
+ const options = spec === true ? {} : spec;
579
+ // Recurse first so a nested change alone still clones this level.
580
+ const nestedWith = options.with;
581
+ const newNested = nestedWith ? this.applyStableRelationOrder(nestedWith, rel.to, depth + 1) : undefined;
582
+ const nestedChanged = newNested !== undefined && newNested !== nestedWith;
583
+ const isToMany = rel.type === 'hasMany' || rel.type === 'manyToMany';
584
+ const hasOrder = options.orderBy !== undefined && !isEmptyOrderBy(options.orderBy);
585
+ let synthOrder;
586
+ if (isToMany && !hasOrder) {
587
+ const targetMeta = this.schema.tables[rel.to];
588
+ const pk = targetMeta?.primaryKey ?? [];
589
+ if (targetMeta && pk.length > 0) {
590
+ const pkFields = pk.map((c) => targetMeta.reverseColumnMap[c] ?? c);
591
+ synthOrder =
592
+ pkFields.length === 1 ? { [pkFields[0]]: 'asc' } : pkFields.map((f) => ({ [f]: 'asc' }));
593
+ }
594
+ }
595
+ if (!synthOrder && !nestedChanged)
596
+ continue; // nothing to change, keep the ref
597
+ out ??= { ...withClause };
598
+ const clonedSpec = { ...options };
599
+ if (synthOrder)
600
+ clonedSpec.orderBy = synthOrder;
601
+ if (nestedChanged)
602
+ clonedSpec.with = newNested;
603
+ out[relName] = clonedSpec;
604
+ }
605
+ return out ?? withClause;
606
+ }
607
+ // -------------------------------------------------------------------------
608
+ // relationLoadStrategy: 'auto', per-relation batched fallback when the
609
+ // introspected metadata proves a probe is unindexed (finding 13).
610
+ // -------------------------------------------------------------------------
611
+ /**
612
+ * Whether a relation can be served by the batched loader, i.e. all its
613
+ * correlation keys are single-column (the loader throws E017 on composite
614
+ * keys). Composite-key relations therefore always stay on the join plan under
615
+ * `'auto'` (and keep the existing unindexed-probe dev warning).
616
+ */
617
+ relationBatchEligible(rel) {
618
+ if (rel.type === 'manyToMany') {
619
+ const through = rel.through;
620
+ if (!through)
621
+ return false;
622
+ const pkLen = this.schema.tables[rel.to]?.primaryKey.length ?? 0;
623
+ return ((0, schema_js_1.normalizeKeyColumns)(through.sourceKey).length === 1 &&
624
+ (0, schema_js_1.normalizeKeyColumns)(through.targetKey).length === 1 &&
625
+ (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length === 1 &&
626
+ pkLen === 1);
627
+ }
628
+ return (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length === 1 && (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length === 1;
629
+ }
630
+ /**
631
+ * The verdict for one relation SUBTREE under `'auto'`: is any probe in the
632
+ * subtree unindexed, is EVERY relation in the subtree batched-eligible, and
633
+ * the first unindexed probe found (for the dev note). Subtree-atomic: a whole
634
+ * top-level relation falls back only when its entire subtree is eligible,
635
+ * mirroring the batched loader recursing the same tree.
636
+ */
637
+ autoSubtreeVerdict(rel, spec, depth) {
638
+ let eligible = this.relationBatchEligible(rel);
639
+ const ownMiss = (0, index_advisor_js_1.missingIndexForRelation)(this.schema, rel);
640
+ let unindexed = ownMiss !== null;
641
+ let miss = ownMiss ?? undefined;
642
+ const options = spec === true ? {} : spec;
643
+ const nested = options.with;
644
+ if (nested && depth < 10) {
645
+ const targetMeta = this.schema.tables[rel.to];
646
+ for (const [childName, childSpec] of Object.entries(nested)) {
647
+ if (!childSpec)
648
+ continue;
649
+ if (childName === '_count') {
650
+ const cv = this.autoCountVerdict(childSpec, targetMeta);
651
+ eligible = eligible && cv.eligible;
652
+ unindexed = unindexed || cv.unindexed;
653
+ if (!miss && cv.miss)
654
+ miss = cv.miss;
655
+ continue;
656
+ }
657
+ const childRel = (0, utils_js_1.ownLookup)(targetMeta?.relations ?? {}, childName);
658
+ if (!childRel)
659
+ continue; // unknown nested relation, let the build path surface it
660
+ const v = this.autoSubtreeVerdict(childRel, childSpec, depth + 1);
661
+ eligible = eligible && v.eligible;
662
+ unindexed = unindexed || v.unindexed;
663
+ if (!miss && v.miss)
664
+ miss = v.miss;
665
+ }
666
+ }
667
+ return { unindexed, eligible, miss };
668
+ }
669
+ /** The `_count` verdict under `'auto'`: any counted probe unindexed + all single-key. */
670
+ autoCountVerdict(countSpec, parentMeta) {
671
+ if (!parentMeta)
672
+ return { unindexed: false, eligible: true };
673
+ let rels;
674
+ try {
675
+ rels = (0, batched_loader_js_1.resolveCountRelations)(parentMeta, countSpec);
676
+ }
677
+ catch {
678
+ return { unindexed: false, eligible: true }; // let the join/loader path surface the error
679
+ }
680
+ let unindexed = false;
681
+ let eligible = true;
682
+ let miss;
683
+ for (const rel of rels) {
684
+ if (!this.relationBatchEligible(rel))
685
+ eligible = false;
686
+ const m = (0, index_advisor_js_1.missingIndexForRelation)(this.schema, rel);
687
+ if (m) {
688
+ unindexed = true;
689
+ miss ??= m;
690
+ }
691
+ }
692
+ return { unindexed, eligible, miss };
693
+ }
694
+ /**
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.
700
+ */
701
+ partitionWithForAuto(withClause) {
702
+ const joinWith = {};
703
+ const batchedWith = {};
704
+ const engaged = [];
705
+ for (const [key, spec] of Object.entries(withClause)) {
706
+ if (!spec)
707
+ continue;
708
+ if (key === '_count') {
709
+ const cv = this.autoCountVerdict(spec, this.tableMeta);
710
+ if (cv.unindexed && cv.eligible) {
711
+ batchedWith[key] = spec;
712
+ engaged.push({ relation: '_count', miss: cv.miss });
713
+ }
714
+ else {
715
+ joinWith[key] = spec;
716
+ }
717
+ continue;
718
+ }
719
+ const rel = (0, utils_js_1.ownLookup)(this.tableMeta.relations, key);
720
+ if (!rel) {
721
+ joinWith[key] = spec; // unknown relation, let the join path surface E005
722
+ continue;
723
+ }
724
+ const v = this.autoSubtreeVerdict(rel, spec, 0);
725
+ if (v.unindexed && v.eligible) {
726
+ batchedWith[key] = spec;
727
+ engaged.push({ relation: key, miss: v.miss });
728
+ }
729
+ else {
730
+ joinWith[key] = spec;
731
+ }
732
+ }
733
+ return { joinWith, batchedWith, engaged };
734
+ }
735
+ /**
736
+ * Plan the `'auto'` split for a query's `with` clause: normalize stable order,
737
+ * partition, and return the split ONLY when at least one relation falls back
738
+ * 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.
740
+ */
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))
745
+ return null;
746
+ const withClause = this.resolveStableOrder(stableFlag)
747
+ ? this.applyStableRelationOrder(withArg, this.table)
748
+ : withArg;
749
+ const split = this.partitionWithForAuto(withClause);
750
+ if (Object.keys(split.batchedWith).length === 0)
751
+ return null;
752
+ return split;
753
+ }
754
+ /** Dev-only once-per-relation note that `'auto'` engaged the batched fallback. */
755
+ emitAutoNotes(engaged) {
756
+ if (process.env.NODE_ENV === 'production')
757
+ return;
758
+ for (const e of engaged) {
759
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.autoStrategy, `${this.table}.${e.relation}`))
760
+ continue;
761
+ const probe = e.miss
762
+ ? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
763
+ : 'a probe in its subtree has no covering index';
764
+ console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}). ` +
765
+ "Create the covering index (or set `relationLoadStrategy: 'join'` to force the single-statement " +
766
+ 'plan); run `npx turbine doctor` for the exact CREATE INDEX SQL.');
767
+ }
768
+ }
769
+ /**
770
+ * Execute a findMany/findUnique `'auto'` split: run the base query with the
771
+ * residual `joinWith` (plus any parent stitch keys the batched subset needs),
772
+ * then load `batchedWith` via the batched loader and stitch. `single` returns
773
+ * the first entity (findUnique) instead of the array. Output is identical in
774
+ * shape to the pure join plan.
775
+ */
776
+ async runAutoSplit(args, split, single) {
777
+ this.emitAutoNotes(split.engaged);
778
+ const { joinWith, batchedWith } = split;
779
+ // Scope-rule parity with the batched strategy: reject nested pick ordering
780
+ // on the batched subset up front.
781
+ (0, batched_loader_js_1.rejectNestedPickOrder)(batchedWith);
782
+ const skip = args.skipGlobalFilters;
783
+ const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, batchedWith);
784
+ const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
785
+ const hasJoin = Object.keys(joinWith).length > 0;
786
+ // Force the residual `with` onto the join plan so the base query never
787
+ // re-enters this auto planning.
788
+ const baseArgs = {
789
+ ...args,
790
+ with: hasJoin ? joinWith : undefined,
791
+ select: proj.select,
792
+ omit: proj.omit,
793
+ relationLoadStrategy: 'join',
794
+ };
795
+ this.currentStrategyTag = 'auto-batched';
796
+ try {
797
+ const deferred = single
798
+ ? this.buildFindUnique(baseArgs)
799
+ : this.buildFindMany(baseArgs);
800
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
801
+ const rows = deferred.transform(result);
802
+ const entities = single ? (rows ? [rows] : []) : rows;
803
+ if (entities.length > 0) {
804
+ await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii === true), entities, batchedWith, args.timeout);
805
+ }
806
+ (0, batched_loader_js_1.stripFields)(entities, proj.strip);
807
+ return single ? (entities[0] ?? null) : entities;
808
+ }
809
+ finally {
810
+ this.currentStrategyTag = undefined;
811
+ }
812
+ }
501
813
  /**
502
814
  * Build the {@link RelationLoadContext} the batched loader needs, closing over
503
815
  * this interface's pool/dialect/executor. Child readers are constructed on the
@@ -549,7 +861,12 @@ class QueryInterface {
549
861
  * from the returned rows, so the shape matches the join strategy exactly.
550
862
  */
551
863
  async runFindManyBatched(args) {
552
- const withClause = args.with;
864
+ // Stable relation order (opt-in): the batched loader forwards each relation's
865
+ // orderBy into its follow-up query, so filling the synthesized PK order here
866
+ // makes the batched output deterministic exactly like the join path.
867
+ const withClause = this.resolveStableOrder(args.stableRelationOrder)
868
+ ? this.applyStableRelationOrder(args.with, this.table)
869
+ : args.with;
553
870
  // Scope-rule parity with the join strategy (which throws at SQL build):
554
871
  // reject nested pick-row ordering BEFORE the base query so acceptance
555
872
  // never depends on how many rows come back.
@@ -705,7 +1022,17 @@ class QueryInterface {
705
1022
  if (!onQuery)
706
1023
  return;
707
1024
  try {
708
- onQuery({ sql, params, duration, model: this.table, action, rows, timestamp: new Date(), error });
1025
+ onQuery({
1026
+ sql,
1027
+ params,
1028
+ duration,
1029
+ model: this.table,
1030
+ action,
1031
+ rows,
1032
+ timestamp: new Date(),
1033
+ error,
1034
+ strategy: this.currentStrategyTag,
1035
+ });
709
1036
  }
710
1037
  catch {
711
1038
  // Listener errors must never crash a query
@@ -844,8 +1171,15 @@ class QueryInterface {
844
1171
  // -------------------------------------------------------------------------
845
1172
  async findUnique(args) {
846
1173
  return this.executeWithMiddleware('findUnique', args, async () => {
847
- if (args.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'batched') {
848
- return this.runFindUniqueBatched(args);
1174
+ if (args.with) {
1175
+ const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
1176
+ if (strategy === 'batched')
1177
+ return this.runFindUniqueBatched(args);
1178
+ if (strategy === 'auto') {
1179
+ const split = this.planAuto(args.with, args.stableRelationOrder);
1180
+ if (split)
1181
+ return this.runAutoSplit(args, split, true);
1182
+ }
849
1183
  }
850
1184
  const deferred = this.buildFindUnique(args);
851
1185
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
@@ -859,7 +1193,10 @@ class QueryInterface {
859
1193
  * strategy's shape for the one row.
860
1194
  */
861
1195
  async runFindUniqueBatched(args) {
862
- const withClause = args.with;
1196
+ // Stable relation order (opt-in), see runFindManyBatched.
1197
+ const withClause = this.resolveStableOrder(args.stableRelationOrder)
1198
+ ? this.applyStableRelationOrder(args.with, this.table)
1199
+ : args.with;
863
1200
  // Same scope-rule parity as runFindManyBatched: reject before querying.
864
1201
  (0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
865
1202
  const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
@@ -877,6 +1214,16 @@ class QueryInterface {
877
1214
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
878
1215
  buildFindUnique(args) {
879
1216
  this.currentSkip = args.skipGlobalFilters;
1217
+ // Prisma compound-unique selector expansion (before global-filter merge and
1218
+ // fingerprinting, so the cache only ever sees the canonical expanded where).
1219
+ args = maybeExpandCompoundUnique(this.tableMeta, args);
1220
+ // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
1221
+ // relations before fingerprinting (see buildFindMany).
1222
+ if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
1223
+ const normalized = this.applyStableRelationOrder(args.with, this.table);
1224
+ if (normalized !== args.with)
1225
+ args = { ...args, with: normalized };
1226
+ }
880
1227
  const includePii = args.includePii === true;
881
1228
  const columnsList = this.resolveColumns(args.select, args.omit, includePii);
882
1229
  // A global filter turns the where into `{ AND: [...] }`, which the
@@ -994,16 +1341,22 @@ class QueryInterface {
994
1341
  if (process.env.NODE_ENV !== 'production') {
995
1342
  if (args?.with) {
996
1343
  const depth = this.measureWithDepth(args.with);
997
- if (depth > 5 && !this.deepWithWarned.has(this.table)) {
998
- this.deepWithWarned.add(this.table);
1344
+ if (depth > 5 && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.deepWith, this.table)) {
999
1345
  console.warn(`[turbine] Deep with clause (depth ${depth}) on "${this.tableMeta.name}" — ` +
1000
1346
  'consider splitting into separate queries for better performance.');
1001
1347
  }
1002
1348
  }
1003
1349
  }
1004
1350
  return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
1005
- if (args?.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'batched') {
1006
- return this.runFindManyBatched(args);
1351
+ if (args?.with) {
1352
+ const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
1353
+ if (strategy === 'batched')
1354
+ return this.runFindManyBatched(args);
1355
+ if (strategy === 'auto') {
1356
+ const split = this.planAuto(args.with, args.stableRelationOrder);
1357
+ if (split)
1358
+ return this.runAutoSplit(args, split, false);
1359
+ }
1007
1360
  }
1008
1361
  const deferred = this.buildFindMany(args);
1009
1362
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
@@ -1112,6 +1465,14 @@ class QueryInterface {
1112
1465
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
1113
1466
  buildFindMany(args) {
1114
1467
  this.currentSkip = args?.skipGlobalFilters;
1468
+ // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
1469
+ // relations BEFORE fingerprinting, so the two orderings get distinct cache
1470
+ // entries and every downstream path (SQL build, collect, parser) inherits it.
1471
+ if (args?.with && this.resolveStableOrder(args.stableRelationOrder)) {
1472
+ const normalized = this.applyStableRelationOrder(args.with, this.table);
1473
+ if (normalized !== args.with)
1474
+ args = { ...args, with: normalized };
1475
+ }
1115
1476
  // `distinct` + relation orderBy is refused up front (E003): the distinct
1116
1477
  // path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
1117
1478
  // <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
@@ -1393,10 +1754,20 @@ class QueryInterface {
1393
1754
  // -------------------------------------------------------------------------
1394
1755
  async findFirst(args) {
1395
1756
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
1396
- if (args?.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'batched') {
1757
+ if (args?.with) {
1758
+ const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
1397
1759
  // findFirst is findMany + LIMIT 1: batch the single base row, then load.
1398
- const rows = await this.runFindManyBatched({ ...args, limit: 1 });
1399
- return (rows[0] ?? null);
1760
+ if (strategy === 'batched') {
1761
+ const rows = await this.runFindManyBatched({ ...args, limit: 1 });
1762
+ return (rows[0] ?? null);
1763
+ }
1764
+ if (strategy === 'auto') {
1765
+ const split = this.planAuto(args.with, args.stableRelationOrder);
1766
+ if (split) {
1767
+ const rows = (await this.runAutoSplit({ ...args, limit: 1 }, split, false));
1768
+ return (rows[0] ?? null);
1769
+ }
1770
+ }
1400
1771
  }
1401
1772
  const deferred = this.buildFindFirst(args);
1402
1773
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);