turbine-orm 0.55.0 → 0.57.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 (52) hide show
  1. package/README.md +16 -1
  2. package/dist/cjs/cli/index.d.ts +3 -1
  3. package/dist/cjs/cli/index.js +341 -14
  4. package/dist/cjs/client.d.ts +16 -1
  5. package/dist/cjs/client.js +7 -39
  6. package/dist/cjs/dialect.d.ts +9 -0
  7. package/dist/cjs/index-stats.d.ts +46 -0
  8. package/dist/cjs/index-stats.js +42 -1
  9. package/dist/cjs/plan-divergence.d.ts +511 -0
  10. package/dist/cjs/plan-divergence.js +790 -0
  11. package/dist/cjs/powql.d.ts +11 -0
  12. package/dist/cjs/powql.js +22 -0
  13. package/dist/cjs/prisma-compat.d.ts +32 -1
  14. package/dist/cjs/prisma-compat.js +297 -41
  15. package/dist/cjs/query/builder.d.ts +45 -0
  16. package/dist/cjs/query/builder.js +90 -17
  17. package/dist/cjs/query/deferred.d.ts +9 -0
  18. package/dist/cjs/query/index.d.ts +2 -0
  19. package/dist/cjs/query/index.js +18 -1
  20. package/dist/cjs/query/option-surface.d.ts +100 -0
  21. package/dist/cjs/query/option-surface.js +214 -0
  22. package/dist/cjs/query/types.d.ts +140 -0
  23. package/dist/cjs/query/utils.d.ts +30 -0
  24. package/dist/cjs/query/utils.js +67 -3
  25. package/dist/cjs/query/warn-registry.d.ts +8 -0
  26. package/dist/cjs/query/warn-registry.js +8 -0
  27. package/dist/cli/index.d.ts +3 -1
  28. package/dist/cli/index.js +341 -14
  29. package/dist/client.d.ts +16 -1
  30. package/dist/client.js +8 -40
  31. package/dist/dialect.d.ts +9 -0
  32. package/dist/index-stats.d.ts +46 -0
  33. package/dist/index-stats.js +42 -1
  34. package/dist/plan-divergence.d.ts +511 -0
  35. package/dist/plan-divergence.js +783 -0
  36. package/dist/powql.d.ts +11 -0
  37. package/dist/powql.js +22 -0
  38. package/dist/prisma-compat.d.ts +32 -1
  39. package/dist/prisma-compat.js +297 -41
  40. package/dist/query/builder.d.ts +45 -0
  41. package/dist/query/builder.js +90 -17
  42. package/dist/query/deferred.d.ts +9 -0
  43. package/dist/query/index.d.ts +2 -0
  44. package/dist/query/index.js +1 -0
  45. package/dist/query/option-surface.d.ts +100 -0
  46. package/dist/query/option-surface.js +209 -0
  47. package/dist/query/types.d.ts +140 -0
  48. package/dist/query/utils.d.ts +30 -0
  49. package/dist/query/utils.js +66 -3
  50. package/dist/query/warn-registry.d.ts +8 -0
  51. package/dist/query/warn-registry.js +8 -0
  52. package/package.json +1 -1
package/README.md CHANGED
@@ -454,7 +454,7 @@ const db = turbine({
454
454
 
455
455
  Where a pg-style alias exists (`max`, `idleTimeoutMillis`, `connectionTimeoutMillis`), the explicit Turbine field wins when both are set.
456
456
 
457
- > **`planCacheMode` (Postgres only, opt-in).** PostgreSQL may promote a **named** prepared statement to a generic plan from its sixth execution onward, and a generic plan is costed blind to the bound values. On a predicate whose selectivity swings per value (a `tenant_id` equality on a shared table, where one value matches a handful of rows and another matches most of them), the statement can be locked onto a plan chosen for the average value, and it never reverts. `planCacheMode: 'auto' | 'force_custom_plan' | 'force_generic_plan'` pins the backend's choice; `'force_custom_plan'` re-plans every execution and removes the cliff. It is applied as a connection parameter (`options=-c plan_cache_mode=...`) when Turbine opens a connection, so it is in force for that connection's first statement and for every checkout, `$transaction`, stream and pipeline on it, and it cannot race your first query. Leave it unset (the default) and Turbine sends nothing at all. Reach for it when you have measured a statement getting slower after its fifth execution. **Correction to the 0.54 text, which said `findMany` / `findFirst` bind `LIMIT $n` and are "much less exposed":** that was false. PostgreSQL does not deny the planner a limit fraction for a bound limit, it substitutes a default of 10% of the child node's own row estimate (clamped at one row), and an unknown `OFFSET` triggers the same substitution even when the limit is a constant, which a paginated Turbine read always has. Two things also need saying about the sentence that opens this note. The sixth execution is a ceiling, not a trigger: `auto` promotes only when the generic plan's **estimated** cost is not worse than the average custom cost, so many statements are never promoted at all, and `pg_prepared_statements.generic_plans` is how you tell. And the shape that gets promoted unprompted is the one with **no limit**, not the limited one: measured on a skewed join predicate, an unlimited `count()`-shaped statement promoted under the default `auto` and ran a nested loop at 430x the buffers of the custom plan, while the same predicate under `LIMIT $n` was never promoted across eight executions (its substituted row count made the generic plan look more expensive). A limited `findMany` gives the planner two unknowns instead of one, which is not the same as more damage. `implicitPkOrdering` is **off by default in core**, so a default `findMany` emits no `ORDER BY`; switching it on adds an ordering a generic plan can walk the whole table in. Measure with `plan_cache_mode = force_generic_plan` against `force_custom_plan` rather than reasoning about which shapes ought to be safe; the fixtures and numbers are on the [relations page](https://turbineorm.dev/relations) and in the 0.55.0 changelog. Three scope limits: it does nothing on an **external pool** (Turbine never opens those connections, so set the GUC in the driver's own setup; Turbine-owned string `replicas` on that same client DO get it); a Postgres wire-compatible engine without the setting (CockroachDB, YugabyteDB, pre-12 PostgreSQL) refuses the connection parameter itself; and a **connection pooler** may filter startup parameters (PgBouncer's `ignore_startup_parameters`), where `ALTER ROLE ... SET plan_cache_mode = ...` is the way in. Any value outside the three throws `ValidationError` at construction, and a non-Postgres engine throws `UnsupportedFeatureError` (`TURBINE_E017`).
457
+ > **`planCacheMode` (Postgres only, opt-in).** PostgreSQL may promote a **named** prepared statement to a generic plan from its sixth execution onward, and a generic plan is costed blind to the bound values. On a predicate whose selectivity swings per value (a `tenant_id` equality on a shared table, where one value matches a handful of rows and another matches most of them), the statement can be locked onto a plan chosen for the average value, and it never reverts. `planCacheMode: 'auto' | 'force_custom_plan' | 'force_generic_plan'` pins the backend's choice; `'force_custom_plan'` re-plans every execution and removes the cliff. It is applied as a connection parameter (`options=-c plan_cache_mode=...`) when Turbine opens a connection, so it is in force for that connection's first statement and for every checkout, `$transaction`, stream and pipeline on it, and it cannot race your first query. Leave it unset (the default) and Turbine sends nothing at all. Reach for it when you have measured a statement getting slower after its fifth execution. **Correction to the 0.54 text, which said `findMany` / `findFirst` bind `LIMIT $n` and are "much less exposed":** that was false. PostgreSQL does not deny the planner a limit fraction for a bound limit, it substitutes a default of 10% of the child node's own row estimate (clamped at one row), and an unknown `OFFSET` triggers the same substitution even when the limit is a constant, which a paginated Turbine read always has. Two things also need saying about the sentence that opens this note. The sixth execution is a ceiling, not a trigger: `auto` promotes only when the generic plan's **estimated** cost is not worse than the average custom cost, so many statements are never promoted at all, and `pg_prepared_statements.generic_plans` is how you tell. And the shape that gets promoted unprompted is the one with **no limit**, not the limited one: measured on a skewed join predicate, an unlimited `count()`-shaped statement promoted under the default `auto` and ran a nested loop at 430x the buffers of the custom plan, while the same predicate under `LIMIT $n` was never promoted across eight executions (its substituted row count made the generic plan look more expensive). A limited `findMany` gives the planner two unknowns instead of one, which is not the same as more damage. `implicitPkOrdering` is **off by default in core**, so a default `findMany` emits no `ORDER BY`; switching it on adds an ordering a generic plan can walk the whole table in. Measure with `plan_cache_mode = force_generic_plan` against `force_custom_plan` rather than reasoning about which shapes ought to be safe; the fixtures and numbers are on the [relations page](https://turbineorm.dev/relations) and in the 0.55.0 changelog. **Two 0.56 corrections to the paragraph above.** First, "neither an ORDER BY nor a limit is required" is true, but it read as if ordering did not matter: on a real multi-tenant schema swept table by table, every divergent shape had an `ORDER BY` and every shape without one measured 1.00x, so it is not necessary in general and is still the strongest single predictor in practice. Second, a custom plan is not automatically the better one: on a reproducible fixture where one dense value's rows are packed at the end of the heap, `LIMIT 20` with no ordering reads 4,262 buffers custom against 71 generic (the default `auto` never promotes there, so it produces the 4,262-buffer plan too). Since 0.56 the per-query read arg **`forceCustomPlan: true`** covers the case a connection-wide setting cannot express, custom on one query and `auto` everywhere else, and `turbine doctor` detects the distribution that admits the flip. **0.57 correction:** that read arg reached the core client only. Through `turbine-orm/prisma-compat` it was accepted and silently dropped until 0.57.0, so a compat integration that followed this advice got a no-op; confirm at the wire with `pg_prepared_statements` rather than assuming. 0.57 also adds a third divergence mechanism to `doctor`: an **unindexed** filter column, where the good plan is a sequential scan the generic plan will not choose (measured 250 buffers against 20,074 on a 20,000-row / 247-page fixture). Three scope limits: it does nothing on an **external pool** (Turbine never opens those connections, so set the GUC in the driver's own setup; Turbine-owned string `replicas` on that same client DO get it); a Postgres wire-compatible engine without the setting (CockroachDB, YugabyteDB, pre-12 PostgreSQL) refuses the connection parameter itself; and a **connection pooler** may filter startup parameters (PgBouncer's `ignore_startup_parameters`), where `ALTER ROLE ... SET plan_cache_mode = ...` is the way in. Any value outside the three throws `ValidationError` at construction, and a non-Postgres engine throws `UnsupportedFeatureError` (`TURBINE_E017`).
458
458
 
459
459
  > **`preparedStatements` and connection poolers.** With prepared statements on, Turbine submits queries as `{ name, text, values }` so Postgres caches the parse and plan **per backend connection**. That is a real win against a database you connect to directly, and a hazard behind a transaction-pooling proxy (PgBouncer in `transaction` mode, Supabase's pooler port, some serverless poolers): the named statement is prepared on one backend and your next query may land on another, which fails with `prepared statement "..." does not exist`. Turbine defaults it to `true` only for pools it creates itself and `false` for external pools passed via `pool` / `turbineHttp()`, because serverless drivers are the common case there. If you are pointing a Turbine-owned pool at a transaction pooler, set `preparedStatements: false`. The environment variable `TURBINE_DISABLE_PREPARED=1` turns it off globally without a code change.
460
460
 
@@ -1038,6 +1038,21 @@ const prisma = createPrismaCompatClient(db, PRISMA_MAP);
1038
1038
  const users = await prisma.User.findMany({ include: { posts: { take: 5 } } });
1039
1039
  ```
1040
1040
 
1041
+ Turbine-only query options (`forceCustomPlan`, `skipGlobalFilters`, `allowFullTableScan`,
1042
+ `warnOnUnlimited`, `timeout`, `optimisticLock`, `distinctOn`, …) pass through the compat
1043
+ delegates, and an unrecognized query-level key logs a one-time dev warning naming the
1044
+ nearest real option instead of being dropped.
1045
+
1046
+ > **Correction, 0.57.0.** Before that release the adapter copied a hand-written allowlist
1047
+ > of keys, so those options were accepted by the type-checker and silently dropped. That
1048
+ > includes `forceCustomPlan`, which 0.56.0 shipped and `turbine doctor` recommended: on
1049
+ > prisma-compat it did nothing at all, while working exactly as documented on the core
1050
+ > client. Two changes are visible on upgrade even if you change nothing: `skipGlobalFilters`
1051
+ > now takes effect where it was inert, and `relationLoadStrategy: 'query'` now maps to
1052
+ > Turbine's `'batched'` instead of silently using the join plan. The option surface is now
1053
+ > compiler-checked against the core argument interfaces, so a newly added core option fails
1054
+ > the build in the adapter rather than being stranded in silence.
1055
+
1041
1056
  ### Capability matrix
1042
1057
 
1043
1058
  Everything is honest about what ports and what doesn't. Features marked **PG-only** throw a typed `UnsupportedFeatureError` (`TURBINE_E017`) on other engines rather than silently degrading.
@@ -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 - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently, --unused, --audit)
17
+ * turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence)
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)
@@ -61,6 +61,8 @@ export interface CliArgs {
61
61
  minScans?: number;
62
62
  /** `doctor --metrics-url <url>`: read _turbine_metrics for the table-heat boost from a separate DB. */
63
63
  metricsUrl?: string;
64
+ /** `doctor --no-plan-divergence`: skip the cached-plan divergence section (and its pg_stats read). */
65
+ noPlanDivergence?: boolean;
64
66
  /** `init --yes`/`-y`: accept every step's default non-interactively. */
65
67
  yes?: boolean;
66
68
  /** `init --skip-schema`: don't scaffold the schema file. */
@@ -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 - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently, --unused, --audit)
18
+ * turbine doctor - Index + cached-plan triage (--fix, --json, --no-concurrently, --unused, --audit, --no-plan-divergence)
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)
@@ -82,6 +82,8 @@ const generate_js_1 = require("../generate.js");
82
82
  const index_advisor_js_1 = require("../index-advisor.js");
83
83
  const index_stats_js_1 = require("../index-stats.js");
84
84
  const introspect_js_1 = require("../introspect.js");
85
+ const plan_divergence_js_1 = require("../plan-divergence.js");
86
+ const schema_js_1 = require("../schema.js");
85
87
  const schema_sql_js_1 = require("../schema-sql.js");
86
88
  const config_js_1 = require("./config.js");
87
89
  const destructive_js_1 = require("./destructive.js");
@@ -192,6 +194,9 @@ function parseArgs(argv = process.argv.slice(2)) {
192
194
  result.metricsUrl = next;
193
195
  i++;
194
196
  break;
197
+ case '--no-plan-divergence':
198
+ result.noPlanDivergence = true;
199
+ break;
195
200
  case '--zod':
196
201
  result.zod = true;
197
202
  break;
@@ -2309,20 +2314,33 @@ async function cmdDoctor(args, config) {
2309
2314
  const missing = (0, index_advisor_js_1.findMissingRelationIndexes)(schema);
2310
2315
  // Collect live statistics. The collector reads whole-schema indexes (for
2311
2316
  // invalid-index detection) plus per-table stats + probed-column null_frac.
2312
- const probedTables = [...new Set(missing.map((m) => m.table))];
2313
2317
  const probedColumns = [];
2314
2318
  for (const m of missing) {
2315
2319
  if (m.columns.length === 1 && m.columns[0] !== undefined) {
2316
2320
  probedColumns.push({ table: m.table, column: m.columns[0] });
2317
2321
  }
2318
2322
  }
2323
+ // Plan-divergence candidates are the columns that ARE indexed, so their tables
2324
+ // are usually disjoint from the missing-index set: both lists feed the same
2325
+ // one-connection snapshot rather than opening a second read.
2326
+ const divergenceOn = args.noPlanDivergence !== true;
2327
+ const divergenceColumns = divergenceOn ? (0, plan_divergence_js_1.collectDivergenceCandidateColumns)(schema) : [];
2328
+ // The columns a finding could ORDER BY, read alongside the candidates: the
2329
+ // size of an unindexed-filter flip turns on the ORDER column's correlation,
2330
+ // not the filter column's, and reading only the latter is how an earlier
2331
+ // revision printed a statistic about the wrong column.
2332
+ const divergenceOrderColumns = divergenceOn ? (0, plan_divergence_js_1.collectDivergenceOrderColumns)(schema) : [];
2333
+ const probedTables = [...new Set(missing.map((m) => m.table))];
2334
+ const statsTables = [...new Set([...probedTables, ...divergenceColumns.map((c) => c.table)])];
2335
+ const distributionColumns = [...divergenceColumns, ...divergenceOrderColumns];
2319
2336
  let snapshot;
2320
2337
  try {
2321
2338
  snapshot = await (0, index_stats_js_1.collectStatsSnapshot)({
2322
2339
  connectionString: url,
2323
2340
  schema: config.schema,
2324
- tables: probedTables,
2341
+ tables: statsTables,
2325
2342
  columns: probedColumns,
2343
+ distributionColumns,
2326
2344
  });
2327
2345
  }
2328
2346
  catch (err) {
@@ -2361,9 +2379,16 @@ async function cmdDoctor(args, config) {
2361
2379
  ? (0, index_stats_js_1.auditDoctorIndexes)(snapshot, (0, index_advisor_js_1.collectDoctorProbeIndexNames)(schema), { minScans, relationProbes })
2362
2380
  : [];
2363
2381
  const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
2382
+ // Plan divergence has its OWN freshness gate. The cost tiers require a
2383
+ // trustworthy stats_reset age because they normalize write counters by it;
2384
+ // this check reads no counter, only pg_stats, whose freshness is ANALYZE. A
2385
+ // cluster with a NULL stats_reset (the default) must still get the check.
2386
+ const divergence = divergenceOn && snapshot.available
2387
+ ? (0, plan_divergence_js_1.findPlanDivergence)(schema, snapshot)
2388
+ : { findings: [], notices: [], candidatesConsidered: 0, consideredIndexed: 0, consideredUnindexed: 0 };
2364
2389
  if (jsonMode) {
2365
2390
  spinner?.stop();
2366
- console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, args }), null, 2));
2391
+ console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, divergence, args }), null, 2));
2367
2392
  return;
2368
2393
  }
2369
2394
  await renderDoctorHuman({
@@ -2375,6 +2400,7 @@ async function cmdDoctor(args, config) {
2375
2400
  usable,
2376
2401
  heat,
2377
2402
  subtract,
2403
+ divergence,
2378
2404
  args,
2379
2405
  config,
2380
2406
  });
@@ -2442,13 +2468,25 @@ function buildDoctorJson(ctx) {
2442
2468
  out.redundant = ctx.subtract.unusedRan ? ctx.subtract.redundant : [];
2443
2469
  out.audit = ctx.subtract.auditRan ? ctx.subtract.audit : [];
2444
2470
  out.invalid = ctx.invalid;
2471
+ // Always an array, never absent: a consumer must not have to write `?? []`
2472
+ // just because the section was skipped or found nothing.
2473
+ out.planDivergence = ctx.divergence.findings;
2474
+ out.planDivergenceNotices = ctx.divergence.notices;
2475
+ // How large the scored population was, and how it split. A consumer counting
2476
+ // findings alone cannot tell "considered and clean" from "never looked", and
2477
+ // the unindexed half of that population did not exist before.
2478
+ out.planDivergenceScored = {
2479
+ considered: ctx.divergence.candidatesConsidered,
2480
+ indexed: ctx.divergence.consideredIndexed,
2481
+ unindexed: ctx.divergence.consideredUnindexed,
2482
+ };
2445
2483
  return out;
2446
2484
  }
2447
2485
  async function renderDoctorHuman(ctx) {
2448
- const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, args, config } = ctx;
2486
+ const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, divergence, args, config } = ctx;
2449
2487
  spinner.succeed(`Scanned ${(0, ui_js_1.bold)(String(Object.keys(schema.tables).length))} tables`);
2450
2488
  const subtractRan = subtract.unusedRan || subtract.auditRan;
2451
- const nothingToAdd = findings.length === 0 && invalid.length === 0;
2489
+ const nothingToAdd = findings.length === 0 && invalid.length === 0 && divergence.findings.length === 0;
2452
2490
  const nothingToSubtract = subtract.unused.length === 0 && subtract.redundant.length === 0 && subtract.audit.length === 0;
2453
2491
  if (nothingToAdd && (!subtractRan || nothingToSubtract)) {
2454
2492
  if (subtractRan) {
@@ -2460,6 +2498,11 @@ async function renderDoctorHuman(ctx) {
2460
2498
  (0, ui_js_1.newline)();
2461
2499
  return;
2462
2500
  }
2501
+ // One column, one place. An unindexed filter column that ALSO diverges is one
2502
+ // problem with one remedy (the index), so the divergence evidence renders as
2503
+ // an extra block on the missing-index finding rather than as a second,
2504
+ // unrelated-looking entry in the cached-plan section.
2505
+ const attached = attachDivergenceToMissingIndexes(findings, divergence);
2463
2506
  if (findings.length > 0) {
2464
2507
  (0, ui_js_1.warn)(`Found ${(0, ui_js_1.bold)(String(findings.length))} unindexed relation probe(s)`);
2465
2508
  (0, ui_js_1.newline)();
@@ -2467,10 +2510,10 @@ async function renderDoctorHuman(ctx) {
2467
2510
  console.log(` ${(0, ui_js_1.dim)('once per parent row, so an unindexed FK costs a full table scan PER PARENT.')}`);
2468
2511
  (0, ui_js_1.newline)();
2469
2512
  if (usable) {
2470
- renderTiers(findings, snapshot, args);
2513
+ renderTiers(findings, snapshot, args, attached);
2471
2514
  }
2472
2515
  else {
2473
- renderTopologyFallback(findings, snapshot);
2516
+ renderTopologyFallback(findings, snapshot, attached);
2474
2517
  }
2475
2518
  // Heat honesty: one line when the workload-heat boost could not be sourced.
2476
2519
  if (!heat.available && heat.notice) {
@@ -2479,6 +2522,7 @@ async function renderDoctorHuman(ctx) {
2479
2522
  }
2480
2523
  }
2481
2524
  renderInvalidIndexes(invalid);
2525
+ renderPlanDivergence(divergence, attached);
2482
2526
  if (subtract.unusedRan) {
2483
2527
  renderUnusedIndexes(subtract.unused, subtract.minScans, snapshot);
2484
2528
  renderRedundantIndexes(subtract.redundant);
@@ -2575,8 +2619,96 @@ function renderDoctorAudit(audit, minScans, snapshot) {
2575
2619
  console.log(` ${(0, ui_js_1.dim)('Consider dropping the ones you confirm are unused. Nothing here is auto-dropped.')}`);
2576
2620
  (0, ui_js_1.newline)();
2577
2621
  }
2622
+ function attachDivergenceToMissingIndexes(findings, divergence) {
2623
+ const byColumn = new Map();
2624
+ for (const d of divergence.findings) {
2625
+ if (d.branch !== 'unindexed-filter')
2626
+ continue;
2627
+ byColumn.set(`${d.table}\u0000${d.column}`, d);
2628
+ }
2629
+ const attached = new Map();
2630
+ for (const f of findings) {
2631
+ // Single-column probes only: a composite probe's index is not the thing the
2632
+ // single-column divergence model reasons about.
2633
+ if (f.missing.columns.length !== 1 || f.missing.columns[0] === undefined)
2634
+ continue;
2635
+ const key = `${f.missing.table}\u0000${f.missing.columns[0]}`;
2636
+ const d = byColumn.get(key);
2637
+ if (d)
2638
+ attached.set(key, d);
2639
+ }
2640
+ return attached;
2641
+ }
2642
+ /** The key a missing-index finding is looked up by in {@link AttachedDivergence}. */
2643
+ function attachKey(f) {
2644
+ return `${f.missing.table}\u0000${f.missing.columns[0] ?? ''}`;
2645
+ }
2646
+ /**
2647
+ * The cached-plan evidence block printed UNDER a missing-index finding.
2648
+ *
2649
+ * It never recommends `forceCustomPlan`: the remedy is the index the same
2650
+ * finding already prints, and recommending a per-query plan-cache override for a
2651
+ * missing index would be advice to paper over a table scan.
2652
+ */
2653
+ /**
2654
+ * How big an `unindexed-filter` flip is, and under what condition, as plain
2655
+ * lines both branch-B renderers print.
2656
+ *
2657
+ * The condition is not decoration. The generic plan's cost is one heap fetch per
2658
+ * index entry, so the ratio is the table's rows-per-page when the heap is not in
2659
+ * `orderColumn` order and ~1x when it is: 80x and 1.2x on two fixtures identical
2660
+ * in every scored input. An earlier revision printed the ratio unconditionally
2661
+ * and quoted the FILTER column's correlation next to a sentence about the ORDER
2662
+ * column's physical order, so the one field offered as the reader's escape hatch
2663
+ * was measured on the wrong column.
2664
+ */
2665
+ function divergenceAmplificationLines(d) {
2666
+ const amp = divInt(d.worstCaseAmplification ?? 0);
2667
+ const corr = d.orderColumnCorrelation;
2668
+ const corrLabel = corr === null || corr === undefined
2669
+ ? `no pg_stats correlation available for "${d.orderColumn}"`
2670
+ : `correlation ${corr.toFixed(5)} on "${d.orderColumn}"`;
2671
+ if (d.heapNearlyOrdered === true) {
2672
+ return [
2673
+ `The size of that flip turns on the heap's physical order, and THIS heap is in near-exact`,
2674
+ `"${d.orderColumn}" order (${corrLabel}), so consecutive index entries hit the`,
2675
+ `same pinned page: measured ~1x, not the ~${amp}x an unordered heap reads. Most likely this`,
2676
+ `one is not costing you anything today. It is also one sampled statistic away from the`,
2677
+ `much worse reading, so measure rather than assume in either direction.`,
2678
+ ];
2679
+ }
2680
+ return [
2681
+ `That costs ~${amp}x the buffers of the seq scan, because each index entry is its own heap`,
2682
+ `fetch (${corrLabel}). The one shape that reads ~1x instead is a heap`,
2683
+ `in near-exact "${d.orderColumn}" order; two pages of local disorder already reads ~41x.`,
2684
+ ];
2685
+ }
2686
+ function renderDivergenceEvidence(d) {
2687
+ const tuples = divInt(d.tuplesWalked ?? d.rows);
2688
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.yellow)('cached-plan risk:')} this unindexed filter column can also flip a cached plan.`);
2689
+ console.log(` ${(0, ui_js_1.dim)(`${divInt(d.rows)} rows in ${divInt(d.pages)} pages, rarest value ~${divInt(d.rarestBucket)} rows, below the assumed LIMIT ${d.assumedLimit}.`)}`);
2690
+ console.log(` ${(0, ui_js_1.dim)(`Without the index the good plan is a seq scan (${divInt(d.pages)} pages); a promoted generic`)}`);
2691
+ console.log(` ${(0, ui_js_1.dim)(`plan cannot see the value is rare, keeps the ordered "${d.orderColumn}" walk, and reads`)}`);
2692
+ console.log(` ${(0, ui_js_1.dim)(`up to ~${tuples} tuples before it fills the LIMIT.`)}`);
2693
+ console.log(` ${(0, ui_js_1.dim)('Postgres promotes this shape exactly when the workload keeps asking for the rare')}`);
2694
+ console.log(` ${(0, ui_js_1.dim)('value: that is the case where the custom plan is expensive enough for the generic')}`);
2695
+ console.log(` ${(0, ui_js_1.dim)('estimate to look cheaper.')}`);
2696
+ for (const line of divergenceAmplificationLines(d))
2697
+ console.log(` ${(0, ui_js_1.dim)(line)}`);
2698
+ console.log(` ${(0, ui_js_1.dim)('Adding the index above is the fix. Confirm first if you want to:')}`);
2699
+ for (const line of d.diagnosticSql.split('\n')) {
2700
+ console.log(` ${(0, ui_js_1.green)(line)}`);
2701
+ }
2702
+ console.log(` ${(0, ui_js_1.dim)('After adding this index, re-run doctor. This column is expected to reappear as a')}`);
2703
+ console.log(` ${(0, ui_js_1.dim)('sparse-value finding in the cached-plan section. That later finding is exposure, not')}`);
2704
+ console.log(` ${(0, ui_js_1.dim)('a regression: the index makes the good plan much cheaper, which is why the ratio it')}`);
2705
+ console.log(` ${(0, ui_js_1.dim)('quotes is larger, and on a measured fixture it is also what stops Postgres from')}`);
2706
+ console.log(` ${(0, ui_js_1.dim)('promoting the generic plan at all. Treat the reappearance as the normal end state;')}`);
2707
+ console.log(` ${(0, ui_js_1.dim)("use the diagnostic block's generic_plans counter to decide whether anything more is")}`);
2708
+ console.log(` ${(0, ui_js_1.dim)('warranted.')}`);
2709
+ }
2578
2710
  /** Cost-aware tiered output: three sections, each finding annotated with its numbers. */
2579
- function renderTiers(findings, snapshot, _args) {
2711
+ function renderTiers(findings, snapshot, _args, attached) {
2580
2712
  const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
2581
2713
  console.log(` ${(0, ui_js_1.dim)(`Cost triage based on live statistics (stats reset ${ageLabel} ago). Thresholds: tiny < ${index_stats_js_1.STATS_THRESHOLDS.tinyTableRows.toLocaleString()} rows,`)}`);
2582
2714
  console.log(` ${(0, ui_js_1.dim)(`"real" write rate >= ${index_stats_js_1.STATS_THRESHOLDS.highWritesPerDay.toLocaleString()}/day, "many" indexes >= ${index_stats_js_1.STATS_THRESHOLDS.manyIndexes}.`)}`);
@@ -2595,12 +2727,12 @@ function renderTiers(findings, snapshot, _args) {
2595
2727
  console.log(` ${(0, ui_js_1.bold)(tierColor[tier](`${TIER_LABEL[tier]} (${inTier.length})`))}`);
2596
2728
  (0, ui_js_1.newline)();
2597
2729
  for (const f of inTier) {
2598
- renderFinding(f, { concurrently: true, withReasons: true });
2730
+ renderFinding(f, { concurrently: true, withReasons: true, divergence: attached.get(attachKey(f)) });
2599
2731
  }
2600
2732
  }
2601
2733
  }
2602
2734
  /** Degraded output: today's size-sorted topology report when stats are absent/young. */
2603
- function renderTopologyFallback(findings, snapshot) {
2735
+ function renderTopologyFallback(findings, snapshot, attached) {
2604
2736
  (0, ui_js_1.warn)('Statistics unavailable or too young to score cost: showing size-sorted topology only.');
2605
2737
  for (const notice of snapshot.notices)
2606
2738
  console.log(` ${(0, ui_js_1.dim)(`- ${notice}`)}`);
@@ -2610,7 +2742,7 @@ function renderTopologyFallback(findings, snapshot) {
2610
2742
  (0, ui_js_1.newline)();
2611
2743
  const sorted = [...findings].sort((a, b) => (b.score.metrics.rows ?? 0) - (a.score.metrics.rows ?? 0));
2612
2744
  for (const f of sorted) {
2613
- renderFinding(f, { concurrently: false, withReasons: false });
2745
+ renderFinding(f, { concurrently: false, withReasons: false, divergence: attached.get(attachKey(f)) });
2614
2746
  }
2615
2747
  }
2616
2748
  /** Render one finding: table + columns, probing relations, reasons, and the create SQL. */
@@ -2628,7 +2760,10 @@ function renderFinding(f, opts) {
2628
2760
  console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)(reason)}`);
2629
2761
  }
2630
2762
  }
2631
- console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(doctorCreateSql(f, { concurrently: opts.concurrently }))}`);
2763
+ const last = opts.divergence ? ui_js_1.symbols.tee : ui_js_1.symbols.teeEnd;
2764
+ console.log(` ${(0, ui_js_1.dim)(last)} ${(0, ui_js_1.green)(doctorCreateSql(f, { concurrently: opts.concurrently }))}`);
2765
+ if (opts.divergence)
2766
+ renderDivergenceEvidence(opts.divergence);
2632
2767
  (0, ui_js_1.newline)();
2633
2768
  }
2634
2769
  /** The invalid-index report section (a failed CONCURRENTLY build leaves these behind). */
@@ -2644,6 +2779,198 @@ function renderInvalidIndexes(invalid) {
2644
2779
  (0, ui_js_1.newline)();
2645
2780
  }
2646
2781
  }
2782
+ /**
2783
+ * The release in which `turbine-orm/prisma-compat` began forwarding Turbine-only
2784
+ * query options (`forceCustomPlan` among them) to the core client.
2785
+ *
2786
+ * Printed rather than assumed, and the sentence stays even after that release:
2787
+ * doctor's audience routinely runs a CLI newer than the library pinned in the
2788
+ * app, and on an older library the option is accepted and silently ignored.
2789
+ */
2790
+ const COMPAT_PASSTHROUGH_VERSION = '0.57.0';
2791
+ /**
2792
+ * The gates + scored-population footer. Split out because it is printed from two
2793
+ * places: the normal section, and the case where every finding was attached to a
2794
+ * missing-index finding instead.
2795
+ */
2796
+ function renderDivergenceGates(divergence) {
2797
+ const t = plan_divergence_js_1.PLAN_DIVERGENCE_THRESHOLDS;
2798
+ console.log(` ${(0, ui_js_1.dim)(`Gates, indexed column: the wrong plan must walk >= ${t.minWalkPages} pages and >= ${Math.round(t.minWalkFraction * 100)}% of the table.`)}`);
2799
+ console.log(` ${(0, ui_js_1.dim)(`Gates, unindexed column: the rarest value must hold fewer rows than the limit, and the wrong`)}`);
2800
+ console.log(` ${(0, ui_js_1.dim)(`plan must walk >= ${t.minGenericTupleWalk.toLocaleString('en-US')} tuples. Assumed LIMIT ${t.assumedLimit} throughout.`)}`);
2801
+ console.log(` ${(0, ui_js_1.dim)(`${divergence.candidatesConsidered} column(s) were scored (${divergence.consideredIndexed} indexed, ${divergence.consideredUnindexed} unindexed). That population is relation-probe`)}`);
2802
+ console.log(` ${(0, ui_js_1.dim)('and leading-index columns only: a filter column that is neither is not covered. Skip this')}`);
2803
+ console.log(` ${(0, ui_js_1.dim)('section with --no-plan-divergence.')}`);
2804
+ }
2805
+ /** Round to a whole number and group it, for the divergence report's estimates. */
2806
+ function divInt(n) {
2807
+ if (!Number.isFinite(n))
2808
+ return 'unbounded';
2809
+ return Math.round(n).toLocaleString('en-US');
2810
+ }
2811
+ /**
2812
+ * The plan-divergence section: columns whose value distribution can flip a
2813
+ * cached plan. Finding-only by design, there is no `--fix` for it: the fix is
2814
+ * application code (scope the plan-cache mode to the affected reads), and the
2815
+ * index that looks like a fix is measured NOT to be one.
2816
+ */
2817
+ function renderPlanDivergence(divergence, attached) {
2818
+ const { notices } = divergence;
2819
+ // Anything already rendered as evidence on a missing-index finding is NOT
2820
+ // repeated here: one column, one problem, one remedy.
2821
+ const rendered = new Set(attached.values());
2822
+ const findings = divergence.findings.filter((f) => !rendered.has(f));
2823
+ // Every divergence finding was attached above, so this section has no entries
2824
+ // of its own. The pointer, the gates and the scored population still belong in
2825
+ // the report: they are output of THIS check, and a reader must be able to tell
2826
+ // "considered and clean" from "never looked".
2827
+ //
2828
+ // Printed BEFORE the notices rather than inside an early return: an early
2829
+ // return that also required `notices.length === 0` dropped both blocks
2830
+ // whenever any candidate lacked a pg_stats row, which is the normal reason a
2831
+ // notice exists.
2832
+ if (findings.length === 0 && rendered.size > 0) {
2833
+ console.log(` ${(0, ui_js_1.dim)(`Cached-plan divergence: ${rendered.size} finding(s), shown with the index findings above.`)}`);
2834
+ renderDivergenceGates(divergence);
2835
+ (0, ui_js_1.newline)();
2836
+ }
2837
+ if (findings.length === 0 && notices.length === 0)
2838
+ return;
2839
+ if (findings.length > 0) {
2840
+ (0, ui_js_1.warn)(`${(0, ui_js_1.bold)(String(findings.length))} column(s) whose value distribution can flip a cached plan`);
2841
+ (0, ui_js_1.newline)();
2842
+ console.log(` ${(0, ui_js_1.dim)('Postgres may promote a named prepared statement to a GENERIC plan from its sixth execution,')}`);
2843
+ console.log(` ${(0, ui_js_1.dim)('but only when the generic plan is not ESTIMATED to cost more than the average custom')}`);
2844
+ console.log(` ${(0, ui_js_1.dim)('plan. A generic plan cannot see your values: it estimates "col = $1" as rows /')}`);
2845
+ console.log(` ${(0, ui_js_1.dim)('n_distinct and an unknown LIMIT as 10% of the child estimate. When those defaults')}`);
2846
+ console.log(` ${(0, ui_js_1.dim)('land on the other side of a plan boundary from the real value, the plan flips.')}`);
2847
+ (0, ui_js_1.newline)();
2848
+ }
2849
+ const analyzedLabel = (f) => f.lastAnalyze === null
2850
+ ? 'last ANALYZE unknown'
2851
+ : `last analyzed ${Math.max(0, Math.round((Date.now() - f.lastAnalyze.getTime()) / 86_400_000))}d ago`;
2852
+ for (const f of findings) {
2853
+ if (f.branch === 'unindexed-filter') {
2854
+ // Only reached when the column has no missing-index finding to hang this
2855
+ // on (an index that exists but cannot serve the equality: partial,
2856
+ // expression, or non-btree). The remedy is still an index, not a
2857
+ // plan-cache setting, so this entry never suggests forceCustomPlan.
2858
+ console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(`${f.table}.${f.column}`))} ${(0, ui_js_1.gray)('UNINDEXED-FILTER FLIP')}`);
2859
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${divInt(f.rows)} rows in ${divInt(f.pages)} pages, rarest value ~${(0, ui_js_1.bold)(divInt(f.rarestBucket))} rows, below the assumed LIMIT ${f.assumedLimit}`);
2860
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} no index serves ${f.column} = $1, so the good plan is a seq scan (${divInt(f.pages)} pages);`);
2861
+ console.log(` ${(0, ui_js_1.dim)(`a promoted generic plan keeps the ordered "${f.orderColumn}" walk and reads up to ~${divInt(f.tuplesWalked ?? f.rows)} tuples`)}`);
2862
+ console.log(` ${(0, ui_js_1.dim)('before it fills the LIMIT.')}`);
2863
+ for (const line of divergenceAmplificationLines(f))
2864
+ console.log(` ${(0, ui_js_1.dim)(line)}`);
2865
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)(`filter-column correlation ${f.correlation.toFixed(2)}, ${analyzedLabel(f)}`)}`);
2866
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)('the fix is an index that can serve this equality. A partial or expression index')}`);
2867
+ console.log(` ${(0, ui_js_1.dim)('on the column does not: the planner has no path for the bare predicate. A hash')}`);
2868
+ console.log(` ${(0, ui_js_1.dim)('index does, and a column served by one is scored by the other rule instead.')}`);
2869
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.dim)('confirm with YOUR values before changing anything:')}`);
2870
+ for (const line of f.diagnosticSql.split('\n')) {
2871
+ console.log(` ${(0, ui_js_1.green)(line)}`);
2872
+ }
2873
+ (0, ui_js_1.newline)();
2874
+ continue;
2875
+ }
2876
+ console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(`${f.table}.${f.column}`))} ${(0, ui_js_1.gray)('SPARSE-VALUE FLIP')}`);
2877
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} generic estimate ${(0, ui_js_1.bold)(divInt(f.genericEstimate))} rows ${(0, ui_js_1.dim)(`(${divInt(f.rows)} rows / ${divInt(f.distinctValues)} distinct values)`)}`);
2878
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} rarest value bucket ${(0, ui_js_1.bold)(divInt(f.rarestBucket))} rows ${(0, ui_js_1.dim)('(pg_stats most_common_freqs / residual bucket)')}`);
2879
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} crossover ${(0, ui_js_1.bold)(divInt(f.crossoverRows ?? 0))} rows ${(0, ui_js_1.dim)(`(sqrt(limit ${f.assumedLimit} x ${divInt(f.pages)} pages); ${divInt(f.crossoverRowsWide ?? 0)} at limit ${f.thresholds.wideLimit})`)}`);
2880
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)(`values below the crossover: ${divInt(f.valuesBelowCrossover ?? 0)} of ${divInt(f.distinctValues)}, filter-column correlation ${f.correlation.toFixed(2)}, ${analyzedLabel(f)}`)}`);
2881
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} for such a value the generic plan walks ~${(0, ui_js_1.bold)(divInt(f.walkPages ?? 0))} of ${divInt(f.pages)} pages ${(0, ui_js_1.dim)(`(${Math.round((f.walkFraction ?? 0) * 100)}% of the table)`)}`);
2882
+ console.log(` ${(0, ui_js_1.dim)(`for reads shaped WHERE ${f.column} = $1 ORDER BY ${f.orderColumn} LIMIT $n,`)}`);
2883
+ console.log(` ${(0, ui_js_1.dim)("where the custom plan reads only that value's own rows.")}`);
2884
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)('No amplification figure is printed, deliberately. This models how many rows a')}`);
2885
+ console.log(` ${(0, ui_js_1.dim)('value has, not WHERE they sit in the heap, and the second half can move the')}`);
2886
+ console.log(` ${(0, ui_js_1.dim)('real cost by an order of magnitude. Measure it instead:')}`);
2887
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.dim)('confirm with YOUR values before changing anything:')}`);
2888
+ for (const line of f.diagnosticSql.split('\n')) {
2889
+ console.log(` ${(0, ui_js_1.green)(line)}`);
2890
+ }
2891
+ (0, ui_js_1.newline)();
2892
+ }
2893
+ if (findings.length > 0) {
2894
+ const first = findings.find((f) => f.branch === 'sparse-value');
2895
+ console.log(` ${(0, ui_js_1.bold)('What to do, in order:')}`);
2896
+ console.log(` 1. Check that this shape is promoted AT ALL. Step 1 of the block above: while`);
2897
+ console.log(` ${(0, ui_js_1.dim)('generic_plans is 0, Postgres is planning with your real values and there is nothing')}`);
2898
+ console.log(` ${(0, ui_js_1.dim)('to fix. A finding is exposure, not an incident, and many shapes never promote.')}`);
2899
+ console.log(` 2. If it does promote, compare the two plans. Both SETs matter: without them a`);
2900
+ console.log(` ${(0, ui_js_1.dim)('repeated seq scan resumes where the last one stopped and a catastrophic case reads')}`);
2901
+ console.log(` ${(0, ui_js_1.dim)('as harmless.')}`);
2902
+ if (first) {
2903
+ // The OPTION is named first and both call shapes follow, so no step
2904
+ // assumes which client the reader is holding. The compat example uses
2905
+ // Prisma's `take`: printing `limit` there would be a second wrong
2906
+ // instruction, since `limit` is a Turbine spelling compat does not read.
2907
+ console.log(` 3. If the flip is real, scope the fix to those reads with ${(0, ui_js_1.cyan)('forceCustomPlan')}. It`);
2908
+ console.log(` ${(0, ui_js_1.dim)('withholds the prepared-statement NAME for that one query, so the driver re-parses')}`);
2909
+ console.log(` ${(0, ui_js_1.dim)('it every execution and it is always planned with the real values. No GUC, no SET')}`);
2910
+ console.log(` ${(0, ui_js_1.dim)('LOCAL, no transaction, no extra round trip.')}`);
2911
+ // Hanging indent rather than an alignment that pretends to line up: the
2912
+ // call's own width depends on the table name, so a fixed padding column
2913
+ // misaligns on every schema but the one it was written against.
2914
+ const args = `where: { ${first.columnField}: value }, orderBy: { ${first.orderColumnField}: 'asc' },`;
2915
+ // The accessor is the camelCase FIELD spelling, not the raw table name:
2916
+ // TurbineClient and the code generator both define table accessors through
2917
+ // snakeToCamel, so `db.inventory_location` is undefined on every
2918
+ // snake_case schema. The finding's own `columnField` / `orderColumnField`
2919
+ // are already field-space for the same reason.
2920
+ console.log(` ${(0, ui_js_1.dim)('On the core client:')}`);
2921
+ console.log(` ${(0, ui_js_1.cyan)(`db.${(0, schema_js_1.snakeToCamel)(first.table)}.findMany({`)}`);
2922
+ console.log(` ${(0, ui_js_1.cyan)(args)}`);
2923
+ console.log(` ${(0, ui_js_1.cyan)('limit: 20, forceCustomPlan: true,')}`);
2924
+ console.log(` ${(0, ui_js_1.cyan)('})')}`);
2925
+ console.log(` ${(0, ui_js_1.dim)("Through turbine-orm/prisma-compat, the same option on the delegate call (Prisma's")}`);
2926
+ console.log(` ${(0, ui_js_1.dim)('`take`, not `limit`). The Prisma MODEL name is not knowable from the schema side,')}`);
2927
+ console.log(` ${(0, ui_js_1.dim)('so substitute your own:')}`);
2928
+ console.log(` ${(0, ui_js_1.cyan)('compat.<Model>.findMany({')}`);
2929
+ console.log(` ${(0, ui_js_1.cyan)(args)}`);
2930
+ console.log(` ${(0, ui_js_1.cyan)('take: 20, forceCustomPlan: true,')}`);
2931
+ console.log(` ${(0, ui_js_1.cyan)('})')}`);
2932
+ console.log(` ${(0, ui_js_1.dim)(`The compat passthrough requires turbine >= ${COMPAT_PASSTHROUGH_VERSION}. On an older version the option`)}`);
2933
+ console.log(` ${(0, ui_js_1.dim)('is accepted and ignored there, so confirm at the wire with the same')}`);
2934
+ console.log(` ${(0, ui_js_1.dim)('pg_prepared_statements check in step 1 rather than assuming it took effect.')}`);
2935
+ console.log(` 4. Reaching for a database-wide plan_cache_mode is not the fix, whichever client you`);
2936
+ console.log(` ${(0, ui_js_1.dim)('use. There are measured shapes where a generic plan is dramatically better: an')}`);
2937
+ console.log(` ${(0, ui_js_1.dim)('unordered LIMIT over a value whose rows are packed at the end of the heap reads')}`);
2938
+ console.log(` ${(0, ui_js_1.dim)('4,262 buffers under a custom plan against 71 under a generic one. That fixture is')}`);
2939
+ console.log(` ${(0, ui_js_1.dim)('printed in full at turbineorm.dev/relations, so the number is checkable rather')}`);
2940
+ console.log(` ${(0, ui_js_1.dim)('than asserted. Pinning every statement')}`);
2941
+ console.log(` ${(0, ui_js_1.dim)('in one direction trades this finding for its mirror image. That applies equally to')}`);
2942
+ console.log(` ${(0, ui_js_1.dim)("Turbine's client-level `planCacheMode` and to a SET or ALTER ROLE applied outside")}`);
2943
+ console.log(` ${(0, ui_js_1.dim)('Turbine.')}`);
2944
+ console.log(` 5. A composite index on (${first.column}, ${first.orderColumn}) makes the GOOD plan better. It`);
2945
+ console.log(` ${(0, ui_js_1.dim)('does NOT stop the generic plan from choosing the other one, and it can widen the gap.')}`);
2946
+ console.log(` ${(0, ui_js_1.dim)('Add it for the custom-plan win, not as a fix for this finding.')}`);
2947
+ }
2948
+ // Stated separately because the first remedy genuinely differs by branch: an
2949
+ // UNINDEXED column's flip is fixed by the index, and a per-query plan-cache
2950
+ // override there would only paper over a table scan.
2951
+ if (findings.some((f) => f.branch === 'unindexed-filter')) {
2952
+ console.log(` ${first ? 6 : 3}. A finding on an UNINDEXED column has a different FIRST remedy: add an index that`);
2953
+ console.log(` ${(0, ui_js_1.dim)('serves the equality, then re-run doctor and re-score. The index moves the')}`);
2954
+ console.log(` ${(0, ui_js_1.dim)('divergence in both directions at once (it makes the good plan much cheaper, which')}`);
2955
+ console.log(` ${(0, ui_js_1.dim)('widens the ratio, and on a measured fixture it also stopped Postgres promoting the')}`);
2956
+ console.log(` ${(0, ui_js_1.dim)('generic plan at all), so do not assume the finding is closed by adding it.')}`);
2957
+ }
2958
+ (0, ui_js_1.newline)();
2959
+ console.log(` ${(0, ui_js_1.dim)('This finding is derived from statistics, not from your traffic: it says the DISTRIBUTION')}`);
2960
+ console.log(` ${(0, ui_js_1.dim)('admits a damaging flip, not that a query is running one today. It cannot see where a')}`);
2961
+ console.log(` ${(0, ui_js_1.dim)('value physically sits in the heap, so a clean report is not evidence of immunity, and')}`);
2962
+ console.log(` ${(0, ui_js_1.dim)('a column that is neither an FK nor indexed is not in the scored population at all.')}`);
2963
+ renderDivergenceGates(divergence);
2964
+ (0, ui_js_1.newline)();
2965
+ }
2966
+ if (notices.length > 0) {
2967
+ console.log(` ${(0, ui_js_1.dim)('Not scored for cached-plan divergence (statistics missing):')}`);
2968
+ for (const n of notices) {
2969
+ console.log(` ${(0, ui_js_1.dim)(`- ${n.table}.${n.column}: ${n.reason}`)}`);
2970
+ }
2971
+ (0, ui_js_1.newline)();
2972
+ }
2973
+ }
2647
2974
  /** Write the --fix migration (CONCURRENTLY + directive by default; plain with --no-concurrently). */
2648
2975
  function renderFixMigration(findings, config, args) {
2649
2976
  const concurrently = args.noConcurrently !== true;
@@ -3117,7 +3444,7 @@ function showHelp() {
3117
3444
  console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
3118
3445
  console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
3119
3446
  console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
3120
- console.log(` ${(0, ui_js_1.cyan)('doctor')} Cost-aware missing-FK-index triage ${(0, ui_js_1.dim)('(--fix, --json, --unused, --audit)')}`);
3447
+ console.log(` ${(0, ui_js_1.cyan)('doctor')} Index + cached-plan triage ${(0, ui_js_1.dim)('(--fix, --json, --unused, --audit)')}`);
3121
3448
  console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI ${(0, ui_js_1.dim)('(--write for writes, --demo for a sample DB)')}`);
3122
3449
  console.log(` ${(0, ui_js_1.cyan)('mcp')} Start read-only MCP server over stdio`);
3123
3450
  console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);
@@ -295,7 +295,22 @@ export interface TurbineConfig {
295
295
  * Default `undefined`: Turbine issues NOTHING and the backend keeps its own
296
296
  * default (`auto`), byte-identical to not setting the option.
297
297
  *
298
- * SESSION-LEVEL, NOT PER QUERY. It is applied as a connection parameter
298
+ * SESSION-LEVEL. THE PER-QUERY LEVER IS A DIFFERENT, NARROWER ONE. This
299
+ * option is a connection parameter and cannot be unset for a single query, so
300
+ * a client-wide value cannot express "custom here, `auto` there". The read
301
+ * option `forceCustomPlan: true` covers that case, and only that case: it
302
+ * withholds the prepared-statement NAME for one query, and because the driver
303
+ * re-parses an unnamed statement on every execution, the counter that
304
+ * promotion depends on is reset before it is ever reached. That
305
+ * mechanism can only ever mean custom, so there is deliberately no per-query
306
+ * `force_generic_plan`; that direction stays here, at the connection. The two
307
+ * do NOT compose in the other order either: `'force_generic_plan'` set here
308
+ * governs unnamed statements as well as named ones (measured), so a
309
+ * per-query `forceCustomPlan: true` cannot escape it and is REFUSED with
310
+ * `ValidationError` (E003) instead of silently doing nothing. Leave this
311
+ * option unset (or `'auto'`) if any query needs the per-query lever.
312
+ *
313
+ * IT IS APPLIED as a connection parameter
299
314
  * (`options=-c plan_cache_mode=...`) when the pool opens a connection, so it
300
315
  * is in force for that connection's very first statement and persists for its
301
316
  * whole life: every pooled checkout, `$transaction`, stream and pipeline on