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.
- package/README.md +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
package/dist/query/utils.d.ts
CHANGED
|
@@ -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
|
|
69
|
-
* Uses Map insertion order
|
|
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
|
package/dist/query/utils.js
CHANGED
|
@@ -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
|
|
90
|
-
* Uses Map insertion order
|
|
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
|
-
|
|
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 {
|
|
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);
|