turbine-orm 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +34 -32
  2. package/dist/adapters/cockroachdb.js +21 -3
  3. package/dist/adapters/index.d.ts +15 -0
  4. package/dist/adapters/yugabytedb.js +20 -3
  5. package/dist/cjs/adapters/cockroachdb.js +21 -3
  6. package/dist/cjs/adapters/index.d.ts +15 -0
  7. package/dist/cjs/adapters/yugabytedb.js +20 -3
  8. package/dist/cjs/cli/destructive.d.ts +18 -4
  9. package/dist/cjs/cli/destructive.js +230 -122
  10. package/dist/cjs/cli/index.d.ts +21 -4
  11. package/dist/cjs/cli/index.js +119 -22
  12. package/dist/cjs/cli/mcp.d.ts +28 -8
  13. package/dist/cjs/cli/mcp.js +170 -127
  14. package/dist/cjs/cli/migrate.d.ts +134 -13
  15. package/dist/cjs/cli/migrate.js +349 -241
  16. package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
  17. package/dist/cjs/cli/pii-predicate-guard.js +390 -0
  18. package/dist/cjs/cli/prisma-resolve.js +75 -4
  19. package/dist/cjs/cli/prisma-schema.d.ts +17 -1
  20. package/dist/cjs/cli/prisma-schema.js +83 -17
  21. package/dist/cjs/cli/sql-statements.d.ts +125 -0
  22. package/dist/cjs/cli/sql-statements.js +378 -0
  23. package/dist/cjs/cli/studio.js +49 -118
  24. package/dist/cjs/cli/ui.d.ts +1 -1
  25. package/dist/cjs/client.d.ts +43 -0
  26. package/dist/cjs/client.js +125 -6
  27. package/dist/cjs/dialect.d.ts +123 -0
  28. package/dist/cjs/dialect.js +33 -0
  29. package/dist/cjs/errors.d.ts +74 -1
  30. package/dist/cjs/errors.js +239 -25
  31. package/dist/cjs/index-advisor.d.ts +33 -1
  32. package/dist/cjs/index-advisor.js +32 -1
  33. package/dist/cjs/introspect.d.ts +48 -0
  34. package/dist/cjs/introspect.js +222 -91
  35. package/dist/cjs/mssql.js +43 -1
  36. package/dist/cjs/mysql.d.ts +5 -2
  37. package/dist/cjs/mysql.js +202 -17
  38. package/dist/cjs/nested-write.js +6 -1
  39. package/dist/cjs/pipeline-submittable.js +17 -3
  40. package/dist/cjs/pipeline.js +75 -9
  41. package/dist/cjs/powdb.d.ts +23 -0
  42. package/dist/cjs/powdb.js +33 -1
  43. package/dist/cjs/powql.d.ts +61 -9
  44. package/dist/cjs/powql.js +186 -49
  45. package/dist/cjs/prisma-compat.js +160 -41
  46. package/dist/cjs/query/aggregates.d.ts +1 -1
  47. package/dist/cjs/query/aggregates.js +80 -18
  48. package/dist/cjs/query/batched-loader.d.ts +10 -0
  49. package/dist/cjs/query/batched-loader.js +268 -7
  50. package/dist/cjs/query/builder.d.ts +73 -0
  51. package/dist/cjs/query/builder.js +225 -28
  52. package/dist/cjs/query/filters.d.ts +162 -0
  53. package/dist/cjs/query/filters.js +250 -1
  54. package/dist/cjs/query/relations.d.ts +10 -10
  55. package/dist/cjs/query/relations.js +93 -12
  56. package/dist/cjs/query/types.d.ts +14 -1
  57. package/dist/cjs/query/utils.d.ts +146 -2
  58. package/dist/cjs/query/utils.js +210 -4
  59. package/dist/cjs/query/warn-registry.d.ts +10 -0
  60. package/dist/cjs/query/warn-registry.js +10 -0
  61. package/dist/cjs/query/where-compile.d.ts +30 -0
  62. package/dist/cjs/query/where-compile.js +41 -0
  63. package/dist/cjs/query/where.d.ts +128 -13
  64. package/dist/cjs/query/where.js +215 -77
  65. package/dist/cjs/query/writes.d.ts +1 -1
  66. package/dist/cjs/query/writes.js +39 -15
  67. package/dist/cjs/schema-builder.d.ts +2 -1
  68. package/dist/cjs/schema-sql.d.ts +94 -4
  69. package/dist/cjs/schema-sql.js +506 -30
  70. package/dist/cjs/schema.d.ts +3 -1
  71. package/dist/cjs/sqlite.d.ts +6 -0
  72. package/dist/cjs/sqlite.js +151 -10
  73. package/dist/cjs/typed-sql.d.ts +29 -1
  74. package/dist/cjs/typed-sql.js +30 -12
  75. package/dist/cli/destructive.d.ts +18 -4
  76. package/dist/cli/destructive.js +229 -121
  77. package/dist/cli/index.d.ts +21 -4
  78. package/dist/cli/index.js +120 -24
  79. package/dist/cli/mcp.d.ts +28 -8
  80. package/dist/cli/mcp.js +172 -129
  81. package/dist/cli/migrate.d.ts +134 -13
  82. package/dist/cli/migrate.js +347 -238
  83. package/dist/cli/pii-predicate-guard.d.ts +112 -0
  84. package/dist/cli/pii-predicate-guard.js +386 -0
  85. package/dist/cli/prisma-resolve.js +75 -4
  86. package/dist/cli/prisma-schema.d.ts +17 -1
  87. package/dist/cli/prisma-schema.js +83 -17
  88. package/dist/cli/sql-statements.d.ts +125 -0
  89. package/dist/cli/sql-statements.js +373 -0
  90. package/dist/cli/studio.js +49 -118
  91. package/dist/cli/ui.d.ts +1 -1
  92. package/dist/client.d.ts +43 -0
  93. package/dist/client.js +126 -7
  94. package/dist/dialect.d.ts +123 -0
  95. package/dist/dialect.js +33 -0
  96. package/dist/errors.d.ts +74 -1
  97. package/dist/errors.js +228 -19
  98. package/dist/index-advisor.d.ts +33 -1
  99. package/dist/index-advisor.js +31 -1
  100. package/dist/introspect.d.ts +48 -0
  101. package/dist/introspect.js +221 -91
  102. package/dist/mssql.js +44 -2
  103. package/dist/mysql.d.ts +5 -2
  104. package/dist/mysql.js +203 -18
  105. package/dist/nested-write.js +7 -2
  106. package/dist/pipeline-submittable.js +18 -4
  107. package/dist/pipeline.js +76 -10
  108. package/dist/powdb.d.ts +23 -0
  109. package/dist/powdb.js +33 -2
  110. package/dist/powql.d.ts +61 -9
  111. package/dist/powql.js +187 -50
  112. package/dist/prisma-compat.js +160 -41
  113. package/dist/query/aggregates.d.ts +1 -1
  114. package/dist/query/aggregates.js +82 -20
  115. package/dist/query/batched-loader.d.ts +10 -0
  116. package/dist/query/batched-loader.js +270 -9
  117. package/dist/query/builder.d.ts +73 -0
  118. package/dist/query/builder.js +226 -30
  119. package/dist/query/filters.d.ts +162 -0
  120. package/dist/query/filters.js +246 -1
  121. package/dist/query/relations.d.ts +10 -10
  122. package/dist/query/relations.js +94 -14
  123. package/dist/query/types.d.ts +14 -1
  124. package/dist/query/utils.d.ts +146 -2
  125. package/dist/query/utils.js +204 -3
  126. package/dist/query/warn-registry.d.ts +10 -0
  127. package/dist/query/warn-registry.js +10 -0
  128. package/dist/query/where-compile.d.ts +30 -0
  129. package/dist/query/where-compile.js +40 -1
  130. package/dist/query/where.d.ts +128 -13
  131. package/dist/query/where.js +216 -80
  132. package/dist/query/writes.d.ts +1 -1
  133. package/dist/query/writes.js +40 -16
  134. package/dist/schema-builder.d.ts +2 -1
  135. package/dist/schema-sql.d.ts +94 -4
  136. package/dist/schema-sql.js +505 -30
  137. package/dist/schema.d.ts +3 -1
  138. package/dist/sqlite.d.ts +6 -0
  139. package/dist/sqlite.js +151 -10
  140. package/dist/typed-sql.d.ts +29 -1
  141. package/dist/typed-sql.js +30 -12
  142. package/package.json +6 -4
@@ -7,9 +7,10 @@
7
7
  * and execution rather than filter-shape bookkeeping.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSON_FILTER_KEYS = exports.JSON_STRING_OPERATORS = exports.JSON_RANGE_OPERATORS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = exports.COLUMN_REF_OPERATORS = void 0;
10
+ exports.MAX_NAMED_ORDER_KEYS = exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSON_FILTER_KEYS = exports.JSON_STRING_OPERATORS = exports.JSON_RANGE_OPERATORS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = exports.RELATION_FILTER_WRAPPER_SET = exports.RELATION_FILTER_WRAPPERS = exports.COLUMN_REF_OPERATORS = void 0;
11
11
  exports.isWhereOperator = isWhereOperator;
12
12
  exports.isUnmatchedPlainObject = isUnmatchedPlainObject;
13
+ exports.hasRelationFilterWrapper = hasRelationFilterWrapper;
13
14
  exports.isColumnRef = isColumnRef;
14
15
  exports.fingerprintOperatorShape = fingerprintOperatorShape;
15
16
  exports.assertBindableEqualsOperand = assertBindableEqualsOperand;
@@ -29,8 +30,12 @@ exports.isOrderBySpec = isOrderBySpec;
29
30
  exports.isJsonPathOrderBy = isJsonPathOrderBy;
30
31
  exports.isRelationPickOrderBy = isRelationPickOrderBy;
31
32
  exports.orderByEntries = orderByEntries;
33
+ exports.orderKeyIdentity = orderKeyIdentity;
34
+ exports.dedupeOrderEntries = dedupeOrderEntries;
35
+ exports.dedupeColumnList = dedupeColumnList;
32
36
  exports.normalizeOrderBy = normalizeOrderBy;
33
37
  const errors_js_1 = require("../errors.js");
38
+ const types_js_1 = require("./types.js");
34
39
  const utils_js_1 = require("./utils.js");
35
40
  // ---------------------------------------------------------------------------
36
41
  // Where-operator detection
@@ -67,6 +72,47 @@ function isUnmatchedPlainObject(value) {
67
72
  * take values only.
68
73
  */
69
74
  exports.COLUMN_REF_OPERATORS = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte']);
75
+ /**
76
+ * THE relation-filter wrappers: the keys whose body is a clause against the
77
+ * relation's TARGET table rather than against this one.
78
+ *
79
+ * One named list, here, because everything that walks a where clause has to
80
+ * agree about them and the copies had already spread. It lived inlined in
81
+ * `where-compile.ts` (`'some' in x || 'every' in x || …`, the SQL compiler's
82
+ * own answer), again in `normalizeRelationFilter` (where.ts) as the negated
83
+ * conjunction of the same five, again in `cli/pii-predicate-guard.ts`, and a
84
+ * fourth time in `prisma-compat.ts` SPLIT across a
85
+ * `RELATION_QUANTIFIERS` set (`some`/`every`/`none`) plus two inline
86
+ * `k === 'is' || k === 'isNot'` tests, which is the copy most likely to drift
87
+ * because half of it does not read as a list and a grep for the list does not
88
+ * find it.
89
+ *
90
+ * The failure mode is not cosmetic: a wrapper the SQL compiler treats as a
91
+ * relation filter but a WALKER does not is a wrapper whose body reaches the
92
+ * builder unwalked. That is precisely the operand-position channel this
93
+ * release closed elsewhere.
94
+ *
95
+ * `query/` is the right home rather than `cli/` because the direction of the
96
+ * dependency is fixed: `cli/` and the prisma-compat shim may import from the
97
+ * query path, and the query path may never import from `cli/`
98
+ * (`scripts/check-import-cycles.mjs`).
99
+ */
100
+ exports.RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
101
+ /** {@link RELATION_FILTER_WRAPPERS} as a membership set, for the walkers. */
102
+ exports.RELATION_FILTER_WRAPPER_SET = new Set(exports.RELATION_FILTER_WRAPPERS);
103
+ /**
104
+ * True when a normalized relation-filter body carries at least one cardinality
105
+ * wrapper. THE predicate the SQL compiler branches on: a key that names a
106
+ * relation but whose value is not one of these falls through to the scalar
107
+ * path.
108
+ */
109
+ function hasRelationFilterWrapper(filterObj) {
110
+ for (const wrapper of exports.RELATION_FILTER_WRAPPERS) {
111
+ if (wrapper in filterObj)
112
+ return true;
113
+ }
114
+ return false;
115
+ }
70
116
  /**
71
117
  * Check if an operator value is a column reference: a plain object whose ONLY
72
118
  * key is `col` with a string value. Anything else (extra keys, non-string
@@ -442,6 +488,209 @@ function orderByEntries(orderBy) {
442
488
  }
443
489
  return [];
444
490
  }
491
+ // ---------------------------------------------------------------------------
492
+ // ORDER BY key identity and arity
493
+ // ---------------------------------------------------------------------------
494
+ /**
495
+ * The longest `orderBy` whose statement keeps a NAMED prepared statement.
496
+ * Past this, the compile paths call `markVariableArity` and the statement is
497
+ * sent unnamed for the same reason a caller-written `OR` array is.
498
+ *
499
+ * WHY 3, and why a threshold at all. Refusing duplicate sort keys
500
+ * ({@link dedupeOrderEntries}) bounds an `orderBy` to the table's column
501
+ * count, which sounds like enough and is not: what is left is the PERMUTATION
502
+ * space. A 20-column table admits 20 one-key sorts, 380 two-key sorts, 6,840
503
+ * three-key sorts and 116,280 four-key sorts, and every one of those is a
504
+ * distinct SQL text and therefore a distinct un-reclaimable server-side
505
+ * prepared statement. The count is dominated by its longest term, so a cap on
506
+ * LENGTH is what actually bounds the total.
507
+ *
508
+ * 3 because that is where real sorts stop and generated ones start. The two
509
+ * shapes that show up in application code are a single sort key, and a sort key
510
+ * plus a tiebreaker for stable pagination (`[{createdAt:'desc'},{id:'asc'}]`).
511
+ * Three covers the widest genuinely hand-written form, a category or priority
512
+ * followed by recency followed by a primary-key tiebreak. A fourth key is
513
+ * effectively always a UI that lets the caller stack sort columns, which is
514
+ * exactly the shape that should not be minting named statements. The asymmetry
515
+ * of the two errors also points this way: naming a 4-key sort risks the 116,280
516
+ * above, while NOT naming one costs a single extra server-side parse per
517
+ * execution on a query that is already rare.
518
+ */
519
+ exports.MAX_NAMED_ORDER_KEYS = 3;
520
+ /**
521
+ * Canonical identity of ONE order term's sort EXPRESSION, or `null` when this
522
+ * rule declines to decide for that shape.
523
+ *
524
+ * DIRECTION IS DELIBERATELY EXCLUDED. `ORDER BY id ASC, id DESC` sorts exactly
525
+ * like `ORDER BY id ASC`: the first term already totally orders the rows it
526
+ * covers, so no later term on the same expression can move anything, whichever
527
+ * way it points. Including the direction would have missed the very case that
528
+ * motivated this, a pair of entries spelled differently AND pointing
529
+ * differently.
530
+ *
531
+ * The column is RESOLVED rather than compared as spelled, via the same
532
+ * `resolveColumnName` every SQL builder resolves keys through, so `userId` and
533
+ * `user_id` on an introspected schema are recognised as the one column they
534
+ * both compile to. Falls back to the raw key when the key resolves to nothing
535
+ * (metadata-less test schemas): the compile path a few lines later is what
536
+ * reports an unknown field, and this function must not pre-empt that error with
537
+ * a worse one.
538
+ *
539
+ * `null` (not compared) for two shapes:
540
+ * - VECTOR KNN ordering. Two distance terms on one column with DIFFERENT
541
+ * target vectors are genuinely different expressions and the second is not a
542
+ * no-op, so deciding would mean hashing the operand, which is a 1,536-float
543
+ * array on a typical embedding column. Not worth the per-build cost for a
544
+ * shape that {@link MAX_NAMED_ORDER_KEYS} already bounds.
545
+ * - RELATION ordering (`{posts:{_count:'desc'}}`, `{author:{name:'asc'}}`,
546
+ * pick-row). Same reason in a different key: the expression is a correlated
547
+ * subquery whose identity is its whole nested shape.
548
+ * Both are bounded by the length cap; neither can be refused wrongly here.
549
+ */
550
+ function orderKeyIdentity(meta, key, value) {
551
+ if (isVectorOrderBy(value))
552
+ return null;
553
+ if (isJsonPathOrderBy(value)) {
554
+ // Same column AND same path AND same cast is one expression; a different
555
+ // path or a numeric-vs-text cast is a different one, and both are ordinary
556
+ // in a multi-key sort over a document column.
557
+ const column = resolvedOrderColumn(meta, key);
558
+ return `json:${column}:${value.type ?? ''}:${JSON.stringify(value.path)}`;
559
+ }
560
+ if (typeof value === 'object' && value !== null && !Array.isArray(value) && !isOrderBySpec(value)) {
561
+ return null;
562
+ }
563
+ return `col:${resolvedOrderColumn(meta, key)}`;
564
+ }
565
+ /** {@link resolveColumnName} with the raw key as the unresolvable fallback. */
566
+ function resolvedOrderColumn(meta, key) {
567
+ return (meta ? (0, utils_js_1.resolveColumnName)(meta, key) : undefined) ?? key;
568
+ }
569
+ /**
570
+ * Remove `orderBy` entries that sort by an expression an earlier entry already
571
+ * sorted by. Returns `null` when nothing is redundant, which is the common case
572
+ * and the one that must stay allocation-light and byte-identical.
573
+ *
574
+ * WHY THIS DROPS RATHER THAN REFUSES, because the reverse was specified and the
575
+ * premise turned out not to hold. A repeated sort key is a no-op in SQL, and it
576
+ * is the only way to push an `orderBy` past the table's column count (measured:
577
+ * `[{id:'asc'}] x n` produced a distinct named prepared statement for every n,
578
+ * from ONE column, with no ceiling short of the request body size). Both of
579
+ * those are true. What is NOT true is that it is always a caller bug. The
580
+ * idiomatic stable-pagination shape is
581
+ *
582
+ * orderBy: [{ [sortField]: sortDir }, { id: 'asc' }]
583
+ *
584
+ * appending a primary-key tiebreak unconditionally, which is correct defensive
585
+ * code, and which produces a duplicate exactly when the caller sorts by the
586
+ * primary key. That is a column header a user clicks, so refusing would turn a
587
+ * working table into a 500 on one column and no others. This repo's own
588
+ * differential fuzz generator writes that pattern, comment included ("a random
589
+ * key first, the PK as final tiebreaker"), and picks the sort field from a pool
590
+ * that contains `id`, so a refusal would have failed the suite on 10-35% of
591
+ * generated cases depending on table width.
592
+ *
593
+ * Dropping has no such failure mode: the removed term provably cannot move a
594
+ * row, so the result set and its order are unchanged, and the caller is told
595
+ * about the redundancy through a dev-only warn-once instead of an exception.
596
+ * The security outcome is identical, because what actually bounds the emitted
597
+ * statement set is the length cap ({@link MAX_NAMED_ORDER_KEYS}), which is
598
+ * shape-blind and applies to repeats and permutations alike. This function's
599
+ * job is narrower: keep the ordinary two-key sort under that cap so it keeps
600
+ * its named statement.
601
+ *
602
+ * A DROPPED TERM IS STILL VALIDATED, see {@link assertDroppedDirection}. The
603
+ * drop is an optimization, and an optimization must not decide whether an input
604
+ * is legal.
605
+ *
606
+ * @param table the table the sort is against, so a refused direction reads
607
+ * exactly as it does when the compile path raises it. Defaults to the
608
+ * metadata's own name, which is what a caller holding only a `TableMetadata`
609
+ * would have passed anyway.
610
+ */
611
+ function dedupeOrderEntries(meta, entries, table = meta?.name) {
612
+ if (entries.length < 2)
613
+ return null;
614
+ const seen = new Map();
615
+ let dropped;
616
+ const kept = [];
617
+ for (const entry of entries) {
618
+ const identity = orderKeyIdentity(meta, entry[0], entry[1]);
619
+ const first = identity === null ? undefined : seen.get(identity);
620
+ if (first !== undefined) {
621
+ assertDroppedDirection(entry[0], entry[1], table);
622
+ // Resolved only on the dropping branch, so the advisory can name the one
623
+ // column two different spellings landed on without costing the hot path.
624
+ if (dropped === undefined)
625
+ dropped = [];
626
+ dropped.push({ key: entry[0], first, resolved: resolvedOrderColumn(meta, entry[0]) });
627
+ continue;
628
+ }
629
+ if (identity !== null)
630
+ seen.set(identity, entry[0]);
631
+ kept.push(entry);
632
+ }
633
+ return dropped ? { entries: kept, dropped } : null;
634
+ }
635
+ /**
636
+ * Validate the direction of a term this function is about to DROP.
637
+ *
638
+ * Without this the drop swallowed the check, because the direction guard lives
639
+ * on the COMPILE path (`buildOrderBy`) and a dropped term never reaches it:
640
+ * `orderBy: [{id:'asc'}, {id:'sideways'}]` was silently accepted while
641
+ * `orderBy: [{id:'sideways'}]` alone raised E003, and `groupBy` (which
642
+ * validates BEFORE its own dedupe) still refused the identical input, so the
643
+ * two surfaces disagreed about whether a query was valid. Whether a bad
644
+ * direction is reported must not depend on whether some earlier term happened
645
+ * to name the same column.
646
+ *
647
+ * Only two shapes can reach here, which is what makes the branch exhaustive:
648
+ * {@link orderKeyIdentity} returns `null` (never deduped, never dropped) for
649
+ * the vector and relation shapes, leaving the JSON-path form and the plain
650
+ * column / OrderBySpec form. Each is checked with exactly the guard
651
+ * `buildOrderBy` (relations.ts) would have applied to it, message included, so
652
+ * a dropped term is accepted or refused identically to a kept one.
653
+ */
654
+ function assertDroppedDirection(key, value, table) {
655
+ if (isJsonPathOrderBy(value)) {
656
+ (0, types_js_1.assertDirectionToken)(value.direction, `JSON-path orderBy on "${key}"`);
657
+ return;
658
+ }
659
+ (0, types_js_1.assertOrderDirection)(value, table === undefined ? `orderBy "${key}"` : `orderBy "${key}" on table "${table}"`);
660
+ }
661
+ /**
662
+ * The {@link dedupeOrderEntries} rule applied to a `DISTINCT ON` column list,
663
+ * the other caller-supplied list written into the SQL one term per element.
664
+ * `DISTINCT ON (a, a)` groups exactly as `DISTINCT ON (a)` does, so a repeat is
665
+ * a no-op here too, and the same generated-code shape produces it (a grouping
666
+ * column chosen by the caller, plus a fixed one appended by the code).
667
+ *
668
+ * No length cap to go with it, deliberately. Unlike `orderBy`, `DISTINCT ON`
669
+ * has a meaning that degrades as the list grows (every added column makes the
670
+ * result strictly less distinct, converging on the plain query), so a long list
671
+ * is self-limiting in a way a long sort is not.
672
+ */
673
+ function dedupeColumnList(meta, columns) {
674
+ if (columns.length < 2)
675
+ return null;
676
+ const seen = new Map();
677
+ let dropped;
678
+ const kept = [];
679
+ for (const key of columns) {
680
+ const column = typeof key === 'string' ? resolvedOrderColumn(meta, key) : null;
681
+ const first = column === null ? undefined : seen.get(column);
682
+ if (first !== undefined && column !== null) {
683
+ if (dropped === undefined)
684
+ dropped = [];
685
+ dropped.push({ key, first, resolved: column });
686
+ continue;
687
+ }
688
+ if (column !== null)
689
+ seen.set(column, key);
690
+ kept.push(key);
691
+ }
692
+ return dropped ? { columns: kept, dropped } : null;
693
+ }
445
694
  /**
446
695
  * Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
447
696
  * direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
@@ -269,19 +269,19 @@ export declare function collectManyToManyTargetGlobalFilter(qi: BuilderCtx, relD
269
269
  * when a filter applies, no-op otherwise.
270
270
  */
271
271
  export declare function collectRelationCountParams(qi: BuilderCtx, relDef: RelationDef, params: unknown[]): void;
272
- export declare function getCamelDateFields(qi: BuilderCtx, table: string, meta: TableMetadata): Set<string>;
273
272
  /**
274
- * Parse a row that may contain JSON nested relation columns.
273
+ * Per-table memo of `[relationName, relDef]` pairs, alongside the existing
274
+ * camelCase-date memo and for the same reason.
275
275
  *
276
- * `fromJson` says where THIS row's own scalar columns came from. A root row
277
- * (join, batched, flatten) is read straight off the driver, so `false`; a row
278
- * decoded out of a `json_agg`/`json_build_object` column is `true` and gets
279
- * its divergent scalars decoded back to the driver's representation first (see
280
- * the JSON-wire section above). Recursion into a relation column is always
281
- * `true`, which is exactly right on every strategy: `batched` and `flatten`
282
- * hand this function driver rows, but any relation still nested INSIDE one of
283
- * those rows arrived as a correlated JSON subquery.
276
+ * {@link parseNestedRow} walks every relation on the table for EVERY row, and
277
+ * `Object.entries(meta.relations)` allocates a fresh array of fresh two-element
278
+ * arrays each time it does. Measured at 227 ns per row on a 12-relation table
279
+ * against 2 ns for a hoisted list, in the single largest ORM function in the
280
+ * live CPU profile. The relation map is immutable metadata, so one array per
281
+ * table is all that is ever needed.
284
282
  */
283
+ export declare function getRelationEntries(qi: BuilderCtx, table: string, meta: TableMetadata): [string, RelationDef][];
284
+ export declare function getCamelDateFields(qi: BuilderCtx, table: string, meta: TableMetadata): Set<string>;
285
285
  export declare function parseNestedRow(qi: BuilderCtx, row: Record<string, unknown>, table: string, fromJson?: boolean): Record<string, unknown>;
286
286
  /**
287
287
  * Resolve the emitted column list for a relation, honoring `select` / `omit`.
@@ -71,6 +71,7 @@ exports.buildRelationCountExpr = buildRelationCountExpr;
71
71
  exports.manyToManyTargetGlobalFilterExists = manyToManyTargetGlobalFilterExists;
72
72
  exports.collectManyToManyTargetGlobalFilter = collectManyToManyTargetGlobalFilter;
73
73
  exports.collectRelationCountParams = collectRelationCountParams;
74
+ exports.getRelationEntries = getRelationEntries;
74
75
  exports.getCamelDateFields = getCamelDateFields;
75
76
  exports.parseNestedRow = parseNestedRow;
76
77
  exports.resolveTargetColumns = resolveTargetColumns;
@@ -192,9 +193,36 @@ function resolveProjection(qi, table, meta, select, omit, includePii) {
192
193
  }
193
194
  // Only include columns where value is true. An explicit `select` naming a
194
195
  // PII column IS the opt-in: it comes back regardless of `includePii`.
195
- return Object.entries(select)
196
+ //
197
+ // Emitted in the TABLE's column order, not the caller's key order, which is
198
+ // the same order the `omit` and default branches below produce. Two reasons,
199
+ // and the first is a security bound:
200
+ //
201
+ // 1. The SELECT list is SQL TEXT, so a caller who reorders their `select`
202
+ // keys mints a distinct, permanently-cached, server-side prepared
203
+ // statement per permutation (measured: 5,040 statements and 39 MB of
204
+ // CachedPlanSource from ONE seven-column table). That is the same
205
+ // unbounded-statement failure the `markVariableArity` rule bounds for
206
+ // caller-chosen ARITY, reached through caller-chosen ORDER instead, which
207
+ // needs no array and no unusual input. `omit` never had it precisely
208
+ // because it derives from `allColumns`. See `canonicalColumnOrder`.
209
+ // 2. It removes a real lockstep hazard. `withFingerprint` already SORTS a
210
+ // relation's `select` keys, so the cache key was permutation-invariant
211
+ // while the SQL was not: the second permutation was served the first
212
+ // one's statement. Harmless for the keyed JSON encoding, NOT harmless for
213
+ // `jsonEncoding: 'positional'`, where the emitted array order comes from
214
+ // the cached SQL and the decoder (`buildRelationShape`) is rebuilt from
215
+ // the current args, so values landed under the wrong keys.
216
+ //
217
+ // Order is safe to change here because nothing downstream reads the
218
+ // projection positionally except through THIS list: the SELECT list, the
219
+ // relation `json_build_object` pairs and the positional `RelationShape`
220
+ // keys are all derived from it, so they move together. The one observable
221
+ // consequence is that a projected row's KEY INSERTION order now follows the
222
+ // table rather than the `select` literal.
223
+ return (0, utils_js_1.canonicalColumnOrder)(meta, Object.entries(select)
196
224
  .filter(([, v]) => v)
197
- .map(([k]) => projectionColumn(table, meta, k, 'select'));
225
+ .map(([k]) => projectionColumn(table, meta, k, 'select')));
198
226
  }
199
227
  // Default / omit-only projection: PII-tagged columns are excluded unless the
200
228
  // caller opted in with `includePii: UNSAFE`. An empty set (untagged schema) keeps the
@@ -499,8 +527,17 @@ function buildOrderBy(qi, orderBy, params, lateralSink) {
499
527
  // rules in step, so it is gone. See orderby-unknown-field.test.ts, which pins
500
528
  // the refusal itself across that surface.
501
529
  const meta = qi.schema.tables[qi.table];
530
+ const entries = (0, filters_js_1.orderByEntries)(orderBy);
531
+ // A long ORDER BY writes one comma-separated term per entry into the SQL
532
+ // TEXT, so its length is an arity the caller chose and the statement gives up
533
+ // its server-side name. Marked HERE, on the compile path, because that is the
534
+ // window `buildCacheEntry` brackets; redundant terms have already been
535
+ // dropped upstream (buildFindMany), so this counts real ones. See
536
+ // MAX_NAMED_ORDER_KEYS in filters.ts for why the threshold is where it is.
537
+ if (entries.length > filters_js_1.MAX_NAMED_ORDER_KEYS)
538
+ qi.markVariableArity();
502
539
  let relOrdCounter = 0;
503
- return (0, filters_js_1.orderByEntries)(orderBy)
540
+ return entries
504
541
  .map(([key, value]) => {
505
542
  // Vector KNN ordering: { distance: { to, metric, direction? } }
506
543
  if ((0, filters_js_1.isVectorOrderBy)(value)) {
@@ -1044,6 +1081,16 @@ function collectRelationPickOrderParams(qi, relName, relDef, spec, params) {
1044
1081
  * in the same order, by {@link collectRelationOrderParams}.
1045
1082
  */
1046
1083
  function buildRelationOrderClause(qi, targetTable, targetMeta, alias, orderEntries, params) {
1084
+ // Same length cap as the top-level `buildOrderBy`. A relation's orderBy comes
1085
+ // from the same request body and its terms are written into the same
1086
+ // statement, so it carries the same exposure. This is the shared choke point
1087
+ // for both to-many shapes (the wrapped hasMany subquery and the m2m junction
1088
+ // subquery). Redundant-term dropping is deliberately NOT mirrored here: it
1089
+ // would have to happen before the `with` fingerprint to stay in lockstep with
1090
+ // the collect path, and a no-op term inside a relation costs only itself
1091
+ // while the cap already bounds what matters.
1092
+ if (orderEntries.length > filters_js_1.MAX_NAMED_ORDER_KEYS)
1093
+ qi.markVariableArity();
1047
1094
  let relOrdCounter = 0;
1048
1095
  const orders = orderEntries
1049
1096
  .map(([key, dirValue]) => {
@@ -1191,6 +1238,25 @@ function collectRelationCountParams(qi, relDef, params) {
1191
1238
  whereMod.collectTargetGlobalFilterAlias(qi, relDef.to, params);
1192
1239
  }
1193
1240
  }
1241
+ /**
1242
+ * Per-table memo of `[relationName, relDef]` pairs, alongside the existing
1243
+ * camelCase-date memo and for the same reason.
1244
+ *
1245
+ * {@link parseNestedRow} walks every relation on the table for EVERY row, and
1246
+ * `Object.entries(meta.relations)` allocates a fresh array of fresh two-element
1247
+ * arrays each time it does. Measured at 227 ns per row on a 12-relation table
1248
+ * against 2 ns for a hoisted list, in the single largest ORM function in the
1249
+ * live CPU profile. The relation map is immutable metadata, so one array per
1250
+ * table is all that is ever needed.
1251
+ */
1252
+ function getRelationEntries(qi, table, meta) {
1253
+ let entries = qi.relationEntryCache.get(table);
1254
+ if (!entries) {
1255
+ entries = Object.entries(meta.relations);
1256
+ qi.relationEntryCache.set(table, entries);
1257
+ }
1258
+ return entries;
1259
+ }
1194
1260
  function getCamelDateFields(qi, table, meta) {
1195
1261
  let camel = qi.camelDateFieldCache.get(table);
1196
1262
  if (!camel) {
@@ -1214,6 +1280,9 @@ function getCamelDateFields(qi, table, meta) {
1214
1280
  * hand this function driver rows, but any relation still nested INSIDE one of
1215
1281
  * those rows arrived as a correlated JSON subquery.
1216
1282
  */
1283
+ /** The reserved relation-`_count` column prefix, and its first char code. */
1284
+ const COUNT_PREFIX = '_count__';
1285
+ const UNDERSCORE_CHAR = 95;
1217
1286
  function parseNestedRow(qi, row, table, fromJson = false) {
1218
1287
  const meta = qi.schema.tables[table];
1219
1288
  if (!meta)
@@ -1221,18 +1290,30 @@ function parseNestedRow(qi, row, table, fromJson = false) {
1221
1290
  const parsed = qi.parseRow(fromJson ? decodeJsonWireRow(qi, row, table, meta) : row, table);
1222
1291
  // Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
1223
1292
  // parseRow copies these unknown columns through under their raw key.
1293
+ //
1294
+ // `for…in` with a first-character reject, rather than `Object.keys(...)`
1295
+ // plus `startsWith`: the old form allocated a fresh key array for EVERY row
1296
+ // of every query purely to discover that nothing started with `_count__`,
1297
+ // which is the case for every query that did not ask for a relation `_count`
1298
+ // (almost all of them). `for…in` allocates nothing, and `_` is a rare first
1299
+ // character on a real field, so the common row costs one charCode compare
1300
+ // per key. Deletion is deferred out of the loop so the scan stays on V8's
1301
+ // enum-cache fast path; the resulting key order is unchanged (the same keys
1302
+ // are removed, and `_count` is appended last either way).
1224
1303
  let countObj;
1225
- for (const key of Object.keys(parsed)) {
1226
- if (key.startsWith('_count__')) {
1227
- if (countObj === undefined)
1228
- countObj = {};
1229
- countObj[key.slice('_count__'.length)] = Number(parsed[key]);
1230
- delete parsed[key];
1231
- }
1304
+ for (const key in parsed) {
1305
+ if (key.charCodeAt(0) !== UNDERSCORE_CHAR || !key.startsWith(COUNT_PREFIX))
1306
+ continue;
1307
+ if (countObj === undefined)
1308
+ countObj = {};
1309
+ countObj[key.slice(COUNT_PREFIX.length)] = Number(parsed[key]);
1232
1310
  }
1233
- if (countObj)
1311
+ if (countObj) {
1312
+ for (const rel of Object.keys(countObj))
1313
+ delete parsed[`${COUNT_PREFIX}${rel}`];
1234
1314
  parsed._count = countObj;
1235
- for (const [relName, relDef] of Object.entries(meta.relations)) {
1315
+ }
1316
+ for (const [relName, relDef] of getRelationEntries(qi, table, meta)) {
1236
1317
  const rawValue = row[relName];
1237
1318
  if (rawValue === undefined)
1238
1319
  continue;
@@ -405,7 +405,20 @@ export type TypedOrderByClause<T, R extends object> = TypedOrderByObject<T, R> |
405
405
  export type WithFieldFlags<NestedT> = [unknown] extends [NestedT] ? Record<string, boolean> : {
406
406
  [K in keyof NestedT]?: boolean;
407
407
  };
408
- export interface WithOptions<NestedR extends object = {}, NestedT = unknown> {
408
+ /**
409
+ * `Record<never, never>` rather than `{}` for the "no nested relations known"
410
+ * default, and the difference is load-bearing rather than cosmetic: the degrade
411
+ * branches in {@link TypedWithClause} and {@link TypedOrderByObject} test
412
+ * `[keyof R] extends [never]`, so the default has to be a type whose `keyof` IS
413
+ * `never`. `object` (biome's first suggestion) has `keyof object = never` too
414
+ * but admits any object, and `Record<string, never>` has `keyof = string`,
415
+ * which would silently flip every untyped `with` clause off the open escape
416
+ * hatch and onto key checking against no keys at all, i.e. reject everything. A
417
+ * mapped type over `never` resolves to exactly the empty object type, so this
418
+ * is the same type `{}` was, spelled in a way that says which property of it
419
+ * matters.
420
+ */
421
+ export interface WithOptions<NestedR extends object = Record<never, never>, NestedT = unknown> {
409
422
  with?: TypedWithClause<NestedR>;
410
423
  /** Filter the related rows. Keys are checked against the relation target when it is known (see {@link WithWhere}). */
411
424
  where?: WithWhere<NestedT, NestedR>;