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
@@ -28,6 +28,12 @@ export interface ColumnNameSource {
28
28
  columnMap: Record<string, string>;
29
29
  reverseColumnMap?: Record<string, string>;
30
30
  allColumns?: string[];
31
+ /**
32
+ * The table's own name, present whenever the source is a real
33
+ * `TableMetadata`. Used for ERROR TEXT only, never for key resolution, so a
34
+ * hand-built source that omits it still resolves identically.
35
+ */
36
+ name?: string;
31
37
  }
32
38
  /**
33
39
  * Resolve a user-supplied key to its unquoted column name, or `undefined` when
@@ -53,6 +59,105 @@ export interface ColumnNameSource {
53
59
  * name (see {@link ownLookup}).
54
60
  */
55
61
  export declare function resolveColumnName(meta: ColumnNameSource, key: string): string | undefined;
62
+ /**
63
+ * THE canonical order for a caller-supplied set of columns: the table's own
64
+ * `allColumns` order, which is the order `omit` and the default projection
65
+ * already produce.
66
+ *
67
+ * ## The failure mode this exists to close, and it is NOT arity
68
+ *
69
+ * Every distinct SQL text Turbine emits is parsed on the server as a NAMED
70
+ * prepared statement and is never DEALLOCATEd. The arity rule
71
+ * (`markVariableArity`) bounds the shapes whose LENGTH the caller picks. It
72
+ * does nothing about the shapes whose length is fixed and whose ORDER the
73
+ * caller picks, and those grow the SQL text just as freely: `select: { a, b }`
74
+ * and `select: { b, a }` are the same query and two different statements.
75
+ *
76
+ * Measured against PostgreSQL 16 on a SEVEN-column table, one connection, with
77
+ * the arity fix already landed:
78
+ *
79
+ * baseline prepared= 0 CachedPlanSource= 0.0 MB
80
+ * after 5040 `select` permutations prepared= 5040 CachedPlanSource=39.4 MB
81
+ * after 300 varying-arity ORs prepared= 5040 CachedPlanSource=39.4 MB
82
+ * after 5040 `distinct` permutations prepared=10080 CachedPlanSource=59.1 MB
83
+ * 720 reordered PATCH bodies (update) prepared= 720 CachedPlanSource= 2.8 MB
84
+ *
85
+ * The write row is the realistic one: `JSON.parse` preserves insertion order,
86
+ * so `update({ where, data: JSON.parse(reqBody) })` hands a request body the
87
+ * SET-clause column order for free. No arrays, no unusual input, no opt-in.
88
+ *
89
+ * The reachable space is ORDERED subsets, sum over k of k! * C(n,k): 9.9e6 for
90
+ * 10 columns, 6.6e18 for 20. Canonicalizing collapses each permutation class to
91
+ * one statement, leaving the UNORDERED subsets (2^n) that `omit` has always
92
+ * had.
93
+ *
94
+ * ## Why ordering is the right remedy here rather than withholding the name
95
+ *
96
+ * A projection's SELECT-list order is not semantically meaningful to Turbine:
97
+ * rows are assembled by NAME (`parseRow`, `jsonScalarPairs` and
98
+ * `buildRelationShape` all key off the same resolved list), so reordering it
99
+ * changes no value. Where an order IS meaningful (`orderBy`, and `DISTINCT ON`,
100
+ * whose list is re-emitted as an ORDER BY prefix), the statement is sent
101
+ * unnamed instead. See `MAX_NAMED_ORDER_KEYS` in filters.ts and the `distinct`
102
+ * mark in builder.ts.
103
+ *
104
+ * Duplicates collapse, which is the other half of "canonical": two spellings of
105
+ * one column (`userId` and `user_id`, both legal on an introspected schema)
106
+ * used to emit that column twice in the SELECT list.
107
+ *
108
+ * Cost is one Set of the projected columns plus one pass over `allColumns`, on
109
+ * a path that already walks the caller's keys.
110
+ *
111
+ * @param meta the table the columns belong to; without `allColumns` there is no
112
+ * canonical order to appeal to and the input is returned untouched.
113
+ * @param columns already-resolved snake_case column names.
114
+ */
115
+ export declare function canonicalColumnOrder(meta: ColumnNameSource, columns: string[]): string[];
116
+ /**
117
+ * {@link canonicalColumnOrder} for a write's `data` entries: same order, same
118
+ * reason, two deliberate differences.
119
+ *
120
+ * DUPLICATES ARE KEPT. Two keys resolving to one column (`{ userId, user_id }`)
121
+ * is a caller error that PostgreSQL reports precisely ("multiple assignments to
122
+ * same column", 42701, and the INSERT equivalent). Collapsing them here would
123
+ * turn that error into a silent write of whichever value survived, so the entry
124
+ * list is REORDERED and never shortened. A stable sort keeps such a pair
125
+ * adjacent and in the caller's relative order, so the engine still sees, and
126
+ * still rejects, both assignments.
127
+ *
128
+ * THE KEY SPELLING IS PRESERVED. Everything downstream (`coerceWriteValue`,
129
+ * `buildSetClause`, `toSqlColumn`) re-resolves the caller's key itself, so only
130
+ * the ORDER of the entries changes here, never their content.
131
+ *
132
+ * A key that resolves to no column sorts LAST, in the caller's relative order.
133
+ * It is about to raise E003 from the SQL builder either way, and the builder is
134
+ * where that error belongs; ordering must not pre-empt it with a worse one.
135
+ */
136
+ export declare function canonicalWriteEntries(meta: ColumnNameSource, entries: [string, unknown][]): [string, unknown][];
137
+ /**
138
+ * Brands a WHERE object whose `AND` / `OR` array Turbine ITSELF synthesized,
139
+ * rather than one the caller wrote.
140
+ *
141
+ * The distinction matters for exactly one rule: a caller-written combinator
142
+ * array is treated as a VARIABLE-ARITY shape and its statement is sent unnamed
143
+ * (see `markVariableArity` in where.ts, and `acquireSql` in builder.ts).
144
+ * Turbine's own wrappers, the `{ AND: [userWhere, globalFilter] }` the
145
+ * global-filter merge produces and the `{ AND: [where, correlation] }` the
146
+ * batched loader produces, have a FIXED arity of two, decided by Turbine, not
147
+ * reachable from a request body. Counting them would have taken every query on
148
+ * a table with a configured global filter off named prepared statements, i.e.
149
+ * penalised precisely the multi-tenant setups the rule exists to protect.
150
+ *
151
+ * A `Symbol.for` key for the same cross-copy-identity reason the warn registry
152
+ * uses one (an ESM and a CJS copy of this module in one process must agree),
153
+ * and a SYMBOL rather than a string key so `Object.keys` (which is what every
154
+ * where walker enumerates) never sees it and no emitted SQL can change.
155
+ */
156
+ export declare const INTERNAL_COMBINATOR: unique symbol;
157
+ /** Tag `where` as carrying a Turbine-synthesized combinator, and return it. */
158
+ export declare function markInternalCombinator<T extends object>(where: T): T;
159
+ /** Was this WHERE object's combinator synthesized by Turbine? */
160
+ export declare function isInternalCombinator(where: unknown): boolean;
56
161
  /**
57
162
  * Escape single quotes for use as string keys in json_build_object().
58
163
  * Doubles single quotes per SQL quoting rules.
@@ -65,8 +170,15 @@ export declare function escSingleQuote(s: string): string;
65
170
  export declare function escapeLike(value: string): string;
66
171
  /**
67
172
  * Simple LRU (Least Recently Used) cache with a fixed maximum size.
68
- * When the cache exceeds maxSize, the oldest (least recently used) entry is evicted.
69
- * Uses Map insertion order for O(1) eviction.
173
+ * When the cache exceeds maxSize, the oldest (least recently used) entry is
174
+ * evicted. Uses Map insertion order, so EVICTION is O(1).
175
+ *
176
+ * The access-order reorder in {@link get} is not: `Map.delete` + `Map.set`
177
+ * leaves a tombstone, and V8 rehashes the whole table once live plus deleted
178
+ * entries reach capacity, so the reorder amortizes to O(capacity) per hit.
179
+ * At the 1,000-entry default that measured 1,356 ns against 2.6 ns for a plain
180
+ * `Map.get`, on the hottest lookup in the SQL build. See {@link get} for why
181
+ * skipping it below capacity is not merely an optimization but exact.
70
182
  */
71
183
  export declare class LRUCache<K, V> {
72
184
  private maxSize;
@@ -79,6 +191,16 @@ export declare class LRUCache<K, V> {
79
191
  /** Cached SQL template paired with its prepared-statement name. */
80
192
  export interface SqlCacheEntry {
81
193
  sql: string;
194
+ /**
195
+ * The name this statement executes under, or `''` for "send it UNNAMED".
196
+ *
197
+ * The empty string is a real value here, not a missing one: a shape whose SQL
198
+ * text is a function of a caller-chosen ARITY (an `OR`/`AND` array) must never
199
+ * take a server-side prepared statement, because those are never DEALLOCATEd
200
+ * and the client's LRU bounds only the client. Storing the verdict on the
201
+ * ENTRY is what makes it survive the cache: a later HIT reuses this name, so
202
+ * a warmed template cannot regain one. See `buildCacheEntry` in builder.ts.
203
+ */
82
204
  name: string;
83
205
  }
84
206
  /**
@@ -467,6 +589,28 @@ export declare function unknownFieldMessage(table: string, field: string, meta:
467
589
  * it. Naming the fix costs one sentence and saves a search.
468
590
  */
469
591
  export declare function relationInProjectionMessage(table: string, field: string, clause: 'select' | 'omit'): string;
592
+ /**
593
+ * Dev-only advisory for a sort/grouping term Turbine dropped as redundant
594
+ * (`filters.ts` `dedupeOrderEntries` / `dedupeColumnList`).
595
+ *
596
+ * Deliberately a WARNING and not an error, and deliberately not silent either.
597
+ * The dropped term provably cannot change a result, so failing the query would
598
+ * be a false alarm on a shape correct code produces (a caller-chosen sort key
599
+ * plus an unconditional primary-key tiebreak, which collide exactly when the
600
+ * caller sorts by the primary key). But a caller who wrote the duplicate by
601
+ * hand, or who believes a second key is doing something, should hear about it
602
+ * once. Dev-only for the usual reason: it describes the QUERY the application
603
+ * sends, which does not vary with the environment, so a production process
604
+ * learns nothing from repeating it.
605
+ *
606
+ * Keyed per dropped key, so a sort assembled per request from the same UI says
607
+ * it once rather than once per request.
608
+ */
609
+ export declare function warnRedundantSortTerm(table: string, clause: string, dropped: readonly {
610
+ key: string;
611
+ first: string;
612
+ resolved: string;
613
+ }[]): void;
470
614
  /**
471
615
  * The two projection SHAPE refusals (0.65), shared by the SQL engines' single
472
616
  * resolver, the batched loader's raw-arg check, and PowDB, so every path that
@@ -67,6 +67,157 @@ export function resolveColumnName(meta, key) {
67
67
  return snake;
68
68
  return undefined;
69
69
  }
70
+ // ---------------------------------------------------------------------------
71
+ // Caller-controlled key ORDER, canonicalized
72
+ // ---------------------------------------------------------------------------
73
+ /**
74
+ * THE canonical order for a caller-supplied set of columns: the table's own
75
+ * `allColumns` order, which is the order `omit` and the default projection
76
+ * already produce.
77
+ *
78
+ * ## The failure mode this exists to close, and it is NOT arity
79
+ *
80
+ * Every distinct SQL text Turbine emits is parsed on the server as a NAMED
81
+ * prepared statement and is never DEALLOCATEd. The arity rule
82
+ * (`markVariableArity`) bounds the shapes whose LENGTH the caller picks. It
83
+ * does nothing about the shapes whose length is fixed and whose ORDER the
84
+ * caller picks, and those grow the SQL text just as freely: `select: { a, b }`
85
+ * and `select: { b, a }` are the same query and two different statements.
86
+ *
87
+ * Measured against PostgreSQL 16 on a SEVEN-column table, one connection, with
88
+ * the arity fix already landed:
89
+ *
90
+ * baseline prepared= 0 CachedPlanSource= 0.0 MB
91
+ * after 5040 `select` permutations prepared= 5040 CachedPlanSource=39.4 MB
92
+ * after 300 varying-arity ORs prepared= 5040 CachedPlanSource=39.4 MB
93
+ * after 5040 `distinct` permutations prepared=10080 CachedPlanSource=59.1 MB
94
+ * 720 reordered PATCH bodies (update) prepared= 720 CachedPlanSource= 2.8 MB
95
+ *
96
+ * The write row is the realistic one: `JSON.parse` preserves insertion order,
97
+ * so `update({ where, data: JSON.parse(reqBody) })` hands a request body the
98
+ * SET-clause column order for free. No arrays, no unusual input, no opt-in.
99
+ *
100
+ * The reachable space is ORDERED subsets, sum over k of k! * C(n,k): 9.9e6 for
101
+ * 10 columns, 6.6e18 for 20. Canonicalizing collapses each permutation class to
102
+ * one statement, leaving the UNORDERED subsets (2^n) that `omit` has always
103
+ * had.
104
+ *
105
+ * ## Why ordering is the right remedy here rather than withholding the name
106
+ *
107
+ * A projection's SELECT-list order is not semantically meaningful to Turbine:
108
+ * rows are assembled by NAME (`parseRow`, `jsonScalarPairs` and
109
+ * `buildRelationShape` all key off the same resolved list), so reordering it
110
+ * changes no value. Where an order IS meaningful (`orderBy`, and `DISTINCT ON`,
111
+ * whose list is re-emitted as an ORDER BY prefix), the statement is sent
112
+ * unnamed instead. See `MAX_NAMED_ORDER_KEYS` in filters.ts and the `distinct`
113
+ * mark in builder.ts.
114
+ *
115
+ * Duplicates collapse, which is the other half of "canonical": two spellings of
116
+ * one column (`userId` and `user_id`, both legal on an introspected schema)
117
+ * used to emit that column twice in the SELECT list.
118
+ *
119
+ * Cost is one Set of the projected columns plus one pass over `allColumns`, on
120
+ * a path that already walks the caller's keys.
121
+ *
122
+ * @param meta the table the columns belong to; without `allColumns` there is no
123
+ * canonical order to appeal to and the input is returned untouched.
124
+ * @param columns already-resolved snake_case column names.
125
+ */
126
+ export function canonicalColumnOrder(meta, columns) {
127
+ const all = meta.allColumns;
128
+ if (all === undefined || columns.length < 2)
129
+ return columns;
130
+ const wanted = new Set(columns);
131
+ const ordered = [];
132
+ for (const col of all) {
133
+ if (wanted.delete(col))
134
+ ordered.push(col);
135
+ }
136
+ // A resolved column absent from `allColumns` is only reachable from
137
+ // hand-built metadata whose maps disagree with each other. Keep it (in the
138
+ // caller's relative order) rather than silently dropping it from the
139
+ // projection: this function reorders, it never decides what is projected.
140
+ if (wanted.size > 0) {
141
+ for (const col of columns) {
142
+ if (wanted.delete(col))
143
+ ordered.push(col);
144
+ }
145
+ }
146
+ return ordered;
147
+ }
148
+ /**
149
+ * {@link canonicalColumnOrder} for a write's `data` entries: same order, same
150
+ * reason, two deliberate differences.
151
+ *
152
+ * DUPLICATES ARE KEPT. Two keys resolving to one column (`{ userId, user_id }`)
153
+ * is a caller error that PostgreSQL reports precisely ("multiple assignments to
154
+ * same column", 42701, and the INSERT equivalent). Collapsing them here would
155
+ * turn that error into a silent write of whichever value survived, so the entry
156
+ * list is REORDERED and never shortened. A stable sort keeps such a pair
157
+ * adjacent and in the caller's relative order, so the engine still sees, and
158
+ * still rejects, both assignments.
159
+ *
160
+ * THE KEY SPELLING IS PRESERVED. Everything downstream (`coerceWriteValue`,
161
+ * `buildSetClause`, `toSqlColumn`) re-resolves the caller's key itself, so only
162
+ * the ORDER of the entries changes here, never their content.
163
+ *
164
+ * A key that resolves to no column sorts LAST, in the caller's relative order.
165
+ * It is about to raise E003 from the SQL builder either way, and the builder is
166
+ * where that error belongs; ordering must not pre-empt it with a worse one.
167
+ */
168
+ export function canonicalWriteEntries(meta, entries) {
169
+ const all = meta.allColumns;
170
+ if (all === undefined || entries.length < 2)
171
+ return entries;
172
+ const index = new Map();
173
+ for (let i = 0; i < all.length; i++)
174
+ index.set(all[i], i);
175
+ const rank = (key) => {
176
+ const column = resolveColumnName(meta, key);
177
+ const at = column === undefined ? undefined : index.get(column);
178
+ return at ?? Number.MAX_SAFE_INTEGER;
179
+ };
180
+ // Ranks are computed once per entry rather than inside the comparator, which
181
+ // would re-resolve every key O(k log k) times.
182
+ const ranked = entries.map((entry, at) => ({ entry, rank: rank(entry[0]), at }));
183
+ // `at` breaks ties explicitly rather than relying on sort stability, so two
184
+ // spellings of one column, and every unresolvable key, keep the order the
185
+ // caller wrote them in.
186
+ ranked.sort((a, b) => a.rank - b.rank || a.at - b.at);
187
+ return ranked.map((r) => r.entry);
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // Internally-synthesized combinator brand
191
+ // ---------------------------------------------------------------------------
192
+ /**
193
+ * Brands a WHERE object whose `AND` / `OR` array Turbine ITSELF synthesized,
194
+ * rather than one the caller wrote.
195
+ *
196
+ * The distinction matters for exactly one rule: a caller-written combinator
197
+ * array is treated as a VARIABLE-ARITY shape and its statement is sent unnamed
198
+ * (see `markVariableArity` in where.ts, and `acquireSql` in builder.ts).
199
+ * Turbine's own wrappers, the `{ AND: [userWhere, globalFilter] }` the
200
+ * global-filter merge produces and the `{ AND: [where, correlation] }` the
201
+ * batched loader produces, have a FIXED arity of two, decided by Turbine, not
202
+ * reachable from a request body. Counting them would have taken every query on
203
+ * a table with a configured global filter off named prepared statements, i.e.
204
+ * penalised precisely the multi-tenant setups the rule exists to protect.
205
+ *
206
+ * A `Symbol.for` key for the same cross-copy-identity reason the warn registry
207
+ * uses one (an ESM and a CJS copy of this module in one process must agree),
208
+ * and a SYMBOL rather than a string key so `Object.keys` (which is what every
209
+ * where walker enumerates) never sees it and no emitted SQL can change.
210
+ */
211
+ export const INTERNAL_COMBINATOR = Symbol.for('turbine.internalCombinator');
212
+ /** Tag `where` as carrying a Turbine-synthesized combinator, and return it. */
213
+ export function markInternalCombinator(where) {
214
+ Object.defineProperty(where, INTERNAL_COMBINATOR, { value: true, enumerable: false, configurable: true });
215
+ return where;
216
+ }
217
+ /** Was this WHERE object's combinator synthesized by Turbine? */
218
+ export function isInternalCombinator(where) {
219
+ return (typeof where === 'object' && where !== null && where[INTERNAL_COMBINATOR] === true);
220
+ }
70
221
  /**
71
222
  * Escape single quotes for use as string keys in json_build_object().
72
223
  * Doubles single quotes per SQL quoting rules.
@@ -86,8 +237,15 @@ export function escapeLike(value) {
86
237
  // ---------------------------------------------------------------------------
87
238
  /**
88
239
  * Simple LRU (Least Recently Used) cache with a fixed maximum size.
89
- * When the cache exceeds maxSize, the oldest (least recently used) entry is evicted.
90
- * Uses Map insertion order for O(1) eviction.
240
+ * When the cache exceeds maxSize, the oldest (least recently used) entry is
241
+ * evicted. Uses Map insertion order, so EVICTION is O(1).
242
+ *
243
+ * The access-order reorder in {@link get} is not: `Map.delete` + `Map.set`
244
+ * leaves a tombstone, and V8 rehashes the whole table once live plus deleted
245
+ * entries reach capacity, so the reorder amortizes to O(capacity) per hit.
246
+ * At the 1,000-entry default that measured 1,356 ns against 2.6 ns for a plain
247
+ * `Map.get`, on the hottest lookup in the SQL build. See {@link get} for why
248
+ * skipping it below capacity is not merely an optimization but exact.
91
249
  */
92
250
  export class LRUCache {
93
251
  maxSize;
@@ -97,7 +255,19 @@ export class LRUCache {
97
255
  }
98
256
  get(key) {
99
257
  const value = this.cache.get(key);
100
- if (value !== undefined) {
258
+ // Access order is only ever CONSUMED by eviction (`set` drops the first
259
+ // key once the cache is full), so while it is still filling, maintaining
260
+ // that order cannot change WHICH entries are present: nothing is evicted,
261
+ // and the reorder is pure cost. So it starts the moment the cache is full,
262
+ // and from then on every hit reorders exactly as before.
263
+ //
264
+ // What is traded, stated plainly: reads that happened while the cache was
265
+ // filling are not reflected in the order, so the first evictions after it
266
+ // fills can drop an entry that was hot early rather than the true
267
+ // least-recently-used one. Reads after that point are ordered normally, so
268
+ // the effect does not accumulate, and the worst case is one extra SQL
269
+ // rebuild on the next miss. This cache decides speed, never results.
270
+ if (value !== undefined && this.cache.size >= this.maxSize) {
101
271
  // Move to end (most recently used)
102
272
  this.cache.delete(key);
103
273
  this.cache.set(key, value);
@@ -876,6 +1046,37 @@ export function relationInProjectionMessage(table, field, clause) {
876
1046
  : `${head} A relation is only present when you ask for it in \`with\`, so leave it out of \`with\` to leave it` +
877
1047
  ' out of the result.';
878
1048
  }
1049
+ /**
1050
+ * Dev-only advisory for a sort/grouping term Turbine dropped as redundant
1051
+ * (`filters.ts` `dedupeOrderEntries` / `dedupeColumnList`).
1052
+ *
1053
+ * Deliberately a WARNING and not an error, and deliberately not silent either.
1054
+ * The dropped term provably cannot change a result, so failing the query would
1055
+ * be a false alarm on a shape correct code produces (a caller-chosen sort key
1056
+ * plus an unconditional primary-key tiebreak, which collide exactly when the
1057
+ * caller sorts by the primary key). But a caller who wrote the duplicate by
1058
+ * hand, or who believes a second key is doing something, should hear about it
1059
+ * once. Dev-only for the usual reason: it describes the QUERY the application
1060
+ * sends, which does not vary with the environment, so a production process
1061
+ * learns nothing from repeating it.
1062
+ *
1063
+ * Keyed per dropped key, so a sort assembled per request from the same UI says
1064
+ * it once rather than once per request.
1065
+ */
1066
+ export function warnRedundantSortTerm(table, clause, dropped) {
1067
+ if (process.env.NODE_ENV === 'production')
1068
+ return;
1069
+ for (const d of dropped) {
1070
+ if (!shouldWarnOnce(WARN_NS.redundantSortTerm, `${table}|${clause}|${d.key}`))
1071
+ continue;
1072
+ const how = d.first === d.key
1073
+ ? `"${d.key}" is named twice`
1074
+ : `"${d.key}" and the earlier "${d.first}" both target ${d.resolved}`;
1075
+ console.warn(`[turbine] ${clause} on table "${table}": ${how}, so the later one was dropped. It could not have ` +
1076
+ 'changed the result (the earlier term already orders those rows), and dropping it keeps the query on ' +
1077
+ 'a shared prepared statement. Remove it to silence this.');
1078
+ }
1079
+ }
879
1080
  /**
880
1081
  * The two projection SHAPE refusals (0.65), shared by the SQL engines' single
881
1082
  * resolver, the batched loader's raw-arg check, and PowDB, so every path that
@@ -65,6 +65,16 @@ export declare const WARN_NS: {
65
65
  * config says it once.
66
66
  */
67
67
  readonly unknownConfigKey: "unknownConfigKey";
68
+ /**
69
+ * A caller-supplied `orderBy` (or `distinct` / `distinctOn.columns`) named one
70
+ * sort expression twice and Turbine dropped the redundant term
71
+ * (filters.ts `dedupeOrderEntries` / `dedupeColumnList`). Not an error: the
72
+ * dropped term provably cannot move a row, and the shape is produced by
73
+ * correct defensive code that appends a fixed tiebreak to a caller-chosen sort
74
+ * key. Keyed on `table|context|key`, so a per-request sort that always
75
+ * collides says it once rather than once per request.
76
+ */
77
+ readonly redundantSortTerm: "redundantSortTerm";
68
78
  /**
69
79
  * `relationLoadStrategy: 'flatten'` was asked for but a relation stayed on the
70
80
  * correlated-subquery path (relations.ts `planFlattenWith`, builder.ts
@@ -100,6 +100,16 @@ export const WARN_NS = {
100
100
  * config says it once.
101
101
  */
102
102
  unknownConfigKey: 'unknownConfigKey',
103
+ /**
104
+ * A caller-supplied `orderBy` (or `distinct` / `distinctOn.columns`) named one
105
+ * sort expression twice and Turbine dropped the redundant term
106
+ * (filters.ts `dedupeOrderEntries` / `dedupeColumnList`). Not an error: the
107
+ * dropped term provably cannot move a row, and the shape is produced by
108
+ * correct defensive code that appends a fixed tiebreak to a caller-chosen sort
109
+ * key. Keyed on `table|context|key`, so a per-request sort that always
110
+ * collides says it once rather than once per request.
111
+ */
112
+ redundantSortTerm: 'redundantSortTerm',
103
113
  /**
104
114
  * `relationLoadStrategy: 'flatten'` was asked for but a relation stayed on the
105
115
  * correlated-subquery path (relations.ts `planFlattenWith`, builder.ts
@@ -36,6 +36,36 @@
36
36
  import type { RelationDef, TableMetadata } from '../schema.js';
37
37
  /** A table-scoped WHERE object (or an `OR`/`AND`/`NOT` branch of one). */
38
38
  export type WhereRecord = Record<string, unknown>;
39
+ /**
40
+ * Maximum nesting of `OR` / `AND` / `NOT` combinators and relation-filter
41
+ * descents in one WHERE clause (and in one groupBy `HAVING`).
42
+ *
43
+ * Every walker over a where clause, the fingerprint, the SQL build and the
44
+ * cache-hit param collect, and every one of them for the table-SCOPED sub-where
45
+ * too, is directly recursive on these three keys. Nothing bounded that
46
+ * recursion, so a request body could choose the JavaScript stack depth:
47
+ * measured with wire-realistic JSON, a 4,000-deep `NOT` chain built fine and an
48
+ * 8,000-deep one (a 64 KB body, comfortably under `express.json()`'s 100 KB
49
+ * default) threw `RangeError: Maximum call stack size exceeded`. A `RangeError`
50
+ * is not a {@link TurbineError}, so it walks straight past the typed-error
51
+ * surface callers catch on and lands as an unhandled rejection.
52
+ *
53
+ * 32 matches the depth cap Studio's fail-closed PII guard already uses, and
54
+ * sits an order of magnitude above anything a hand-written or
55
+ * query-builder-generated predicate reaches (`with` is capped at 10 for the
56
+ * same class of reason). Exceeding it is a {@link ValidationError} (E003), the
57
+ * same typed refusal every other malformed where shape gets.
58
+ */
59
+ export declare const MAX_WHERE_DEPTH = 32;
60
+ /**
61
+ * Refuse a where/having walk that has nested past {@link MAX_WHERE_DEPTH}.
62
+ *
63
+ * Called at the TOP of every recursive walker rather than at the recursion
64
+ * site, so a cap breach is caught on whichever walker runs first (on a cache
65
+ * HIT the SQL build never runs, so a check placed only there would be skipped
66
+ * exactly when the request is being served fastest).
67
+ */
68
+ export declare function assertWhereDepth(depth: number, clause?: 'where' | 'having'): void;
39
69
  /**
40
70
  * Everything the shared walk needs from the owning {@link QueryInterface}. Bound
41
71
  * once per instance (see `QueryInterface`'s `whereHost` field) so the walk stays
@@ -33,8 +33,47 @@
33
33
  * last-line guard against a future leaf builder / collect mirror falling out of
34
34
  * step.
35
35
  */
36
- import { findArrayUniqueKey, findJsonUniqueKey, fingerprintArrayFilterShape, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isWhereOperator, sortedKeys, VECTOR_DISTANCE_COMPARATORS, } from './filters.js';
36
+ import { ValidationError } from '../errors.js';
37
+ import { findArrayUniqueKey, findJsonUniqueKey, fingerprintArrayFilterShape, fingerprintJsonFilterShape, fingerprintOperatorShape, hasRelationFilterWrapper, isArrayFilter, isJsonFilter, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isWhereOperator, sortedKeys, VECTOR_DISTANCE_COMPARATORS, } from './filters.js';
37
38
  import { ownLookup } from './utils.js';
39
+ /**
40
+ * Maximum nesting of `OR` / `AND` / `NOT` combinators and relation-filter
41
+ * descents in one WHERE clause (and in one groupBy `HAVING`).
42
+ *
43
+ * Every walker over a where clause, the fingerprint, the SQL build and the
44
+ * cache-hit param collect, and every one of them for the table-SCOPED sub-where
45
+ * too, is directly recursive on these three keys. Nothing bounded that
46
+ * recursion, so a request body could choose the JavaScript stack depth:
47
+ * measured with wire-realistic JSON, a 4,000-deep `NOT` chain built fine and an
48
+ * 8,000-deep one (a 64 KB body, comfortably under `express.json()`'s 100 KB
49
+ * default) threw `RangeError: Maximum call stack size exceeded`. A `RangeError`
50
+ * is not a {@link TurbineError}, so it walks straight past the typed-error
51
+ * surface callers catch on and lands as an unhandled rejection.
52
+ *
53
+ * 32 matches the depth cap Studio's fail-closed PII guard already uses, and
54
+ * sits an order of magnitude above anything a hand-written or
55
+ * query-builder-generated predicate reaches (`with` is capped at 10 for the
56
+ * same class of reason). Exceeding it is a {@link ValidationError} (E003), the
57
+ * same typed refusal every other malformed where shape gets.
58
+ */
59
+ export const MAX_WHERE_DEPTH = 32;
60
+ /**
61
+ * Refuse a where/having walk that has nested past {@link MAX_WHERE_DEPTH}.
62
+ *
63
+ * Called at the TOP of every recursive walker rather than at the recursion
64
+ * site, so a cap breach is caught on whichever walker runs first (on a cache
65
+ * HIT the SQL build never runs, so a check placed only there would be skipped
66
+ * exactly when the request is being served fastest).
67
+ */
68
+ export function assertWhereDepth(depth, clause = 'where') {
69
+ if (depth <= MAX_WHERE_DEPTH)
70
+ return;
71
+ throw new ValidationError(`[turbine] \`${clause}\` clause nests more than ${MAX_WHERE_DEPTH} levels of AND / OR / NOT ` +
72
+ `(or relation filters) deep. That is far past anything a real predicate needs, and an unbounded ` +
73
+ `walk over caller-supplied nesting is a stack-overflow surface, so it is refused. If this is a ` +
74
+ `generated predicate, flatten it: a single \`AND\` / \`OR\` array of N conditions is one level, ` +
75
+ `not N.`);
76
+ }
38
77
  /** True when a normalized relation filter carries at least one cardinality key. */
39
78
  function isRelationFilterObj(filterObj) {
40
79
  return ('some' in filterObj || 'every' in filterObj || 'none' in filterObj || 'is' in filterObj || 'isNot' in filterObj);