turbine-orm 0.70.0 → 0.71.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 +164 -1041
- package/dist/cjs/cli/compile-query.d.ts +198 -0
- package/dist/cjs/cli/compile-query.js +529 -0
- package/dist/cjs/cli/index.d.ts +25 -1
- package/dist/cjs/cli/index.js +49 -1
- package/dist/cjs/cli/mcp.js +198 -16
- package/dist/cjs/client.d.ts +45 -10
- package/dist/cjs/client.js +21 -3
- package/dist/cjs/connection-url.d.ts +160 -0
- package/dist/cjs/connection-url.js +296 -0
- package/dist/cjs/index-stats.d.ts +4 -1
- package/dist/cjs/index-stats.js +27 -11
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/plan-flip-probe.js +17 -1
- package/dist/cjs/powql.d.ts +1 -0
- package/dist/cjs/powql.js +9 -0
- package/dist/cjs/query/builder.d.ts +133 -2
- package/dist/cjs/query/builder.js +288 -64
- package/dist/cjs/query/deferred.d.ts +12 -6
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/option-surface.js +6 -0
- package/dist/cjs/query/types.d.ts +47 -0
- package/dist/cjs/query/where.d.ts +11 -2
- package/dist/cli/compile-query.d.ts +198 -0
- package/dist/cli/compile-query.js +522 -0
- package/dist/cli/index.d.ts +25 -1
- package/dist/cli/index.js +48 -1
- package/dist/cli/mcp.js +198 -16
- package/dist/client.d.ts +45 -10
- package/dist/client.js +19 -1
- package/dist/connection-url.d.ts +160 -0
- package/dist/connection-url.js +289 -0
- package/dist/index-stats.d.ts +4 -1
- package/dist/index-stats.js +27 -11
- package/dist/index.d.ts +1 -1
- package/dist/plan-flip-probe.js +17 -1
- package/dist/powql.d.ts +1 -0
- package/dist/powql.js +9 -0
- package/dist/query/builder.d.ts +133 -2
- package/dist/query/builder.js +288 -64
- package/dist/query/deferred.d.ts +12 -6
- package/dist/query/index.d.ts +1 -1
- package/dist/query/option-surface.js +6 -0
- package/dist/query/types.d.ts +47 -0
- package/dist/query/where.d.ts +11 -2
- package/package.json +8 -6
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* turbine-orm CLI: compile a read query WITHOUT executing it.
|
|
4
|
+
*
|
|
5
|
+
* Turbine already separates BUILD from EXECUTE: every `build*()` method on
|
|
6
|
+
* `QueryInterface` returns a {@link DeferredQuery} (`{ sql, params, transform,
|
|
7
|
+
* tag }`) and nothing runs until something calls `execute()`. This module is
|
|
8
|
+
* that seam turned into a tool: hand it schema metadata and a set of `findMany`
|
|
9
|
+
* -shaped args, get back the exact statement the ORM would send, the bound
|
|
10
|
+
* parameter list, and a read of whether the query is a mistake.
|
|
11
|
+
*
|
|
12
|
+
* ZERO DATABASE ACCESS, ENFORCED BY CONSTRUCTION RATHER THAN BY REVIEW.
|
|
13
|
+
* `QueryInterface` takes a pool in its constructor and never touches it on the
|
|
14
|
+
* build path, so this module hands it {@link SEALED_POOL}, whose every method
|
|
15
|
+
* THROWS. That is the whole guarantee: a future build path that grew a query
|
|
16
|
+
* would fail loudly here instead of quietly opening a connection, and the
|
|
17
|
+
* "compiles nothing, runs nothing" claim in the tool description does not
|
|
18
|
+
* depend on anyone re-reading the builder. The caller is responsible for
|
|
19
|
+
* obtaining `SchemaMetadata`; this module reads no catalog of its own.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT REFUSES TO DO. Read operations only, and that is a property of this
|
|
22
|
+
* module, not of its caller: {@link COMPILE_OPERATIONS} is the closed set, and
|
|
23
|
+
* a name outside it never reaches a builder. A write's SQL text is arguably as
|
|
24
|
+
* harmless to display as a read's, but the TOOL SURFACE is the security
|
|
25
|
+
* boundary on an agent-facing server, and "we also compile deletes, we just
|
|
26
|
+
* don't run them" is an argument, where "there is no write path" is a fact.
|
|
27
|
+
*
|
|
28
|
+
* A COMPILE FAILURE IS AN ANSWER, NOT AN ERROR. `where: { titel: 'x' }` throws
|
|
29
|
+
* `ValidationError` (E003) and `with: { autor: true }` throws `RelationError`
|
|
30
|
+
* (E005); both are exactly what the caller asked to find out, one round trip
|
|
31
|
+
* before the code is written. So a `TurbineError` out of the builder comes back
|
|
32
|
+
* as a successful result carrying the code, the message, the docs URL and the
|
|
33
|
+
* catalog's causes/fixes. Anything that is NOT a TurbineError propagates: it is
|
|
34
|
+
* a bug or a malformed request, and dressing it up as a query verdict would
|
|
35
|
+
* hide it.
|
|
36
|
+
*/
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.COLUMN_NAMING_ARG_KEYS = exports.SEALED_POOL = exports.COMPILE_OPERATIONS = void 0;
|
|
39
|
+
exports.isAggregateShaped = isAggregateShaped;
|
|
40
|
+
exports.carriesColumnNamingArg = carriesColumnNamingArg;
|
|
41
|
+
exports.compileQueryPlan = compileQueryPlan;
|
|
42
|
+
exports.collectAggregateColumnNames = collectAggregateColumnNames;
|
|
43
|
+
const errors_js_1 = require("../errors.js");
|
|
44
|
+
const index_advisor_js_1 = require("../index-advisor.js");
|
|
45
|
+
const index_js_1 = require("../query/index.js");
|
|
46
|
+
const utils_js_1 = require("../query/utils.js");
|
|
47
|
+
const error_catalog_js_1 = require("./error-catalog.js");
|
|
48
|
+
const pii_predicate_guard_js_1 = require("./pii-predicate-guard.js");
|
|
49
|
+
/**
|
|
50
|
+
* The read operations this module compiles. A CLOSED set, checked before any
|
|
51
|
+
* builder is reached, which is what keeps the read-only stance a fact rather
|
|
52
|
+
* than a convention: there is no branch here that reaches `buildCreate`,
|
|
53
|
+
* `buildUpdate`, `buildDelete` or `buildUpsert`.
|
|
54
|
+
*/
|
|
55
|
+
exports.COMPILE_OPERATIONS = ['findMany', 'findUnique', 'findFirst', 'count', 'aggregate', 'groupBy'];
|
|
56
|
+
/** True for the two operations whose args are NOT findMany-shaped. */
|
|
57
|
+
function isAggregateShaped(operation) {
|
|
58
|
+
return operation === 'aggregate' || operation === 'groupBy';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A pool that refuses every call.
|
|
62
|
+
*
|
|
63
|
+
* `QueryInterface`'s constructor requires one, and the build path never uses
|
|
64
|
+
* it. Passing a sealed object rather than the caller's real pool is what makes
|
|
65
|
+
* "this compiles and does not execute" structurally true: there is no live
|
|
66
|
+
* connection in scope for a statement to escape down, and a future build path
|
|
67
|
+
* that tried to read something would throw a message naming this file rather
|
|
68
|
+
* than silently issuing a query on an agent's behalf.
|
|
69
|
+
*/
|
|
70
|
+
exports.SEALED_POOL = {
|
|
71
|
+
query() {
|
|
72
|
+
throw new Error('[turbine] compile-query: the compile path must never execute a statement (sealed pool)');
|
|
73
|
+
},
|
|
74
|
+
connect() {
|
|
75
|
+
throw new Error('[turbine] compile-query: the compile path must never open a connection (sealed pool)');
|
|
76
|
+
},
|
|
77
|
+
end() {
|
|
78
|
+
throw new Error('[turbine] compile-query: the compile path owns no connection to close (sealed pool)');
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Serialized length past which a bound parameter is summarized instead of
|
|
83
|
+
* echoed. A TOKEN budget, not a security boundary: see the params note in
|
|
84
|
+
* {@link compileQueryPlan}.
|
|
85
|
+
*/
|
|
86
|
+
const PARAM_ECHO_MAX_CHARS = 200;
|
|
87
|
+
/** Depth past which the with-clause walk stops describing relations. */
|
|
88
|
+
const WITH_WALK_MAX_DEPTH = 12;
|
|
89
|
+
/**
|
|
90
|
+
* Query-arg keys that name a column or an ordering over one, in any of the six
|
|
91
|
+
* operations. Used only by the caller's fail-closed check for an unreadable PII
|
|
92
|
+
* tag scan: with no trustworthy tag list, a compile carrying any of these
|
|
93
|
+
* cannot be shown not to name a hidden column.
|
|
94
|
+
*
|
|
95
|
+
* `select` / `omit` / `with` are deliberately absent, matching the guard's own
|
|
96
|
+
* rule: they return values, and this tool returns no values at all.
|
|
97
|
+
*/
|
|
98
|
+
exports.COLUMN_NAMING_ARG_KEYS = [
|
|
99
|
+
'where',
|
|
100
|
+
'orderBy',
|
|
101
|
+
'cursor',
|
|
102
|
+
'distinct',
|
|
103
|
+
'by',
|
|
104
|
+
'having',
|
|
105
|
+
'distinctOn',
|
|
106
|
+
'_count',
|
|
107
|
+
'_sum',
|
|
108
|
+
'_avg',
|
|
109
|
+
'_min',
|
|
110
|
+
'_max',
|
|
111
|
+
];
|
|
112
|
+
/** Does this args object carry a key that names a column? */
|
|
113
|
+
function carriesColumnNamingArg(args) {
|
|
114
|
+
return exports.COLUMN_NAMING_ARG_KEYS.some((key) => args[key] !== undefined);
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Compile
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
function isPlainObject(value) {
|
|
120
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
121
|
+
}
|
|
122
|
+
/** A where clause that actually constrains something. `{}` compiles to no WHERE. */
|
|
123
|
+
function hasRealWhere(args) {
|
|
124
|
+
const where = args.where;
|
|
125
|
+
if (!isPlainObject(where))
|
|
126
|
+
return false;
|
|
127
|
+
return Object.values(where).some((value) => value !== undefined);
|
|
128
|
+
}
|
|
129
|
+
function positiveInteger(value) {
|
|
130
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Compile one read query and describe it. Never touches a database; see the
|
|
134
|
+
* module header for why that is enforced rather than promised.
|
|
135
|
+
*/
|
|
136
|
+
function compileQueryPlan(input) {
|
|
137
|
+
const { metadata, table, operation, args } = input;
|
|
138
|
+
// THIS OPTIONS LITERAL IS WHAT MAKES `paramNote` TRUE. Every element of
|
|
139
|
+
// `deferred.params` traces back to a value the caller wrote in `args`, and it
|
|
140
|
+
// does so because exactly two core features bind a value the caller did not
|
|
141
|
+
// write: a configured `globalFilters` entry, and `defaultLimit`. Neither is
|
|
142
|
+
// set here. Adding either to this literal turns the echoed parameter list into
|
|
143
|
+
// a disclosure channel for the application's own configuration, so do not,
|
|
144
|
+
// and if a third such feature ever lands, it belongs on this list rather than
|
|
145
|
+
// in the reply.
|
|
146
|
+
const qi = new index_js_1.QueryInterface(exports.SEALED_POOL, table.name, metadata, [], {
|
|
147
|
+
// Diagnostics come from this module, not from a console warning fired into
|
|
148
|
+
// the operator's stderr on someone else's behalf.
|
|
149
|
+
warnOnUnlimited: false,
|
|
150
|
+
// The cache is per-QueryInterface and this one is discarded with the reply,
|
|
151
|
+
// so leaving it ON would only ever miss. It stays OFF for the same reason
|
|
152
|
+
// `explain_query` and Studio's builder turn it off: a diagnostic compile
|
|
153
|
+
// must not be able to seed or read a shared template. The prepared-statement
|
|
154
|
+
// NAME is unaffected, `buildCacheEntry` computes it either way, which is
|
|
155
|
+
// what lets the report tell a named shape from an unnamed one.
|
|
156
|
+
sqlCache: false,
|
|
157
|
+
preparedStatements: false,
|
|
158
|
+
});
|
|
159
|
+
let deferred;
|
|
160
|
+
try {
|
|
161
|
+
deferred = compileOne(qi, operation, args);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
if (err instanceof errors_js_1.TurbineError) {
|
|
165
|
+
return { ok: false, table: table.name, operation, error: describeTurbineError(err) };
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
const requested = args.relationLoadStrategy ?? null;
|
|
170
|
+
const effective = requested ?? input.clientRelationLoadStrategy ?? 'auto';
|
|
171
|
+
const relations = [];
|
|
172
|
+
let relationsTruncated = false;
|
|
173
|
+
if (isPlainObject(args.with)) {
|
|
174
|
+
relationsTruncated = walkWith(metadata, table, args.with, 1, '', relations);
|
|
175
|
+
}
|
|
176
|
+
const relationDepth = relations.reduce((max, relation) => Math.max(max, relation.depth), 0);
|
|
177
|
+
const params = deferred.params.map(echoParam);
|
|
178
|
+
const paramsTruncated = params.some((entry) => entry.truncated);
|
|
179
|
+
const named = deferred.preparedName !== '' && deferred.preparedName !== undefined;
|
|
180
|
+
const bound = describeBound(operation, args);
|
|
181
|
+
const hasWhere = hasRealWhere(args);
|
|
182
|
+
const success = {
|
|
183
|
+
table: table.name,
|
|
184
|
+
operation,
|
|
185
|
+
sql: deferred.sql,
|
|
186
|
+
params: params.map((entry) => entry.value),
|
|
187
|
+
paramCount: deferred.params.length,
|
|
188
|
+
paramsTruncated,
|
|
189
|
+
paramNote: 'Every bound value here came from this request. The compiler binds no value of its own on this path: ' +
|
|
190
|
+
'the two core features that would (a configured global filter, and a client-level default limit) are not ' +
|
|
191
|
+
'enabled on the compiling interface. A value whose serialized form exceeds ' +
|
|
192
|
+
`${PARAM_ECHO_MAX_CHARS} characters is summarized rather than echoed, and paramsTruncated says so.`,
|
|
193
|
+
preparedStatement: {
|
|
194
|
+
named,
|
|
195
|
+
name: named ? (deferred.preparedName ?? null) : null,
|
|
196
|
+
note: named
|
|
197
|
+
? 'This shape executes as a NAMED prepared statement, so the server parses and plans it once per ' +
|
|
198
|
+
'connection and reuses it.'
|
|
199
|
+
: 'This shape executes UNNAMED. Two things give a statement up its server-side name, and this query has ' +
|
|
200
|
+
'at least one: a caller-written AND / OR ARRAY in the where or having clause (whose LENGTH is written ' +
|
|
201
|
+
'into the SQL text, one parenthesized branch per element), or a MULTI-column `distinct` (whose column ' +
|
|
202
|
+
'list is written in one term at a time, so the reachable statement set is the ordered subsets of the ' +
|
|
203
|
+
'table). Either way a list assembled from variable input would mint statements the server never ' +
|
|
204
|
+
'deallocates, so unnamed is the bound. It costs one re-parse per execution and changes no SQL. Use ' +
|
|
205
|
+
'`in: [...]` where you can: it binds one array parameter whatever the list length.',
|
|
206
|
+
},
|
|
207
|
+
statements: describeStatements(operation, effective, relations.length, args),
|
|
208
|
+
plan: {
|
|
209
|
+
relationLoadStrategy: { requested, effective, note: strategyNote(requested, effective, relations.length) },
|
|
210
|
+
relationCount: relations.length,
|
|
211
|
+
relationDepth,
|
|
212
|
+
relations,
|
|
213
|
+
relationsTruncated,
|
|
214
|
+
hasWhere,
|
|
215
|
+
hasOrderBy: args.orderBy !== undefined,
|
|
216
|
+
bounded: bound.bounded,
|
|
217
|
+
limit: bound.limit,
|
|
218
|
+
boundedBy: bound.by,
|
|
219
|
+
readsEveryRow: !hasWhere,
|
|
220
|
+
},
|
|
221
|
+
advice: buildAdvice(operation, relations, relationDepth, named, hasWhere, bound),
|
|
222
|
+
note: 'This is the statement the ORM would send. It was compiled and NOT executed: no connection was opened and ' +
|
|
223
|
+
'no query ran, so the numbers here are properties of the query, never of your data.',
|
|
224
|
+
};
|
|
225
|
+
return { ok: true, ...success };
|
|
226
|
+
}
|
|
227
|
+
/** Dispatch to the one builder for this operation. The only place operations map to builders. */
|
|
228
|
+
function compileOne(qi, operation, args) {
|
|
229
|
+
switch (operation) {
|
|
230
|
+
case 'findMany':
|
|
231
|
+
return qi.buildFindMany(args);
|
|
232
|
+
// `as unknown as` for the two arg types with a REQUIRED member
|
|
233
|
+
// (`findUnique.where`, `groupBy.by`). Omitting it is a caller error the
|
|
234
|
+
// builder raises as a typed `ValidationError`, which is the answer this tool
|
|
235
|
+
// exists to hand back, so the cast must not pre-empt it with a TypeScript
|
|
236
|
+
// complaint about args that arrived over JSON-RPC anyway.
|
|
237
|
+
case 'findUnique':
|
|
238
|
+
return qi.buildFindUnique(args);
|
|
239
|
+
case 'findFirst':
|
|
240
|
+
return qi.buildFindFirst(args);
|
|
241
|
+
case 'count':
|
|
242
|
+
return qi.buildCount(args);
|
|
243
|
+
case 'aggregate':
|
|
244
|
+
return qi.buildAggregate(args);
|
|
245
|
+
case 'groupBy':
|
|
246
|
+
return qi.buildGroupBy(args);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* A thrown `TurbineError`, as the caller's answer.
|
|
251
|
+
*
|
|
252
|
+
* The catalog fields are folded in here rather than left to the caller because
|
|
253
|
+
* this is the one place that knows a compile FAILED, and an agent holding
|
|
254
|
+
* `TURBINE_E003` with no idea what E003 means is one tool call away from
|
|
255
|
+
* guessing. Absent when the code is not catalogued (nothing here invents one).
|
|
256
|
+
*/
|
|
257
|
+
function describeTurbineError(err) {
|
|
258
|
+
const explanation = (0, error_catalog_js_1.explainErrorCode)(err.code);
|
|
259
|
+
return {
|
|
260
|
+
code: err.code,
|
|
261
|
+
message: err.message,
|
|
262
|
+
docsUrl: err.docsUrl,
|
|
263
|
+
className: explanation?.className,
|
|
264
|
+
whenThrown: explanation?.whenThrown,
|
|
265
|
+
likelyCauses: explanation?.likelyCauses,
|
|
266
|
+
howToFix: explanation?.howToFix,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* One bound parameter, sized for an LLM context.
|
|
271
|
+
*
|
|
272
|
+
* A withholding is LABELLED, never expressed by dropping the value: an absent
|
|
273
|
+
* element would renumber the list and make `$3` in the SQL point at the wrong
|
|
274
|
+
* entry, which is a worse answer than a marker.
|
|
275
|
+
*/
|
|
276
|
+
function echoParam(value) {
|
|
277
|
+
let json;
|
|
278
|
+
try {
|
|
279
|
+
json = JSON.stringify(value);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return { value: '(value withheld: not JSON-serializable)', truncated: true };
|
|
283
|
+
}
|
|
284
|
+
if (json === undefined)
|
|
285
|
+
return { value: null, truncated: false };
|
|
286
|
+
if (json.length <= PARAM_ECHO_MAX_CHARS)
|
|
287
|
+
return { value, truncated: false };
|
|
288
|
+
if (typeof value === 'string') {
|
|
289
|
+
return { value: `${value.slice(0, PARAM_ECHO_MAX_CHARS)}… (truncated, ${value.length} chars)`, truncated: true };
|
|
290
|
+
}
|
|
291
|
+
return { value: `(value withheld: ${json.length} serialized chars, too large to echo)`, truncated: true };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Describe every relation the `with` clause reaches.
|
|
295
|
+
*
|
|
296
|
+
* Runs only AFTER a successful compile, so an unknown relation name cannot
|
|
297
|
+
* appear here: the builder has already refused it as E005. Returns true when
|
|
298
|
+
* the walk stopped at {@link WITH_WALK_MAX_DEPTH}, which is reported rather
|
|
299
|
+
* than silently swallowed.
|
|
300
|
+
*/
|
|
301
|
+
function walkWith(metadata, table, withClause, depth, prefix, out) {
|
|
302
|
+
if (depth > WITH_WALK_MAX_DEPTH)
|
|
303
|
+
return true;
|
|
304
|
+
let truncated = false;
|
|
305
|
+
for (const [key, spec] of Object.entries(withClause)) {
|
|
306
|
+
// `_count` is an inline aggregate, not a relation node: it adds no follow-up
|
|
307
|
+
// statement and has no target table of its own.
|
|
308
|
+
if (key === '_count')
|
|
309
|
+
continue;
|
|
310
|
+
const relation = (0, utils_js_1.ownLookup)(table.relations, key);
|
|
311
|
+
if (!relation)
|
|
312
|
+
continue;
|
|
313
|
+
const path = prefix ? `${prefix}.${relation.name}` : relation.name;
|
|
314
|
+
out.push({
|
|
315
|
+
path,
|
|
316
|
+
name: relation.name,
|
|
317
|
+
type: relation.type,
|
|
318
|
+
from: relation.from,
|
|
319
|
+
to: relation.to,
|
|
320
|
+
depth,
|
|
321
|
+
returnsArray: relation.type === 'hasMany' || relation.type === 'manyToMany',
|
|
322
|
+
limit: isPlainObject(spec) ? positiveInteger(spec.limit) : null,
|
|
323
|
+
unindexedProbe: (0, index_advisor_js_1.missingIndexForRelation)(metadata, relation),
|
|
324
|
+
});
|
|
325
|
+
const target = (0, utils_js_1.ownLookup)(metadata.tables, relation.to);
|
|
326
|
+
if (target && isPlainObject(spec) && isPlainObject(spec.with)) {
|
|
327
|
+
truncated = walkWith(metadata, target, spec.with, depth + 1, path, out) || truncated;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return truncated;
|
|
331
|
+
}
|
|
332
|
+
/** Whether the query is bounded to a known number of rows, and by what. */
|
|
333
|
+
function describeBound(operation, args) {
|
|
334
|
+
if (operation === 'findUnique')
|
|
335
|
+
return { bounded: true, limit: 1, by: 'findUnique (a unique where matches one row)' };
|
|
336
|
+
if (operation === 'findFirst')
|
|
337
|
+
return { bounded: true, limit: 1, by: 'findFirst (LIMIT 1)' };
|
|
338
|
+
if (operation === 'count' || operation === 'aggregate') {
|
|
339
|
+
return { bounded: true, limit: 1, by: `${operation} (one result row)` };
|
|
340
|
+
}
|
|
341
|
+
// `by` names the key that actually produced the number, not merely the key
|
|
342
|
+
// that was present: `{ limit: 0, take: 5 }` is bounded BY TAKE.
|
|
343
|
+
const fromLimit = positiveInteger(args.limit);
|
|
344
|
+
if (fromLimit !== null)
|
|
345
|
+
return { bounded: true, limit: fromLimit, by: 'limit' };
|
|
346
|
+
const fromTake = positiveInteger(args.take);
|
|
347
|
+
if (fromTake !== null)
|
|
348
|
+
return { bounded: true, limit: fromTake, by: 'take' };
|
|
349
|
+
return { bounded: false, limit: null, by: null };
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* How many statements this query costs at execution.
|
|
353
|
+
*
|
|
354
|
+
* Only two of the four strategies have a knowable answer before the query runs,
|
|
355
|
+
* and saying so is the point: `'auto'` decides its split per relation at
|
|
356
|
+
* execute time from index coverage and the parent-row bound, so a number here
|
|
357
|
+
* would be a guess dressed as a fact. It gets a RANGE and a reason instead.
|
|
358
|
+
*/
|
|
359
|
+
function describeStatements(operation, effective, relationCount, args) {
|
|
360
|
+
if (!isPlainObject(args.with) || relationCount === 0) {
|
|
361
|
+
return { compiled: 1, atExecution: 1, note: 'No relations to load, so this is a single statement.' };
|
|
362
|
+
}
|
|
363
|
+
if (operation === 'count' || operation === 'aggregate' || operation === 'groupBy') {
|
|
364
|
+
return {
|
|
365
|
+
compiled: 1,
|
|
366
|
+
atExecution: 1,
|
|
367
|
+
note: `A ${operation} loads no relations, so a \`with\` clause here does not add statements.`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
if (effective === 'batched') {
|
|
371
|
+
return {
|
|
372
|
+
compiled: 1,
|
|
373
|
+
atExecution: 1 + relationCount,
|
|
374
|
+
note: `The base statement plus one flat follow-up per relation (${relationCount}), stitched client-side. A ` +
|
|
375
|
+
'parent set with more than 32,000 correlation keys is chunked, which adds a statement per chunk.',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (effective === 'auto') {
|
|
379
|
+
return {
|
|
380
|
+
compiled: 1,
|
|
381
|
+
atExecution: null,
|
|
382
|
+
note: `Between 1 and ${1 + relationCount}. \`auto\` plans the single-statement join and falls back to a batched ` +
|
|
383
|
+
'follow-up PER RELATION whose correlation column is provably unindexed, or whose parent set is unbounded; ' +
|
|
384
|
+
'that decision is made at execute time, so it is not knowable here. The SQL above is the join plan. Pass ' +
|
|
385
|
+
"relationLoadStrategy: 'join' or 'batched' to compile a query whose statement count is fixed.",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
compiled: 1,
|
|
390
|
+
atExecution: 1,
|
|
391
|
+
note: effective === 'flatten'
|
|
392
|
+
? 'One statement: eligible to-one relations compile to a LEFT JOIN, and any relation that is not eligible ' +
|
|
393
|
+
'silently falls back to a correlated subquery in the same statement.'
|
|
394
|
+
: 'One statement: every relation is a correlated json_agg subquery inside the SELECT above.',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function strategyNote(requested, effective, relationCount) {
|
|
398
|
+
if (relationCount === 0)
|
|
399
|
+
return 'This query loads no relations, so the relation-load strategy does not apply.';
|
|
400
|
+
if (requested !== null)
|
|
401
|
+
return `The query asked for '${requested}', so that is what it compiles to.`;
|
|
402
|
+
return `The query names no strategy, so it inherits the client default ('${effective}').`;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The part that answers "is this query a mistake" rather than "what SQL is it".
|
|
406
|
+
*
|
|
407
|
+
* Every entry is derived from the compiled query or from schema metadata, never
|
|
408
|
+
* from data, so nothing here can be wrong because a table is empty today.
|
|
409
|
+
*/
|
|
410
|
+
function buildAdvice(operation, relations, relationDepth, named, hasWhere, bound) {
|
|
411
|
+
const advice = [];
|
|
412
|
+
if (operation === 'findMany' && !bound.bounded) {
|
|
413
|
+
advice.push({
|
|
414
|
+
code: 'unbounded-read',
|
|
415
|
+
severity: 'warn',
|
|
416
|
+
message: hasWhere
|
|
417
|
+
? 'This findMany has no `limit`, so it returns every row matching the filter. That set grows with the ' +
|
|
418
|
+
'table; add a `limit` (and an `orderBy` so the page is deterministic).'
|
|
419
|
+
: 'This findMany has neither a `where` nor a `limit`, so it reads and returns the whole table. Add a ' +
|
|
420
|
+
'filter, a limit, or both.',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// `findUnique` is excluded because the builder refuses one with no predicate
|
|
424
|
+
// (E003), so it can never reach this point without a where.
|
|
425
|
+
if (!hasWhere && operation !== 'findUnique') {
|
|
426
|
+
advice.push({
|
|
427
|
+
code: 'no-filter',
|
|
428
|
+
severity: 'info',
|
|
429
|
+
message: 'No `where` clause, so the engine has no predicate to use an index for and must consider every row of ' +
|
|
430
|
+
'the table. A LIMIT bounds the rows RETURNED, not the rows scanned.',
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
for (const relation of relations) {
|
|
434
|
+
if (!relation.unindexedProbe)
|
|
435
|
+
continue;
|
|
436
|
+
advice.push({
|
|
437
|
+
code: 'unindexed-relation-probe',
|
|
438
|
+
severity: 'warn',
|
|
439
|
+
message: `Relation "${relation.path}" probes ${relation.unindexedProbe.table}(${relation.unindexedProbe.columns.join(', ')}), ` +
|
|
440
|
+
'which no index serves. Under the single-statement join that probe is a full scan re-run once per parent ' +
|
|
441
|
+
`row. Create the index (${relation.unindexedProbe.createSql}) or load this relation batched.`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if (relationDepth > 5) {
|
|
445
|
+
advice.push({
|
|
446
|
+
code: 'deep-with',
|
|
447
|
+
severity: 'warn',
|
|
448
|
+
message: `The \`with\` clause is ${relationDepth} levels deep. Under the join strategy each level is a correlated ` +
|
|
449
|
+
'subquery the engine re-evaluates once per row of the level above, so the work multiplies down the tree. ' +
|
|
450
|
+
'Split the query, or load it batched.',
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
const unlimitedToMany = relations.filter((relation) => relation.returnsArray && relation.limit === null);
|
|
454
|
+
if (unlimitedToMany.length > 0) {
|
|
455
|
+
advice.push({
|
|
456
|
+
code: 'unbounded-relation',
|
|
457
|
+
severity: 'info',
|
|
458
|
+
message: `To-many relation(s) ${unlimitedToMany.map((relation) => relation.path).join(', ')} carry no per-relation ` +
|
|
459
|
+
'`limit`, so every child row of every parent is materialized into the result. Set a `limit` on the ' +
|
|
460
|
+
'relation if you only need the first few.',
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (!named) {
|
|
464
|
+
advice.push({
|
|
465
|
+
code: 'unnamed-prepared-statement',
|
|
466
|
+
severity: 'info',
|
|
467
|
+
message: 'This shape executes as an UNNAMED prepared statement, because a caller-written AND / OR array or a ' +
|
|
468
|
+
'multi-column `distinct` writes its own LENGTH into the SQL text. That is deliberate and bounds server ' +
|
|
469
|
+
'memory; the cost is one re-parse per execution. Prefer `in: [...]` for a variable-length list.',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return advice;
|
|
473
|
+
}
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Aggregate-shaped column names, for the caller's PII guard
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
/**
|
|
478
|
+
* Every string that could be a column name anywhere in an `aggregate` /
|
|
479
|
+
* `groupBy` args object, EXCLUDING `where` (which the shared PII predicate
|
|
480
|
+
* guard walks properly).
|
|
481
|
+
*
|
|
482
|
+
* DELIBERATELY BLUNTER THAN THE SHARED WALKER, and blunt in the fail-closed
|
|
483
|
+
* direction. The aggregate arg surface names columns in positions the where /
|
|
484
|
+
* orderBy walker was never built for: `by` names them as ARRAY ELEMENTS,
|
|
485
|
+
* `_min` / `_max` / `_sum` / `_avg` as KEYS one level under an aggregate block,
|
|
486
|
+
* `having` as keys under both, and a JSON-path group key names one inside a
|
|
487
|
+
* `{ field, path }` object. Teaching the shared walker each of those shapes
|
|
488
|
+
* means a second place that has to stay in step with the aggregate compiler,
|
|
489
|
+
* which is the exact failure mode `cli/pii-predicate-guard.ts` exists to end.
|
|
490
|
+
*
|
|
491
|
+
* So this harvests EVERY string in key or value position and hands the lot to
|
|
492
|
+
* the caller's column resolver. A string that is not a column resolves to
|
|
493
|
+
* nothing and is ignored; a string that IS a hidden column is refused wherever
|
|
494
|
+
* it appears. The cost is a false refusal on a schema whose hidden column is
|
|
495
|
+
* named `desc` or `_all`; the benefit is that a new aggregate arg shape is
|
|
496
|
+
* covered the day it ships, with no edit here.
|
|
497
|
+
*
|
|
498
|
+
* `where` is excluded because the shared walker already covers it with the
|
|
499
|
+
* relation-aware precision this cannot have.
|
|
500
|
+
*/
|
|
501
|
+
function collectAggregateColumnNames(args) {
|
|
502
|
+
const names = new Set();
|
|
503
|
+
const visit = (value, depth) => {
|
|
504
|
+
if (depth > pii_predicate_guard_js_1.PII_GUARD_MAX_DEPTH) {
|
|
505
|
+
throw new RangeError(`[turbine] compile-query: aggregate args are nested more than ${pii_predicate_guard_js_1.PII_GUARD_MAX_DEPTH} levels deep`);
|
|
506
|
+
}
|
|
507
|
+
if (typeof value === 'string') {
|
|
508
|
+
names.add(value);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (Array.isArray(value)) {
|
|
512
|
+
for (const item of value)
|
|
513
|
+
visit(item, depth + 1);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!isPlainObject(value))
|
|
517
|
+
return;
|
|
518
|
+
for (const [key, member] of Object.entries(value)) {
|
|
519
|
+
names.add(key);
|
|
520
|
+
visit(member, depth + 1);
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
for (const [key, value] of Object.entries(args)) {
|
|
524
|
+
if (key === 'where')
|
|
525
|
+
continue;
|
|
526
|
+
visit(value, 1);
|
|
527
|
+
}
|
|
528
|
+
return [...names];
|
|
529
|
+
}
|
package/dist/cjs/cli/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* turbine migrate status , Show migration status
|
|
15
15
|
* turbine seed , Run seed file
|
|
16
16
|
* turbine status , Show schema summary
|
|
17
|
-
* turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence)
|
|
17
|
+
* turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence, --allow-pooler)
|
|
18
18
|
* turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
|
|
19
19
|
* turbine mcp , Start read-only MCP server over JSON-RPC stdio
|
|
20
20
|
* turbine observe , Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
@@ -63,6 +63,8 @@ export interface CliArgs {
|
|
|
63
63
|
metricsUrl?: string;
|
|
64
64
|
/** `doctor --no-plan-divergence`: skip the cached-plan divergence section (and its pg_stats read). */
|
|
65
65
|
noPlanDivergence?: boolean;
|
|
66
|
+
/** `doctor --allow-pooler`: run even when the connection string looks like a transaction pooler. */
|
|
67
|
+
allowPooler?: boolean;
|
|
66
68
|
/** `init --yes`/`-y`: accept every step's default non-interactively. */
|
|
67
69
|
yes?: boolean;
|
|
68
70
|
/** `init --skip-schema`: don't scaffold the schema file. */
|
|
@@ -454,6 +456,28 @@ export type SeedExecutionPlan = {
|
|
|
454
456
|
file: string;
|
|
455
457
|
};
|
|
456
458
|
export declare function getSeedExecutionPlan(seedFile: string): SeedExecutionPlan;
|
|
459
|
+
/**
|
|
460
|
+
* Refuse a `doctor` run whose connection string points at a transaction pooler.
|
|
461
|
+
*
|
|
462
|
+
* Called BEFORE anything opens a connection, which is the whole point: the
|
|
463
|
+
* refusal has to happen while the only thing anybody has touched is a string.
|
|
464
|
+
*
|
|
465
|
+
* Doctor is a diagnostic command that reads statistics and reasons about
|
|
466
|
+
* cached plans, and both of those arguments assume the session it is talking to
|
|
467
|
+
* is its own. Through a pooler it is not: session state this process sets can be
|
|
468
|
+
* left on a shared backend for someone else's queries, and the session-scoped
|
|
469
|
+
* view the report's own remediation tells the reader to inspect
|
|
470
|
+
* (`pg_prepared_statements`) belongs to whichever backend happened to answer.
|
|
471
|
+
* Refusing is the honest outcome; reporting confidently over a connection whose
|
|
472
|
+
* session semantics do not hold is not.
|
|
473
|
+
*
|
|
474
|
+
* Written to STDERR, including in `--json` mode, where stdout is contracted to
|
|
475
|
+
* carry the report and nothing else.
|
|
476
|
+
*
|
|
477
|
+
* Exported for the unit test only (like {@link isLoopbackHost}): a gate whose
|
|
478
|
+
* failure path is `process.exit` is untestable through the command itself.
|
|
479
|
+
*/
|
|
480
|
+
export declare function refusePoolerConnection(url: string, args: CliArgs): void;
|
|
457
481
|
/**
|
|
458
482
|
* True when `host` is a loopback address Studio/Observe may bind without
|
|
459
483
|
* `--allow-remote`. Accepts IPv4, IPv6, and the common bracket form.
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* turbine migrate status , Show migration status
|
|
16
16
|
* turbine seed , Run seed file
|
|
17
17
|
* turbine status , Show schema summary
|
|
18
|
-
* turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence)
|
|
18
|
+
* turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence, --allow-pooler)
|
|
19
19
|
* turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
|
|
20
20
|
* turbine mcp , Start read-only MCP server over JSON-RPC stdio
|
|
21
21
|
* turbine observe , Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
|
|
@@ -77,12 +77,14 @@ exports.resolveMigrateFromPrismaUrl = resolveMigrateFromPrismaUrl;
|
|
|
77
77
|
exports.readPrismaSchemaSource = readPrismaSchemaSource;
|
|
78
78
|
exports.buildMigrateDeployOptions = buildMigrateDeployOptions;
|
|
79
79
|
exports.getSeedExecutionPlan = getSeedExecutionPlan;
|
|
80
|
+
exports.refusePoolerConnection = refusePoolerConnection;
|
|
80
81
|
exports.isLoopbackHost = isLoopbackHost;
|
|
81
82
|
exports.showSubcommandHelp = showSubcommandHelp;
|
|
82
83
|
const node_fs_1 = require("node:fs");
|
|
83
84
|
const node_os_1 = require("node:os");
|
|
84
85
|
const node_path_1 = require("node:path");
|
|
85
86
|
const node_url_1 = require("node:url");
|
|
87
|
+
const connection_url_js_1 = require("../connection-url.js");
|
|
86
88
|
const generate_js_1 = require("../generate.js");
|
|
87
89
|
const index_advisor_js_1 = require("../index-advisor.js");
|
|
88
90
|
const index_stats_js_1 = require("../index-stats.js");
|
|
@@ -222,6 +224,9 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
222
224
|
case '--no-plan-divergence':
|
|
223
225
|
result.noPlanDivergence = true;
|
|
224
226
|
break;
|
|
227
|
+
case '--allow-pooler':
|
|
228
|
+
result.allowPooler = true;
|
|
229
|
+
break;
|
|
225
230
|
case '--zod':
|
|
226
231
|
result.zod = true;
|
|
227
232
|
break;
|
|
@@ -2674,9 +2679,46 @@ const CONCURRENTLY_RECIPE_COMMENT = [
|
|
|
2674
2679
|
'-- makes "migrate up" look hung. For bounded waits, SET lock_timeout /',
|
|
2675
2680
|
'-- statement_timeout in a psql session.',
|
|
2676
2681
|
].join('\n');
|
|
2682
|
+
/**
|
|
2683
|
+
* Refuse a `doctor` run whose connection string points at a transaction pooler.
|
|
2684
|
+
*
|
|
2685
|
+
* Called BEFORE anything opens a connection, which is the whole point: the
|
|
2686
|
+
* refusal has to happen while the only thing anybody has touched is a string.
|
|
2687
|
+
*
|
|
2688
|
+
* Doctor is a diagnostic command that reads statistics and reasons about
|
|
2689
|
+
* cached plans, and both of those arguments assume the session it is talking to
|
|
2690
|
+
* is its own. Through a pooler it is not: session state this process sets can be
|
|
2691
|
+
* left on a shared backend for someone else's queries, and the session-scoped
|
|
2692
|
+
* view the report's own remediation tells the reader to inspect
|
|
2693
|
+
* (`pg_prepared_statements`) belongs to whichever backend happened to answer.
|
|
2694
|
+
* Refusing is the honest outcome; reporting confidently over a connection whose
|
|
2695
|
+
* session semantics do not hold is not.
|
|
2696
|
+
*
|
|
2697
|
+
* Written to STDERR, including in `--json` mode, where stdout is contracted to
|
|
2698
|
+
* carry the report and nothing else.
|
|
2699
|
+
*
|
|
2700
|
+
* Exported for the unit test only (like {@link isLoopbackHost}): a gate whose
|
|
2701
|
+
* failure path is `process.exit` is untestable through the command itself.
|
|
2702
|
+
*/
|
|
2703
|
+
function refusePoolerConnection(url, args) {
|
|
2704
|
+
if (args.allowPooler === true)
|
|
2705
|
+
return;
|
|
2706
|
+
const detection = (0, connection_url_js_1.detectPooler)(url);
|
|
2707
|
+
if (!detection.pooled)
|
|
2708
|
+
return;
|
|
2709
|
+
const lines = (0, connection_url_js_1.poolerRefusalMessage)(detection, { command: 'turbine doctor', allowFlag: '--allow-pooler' });
|
|
2710
|
+
console.error('');
|
|
2711
|
+
console.error(` ${(0, ui_js_1.red)(ui_js_1.symbols.cross)} ${lines[0]}`);
|
|
2712
|
+
for (const line of lines.slice(1))
|
|
2713
|
+
console.error(line === '' ? '' : ` ${line}`);
|
|
2714
|
+
console.error('');
|
|
2715
|
+
process.exit(1);
|
|
2716
|
+
}
|
|
2677
2717
|
async function cmdDoctor(args, config) {
|
|
2678
2718
|
const jsonMode = args.json === true;
|
|
2679
2719
|
const url = requireUrl(config);
|
|
2720
|
+
// Before the banner, before introspect, before any pool is constructed.
|
|
2721
|
+
refusePoolerConnection(url, args);
|
|
2680
2722
|
if (!jsonMode) {
|
|
2681
2723
|
(0, ui_js_1.banner)();
|
|
2682
2724
|
(0, ui_js_1.label)('Database', (0, ui_js_1.redactUrl)(url));
|
|
@@ -3862,6 +3904,12 @@ function showDoctorHelp() {
|
|
|
3862
3904
|
console.log(` ${(0, ui_js_1.cyan)('--min-scans')} ${(0, ui_js_1.dim)('<n>')} idx_scan below this counts as never-scanned ${(0, ui_js_1.dim)('(default: 1)')}`);
|
|
3863
3905
|
console.log(` ${(0, ui_js_1.cyan)('--metrics-url')} ${(0, ui_js_1.dim)('<url>')} Read ${(0, ui_js_1.cyan)('_turbine_metrics')} for the table-heat boost from a separate DB`);
|
|
3864
3906
|
console.log(` ${(0, ui_js_1.cyan)('--no-plan-divergence')} Skip the cached-plan divergence section ${(0, ui_js_1.dim)('(and its pg_stats read)')}`);
|
|
3907
|
+
console.log(` ${(0, ui_js_1.cyan)('--allow-pooler')} Run against a connection pooler anyway ${(0, ui_js_1.dim)('(refused by default)')}`);
|
|
3908
|
+
(0, ui_js_1.newline)();
|
|
3909
|
+
console.log(` ${(0, ui_js_1.dim)('doctor refuses a transaction-pooling endpoint (a')} ${(0, ui_js_1.cyan)('-pooler')} ${(0, ui_js_1.dim)('or')} ${(0, ui_js_1.cyan)('pgbouncer')}`);
|
|
3910
|
+
console.log(` ${(0, ui_js_1.dim)('host, or port 6543). Point it at the direct endpoint instead: a pooler')}`);
|
|
3911
|
+
console.log(` ${(0, ui_js_1.dim)('shares one server backend between clients, so neither the session state')}`);
|
|
3912
|
+
console.log(` ${(0, ui_js_1.dim)('doctor needs nor the session-scoped views it cites belong to it.')}`);
|
|
3865
3913
|
(0, ui_js_1.newline)();
|
|
3866
3914
|
console.log(` ${(0, ui_js_1.bold)('Examples:')}`);
|
|
3867
3915
|
console.log(` ${(0, ui_js_1.dim)('$')} npx turbine doctor`);
|