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.
Files changed (46) hide show
  1. package/README.md +164 -1041
  2. package/dist/cjs/cli/compile-query.d.ts +198 -0
  3. package/dist/cjs/cli/compile-query.js +529 -0
  4. package/dist/cjs/cli/index.d.ts +25 -1
  5. package/dist/cjs/cli/index.js +49 -1
  6. package/dist/cjs/cli/mcp.js +198 -16
  7. package/dist/cjs/client.d.ts +45 -10
  8. package/dist/cjs/client.js +21 -3
  9. package/dist/cjs/connection-url.d.ts +160 -0
  10. package/dist/cjs/connection-url.js +296 -0
  11. package/dist/cjs/index-stats.d.ts +4 -1
  12. package/dist/cjs/index-stats.js +27 -11
  13. package/dist/cjs/index.d.ts +1 -1
  14. package/dist/cjs/plan-flip-probe.js +17 -1
  15. package/dist/cjs/powql.d.ts +1 -0
  16. package/dist/cjs/powql.js +9 -0
  17. package/dist/cjs/query/builder.d.ts +133 -2
  18. package/dist/cjs/query/builder.js +288 -64
  19. package/dist/cjs/query/deferred.d.ts +12 -6
  20. package/dist/cjs/query/index.d.ts +1 -1
  21. package/dist/cjs/query/option-surface.js +6 -0
  22. package/dist/cjs/query/types.d.ts +47 -0
  23. package/dist/cjs/query/where.d.ts +11 -2
  24. package/dist/cli/compile-query.d.ts +198 -0
  25. package/dist/cli/compile-query.js +522 -0
  26. package/dist/cli/index.d.ts +25 -1
  27. package/dist/cli/index.js +48 -1
  28. package/dist/cli/mcp.js +198 -16
  29. package/dist/client.d.ts +45 -10
  30. package/dist/client.js +19 -1
  31. package/dist/connection-url.d.ts +160 -0
  32. package/dist/connection-url.js +289 -0
  33. package/dist/index-stats.d.ts +4 -1
  34. package/dist/index-stats.js +27 -11
  35. package/dist/index.d.ts +1 -1
  36. package/dist/plan-flip-probe.js +17 -1
  37. package/dist/powql.d.ts +1 -0
  38. package/dist/powql.js +9 -0
  39. package/dist/query/builder.d.ts +133 -2
  40. package/dist/query/builder.js +288 -64
  41. package/dist/query/deferred.d.ts +12 -6
  42. package/dist/query/index.d.ts +1 -1
  43. package/dist/query/option-surface.js +6 -0
  44. package/dist/query/types.d.ts +47 -0
  45. package/dist/query/where.d.ts +11 -2
  46. 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
+ }
@@ -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.
@@ -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`);