turbine-orm 0.55.0 → 0.56.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 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. 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
 
@@ -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,7 @@ 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");
85
86
  const schema_sql_js_1 = require("../schema-sql.js");
86
87
  const config_js_1 = require("./config.js");
87
88
  const destructive_js_1 = require("./destructive.js");
@@ -192,6 +193,9 @@ function parseArgs(argv = process.argv.slice(2)) {
192
193
  result.metricsUrl = next;
193
194
  i++;
194
195
  break;
196
+ case '--no-plan-divergence':
197
+ result.noPlanDivergence = true;
198
+ break;
195
199
  case '--zod':
196
200
  result.zod = true;
197
201
  break;
@@ -2309,20 +2313,27 @@ async function cmdDoctor(args, config) {
2309
2313
  const missing = (0, index_advisor_js_1.findMissingRelationIndexes)(schema);
2310
2314
  // Collect live statistics. The collector reads whole-schema indexes (for
2311
2315
  // invalid-index detection) plus per-table stats + probed-column null_frac.
2312
- const probedTables = [...new Set(missing.map((m) => m.table))];
2313
2316
  const probedColumns = [];
2314
2317
  for (const m of missing) {
2315
2318
  if (m.columns.length === 1 && m.columns[0] !== undefined) {
2316
2319
  probedColumns.push({ table: m.table, column: m.columns[0] });
2317
2320
  }
2318
2321
  }
2322
+ // Plan-divergence candidates are the columns that ARE indexed, so their tables
2323
+ // are usually disjoint from the missing-index set: both lists feed the same
2324
+ // one-connection snapshot rather than opening a second read.
2325
+ const divergenceOn = args.noPlanDivergence !== true;
2326
+ const divergenceColumns = divergenceOn ? (0, plan_divergence_js_1.collectDivergenceCandidateColumns)(schema) : [];
2327
+ const probedTables = [...new Set(missing.map((m) => m.table))];
2328
+ const statsTables = [...new Set([...probedTables, ...divergenceColumns.map((c) => c.table)])];
2319
2329
  let snapshot;
2320
2330
  try {
2321
2331
  snapshot = await (0, index_stats_js_1.collectStatsSnapshot)({
2322
2332
  connectionString: url,
2323
2333
  schema: config.schema,
2324
- tables: probedTables,
2334
+ tables: statsTables,
2325
2335
  columns: probedColumns,
2336
+ distributionColumns: divergenceColumns,
2326
2337
  });
2327
2338
  }
2328
2339
  catch (err) {
@@ -2361,9 +2372,16 @@ async function cmdDoctor(args, config) {
2361
2372
  ? (0, index_stats_js_1.auditDoctorIndexes)(snapshot, (0, index_advisor_js_1.collectDoctorProbeIndexNames)(schema), { minScans, relationProbes })
2362
2373
  : [];
2363
2374
  const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
2375
+ // Plan divergence has its OWN freshness gate. The cost tiers require a
2376
+ // trustworthy stats_reset age because they normalize write counters by it;
2377
+ // this check reads no counter, only pg_stats, whose freshness is ANALYZE. A
2378
+ // cluster with a NULL stats_reset (the default) must still get the check.
2379
+ const divergence = divergenceOn && snapshot.available
2380
+ ? (0, plan_divergence_js_1.findPlanDivergence)(schema, snapshot)
2381
+ : { findings: [], notices: [], candidatesConsidered: 0 };
2364
2382
  if (jsonMode) {
2365
2383
  spinner?.stop();
2366
- console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, args }), null, 2));
2384
+ console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, divergence, args }), null, 2));
2367
2385
  return;
2368
2386
  }
2369
2387
  await renderDoctorHuman({
@@ -2375,6 +2393,7 @@ async function cmdDoctor(args, config) {
2375
2393
  usable,
2376
2394
  heat,
2377
2395
  subtract,
2396
+ divergence,
2378
2397
  args,
2379
2398
  config,
2380
2399
  });
@@ -2442,13 +2461,17 @@ function buildDoctorJson(ctx) {
2442
2461
  out.redundant = ctx.subtract.unusedRan ? ctx.subtract.redundant : [];
2443
2462
  out.audit = ctx.subtract.auditRan ? ctx.subtract.audit : [];
2444
2463
  out.invalid = ctx.invalid;
2464
+ // Always an array, never absent: a consumer must not have to write `?? []`
2465
+ // just because the section was skipped or found nothing.
2466
+ out.planDivergence = ctx.divergence.findings;
2467
+ out.planDivergenceNotices = ctx.divergence.notices;
2445
2468
  return out;
2446
2469
  }
2447
2470
  async function renderDoctorHuman(ctx) {
2448
- const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, args, config } = ctx;
2471
+ const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, divergence, args, config } = ctx;
2449
2472
  spinner.succeed(`Scanned ${(0, ui_js_1.bold)(String(Object.keys(schema.tables).length))} tables`);
2450
2473
  const subtractRan = subtract.unusedRan || subtract.auditRan;
2451
- const nothingToAdd = findings.length === 0 && invalid.length === 0;
2474
+ const nothingToAdd = findings.length === 0 && invalid.length === 0 && divergence.findings.length === 0;
2452
2475
  const nothingToSubtract = subtract.unused.length === 0 && subtract.redundant.length === 0 && subtract.audit.length === 0;
2453
2476
  if (nothingToAdd && (!subtractRan || nothingToSubtract)) {
2454
2477
  if (subtractRan) {
@@ -2479,6 +2502,7 @@ async function renderDoctorHuman(ctx) {
2479
2502
  }
2480
2503
  }
2481
2504
  renderInvalidIndexes(invalid);
2505
+ renderPlanDivergence(divergence);
2482
2506
  if (subtract.unusedRan) {
2483
2507
  renderUnusedIndexes(subtract.unused, subtract.minScans, snapshot);
2484
2508
  renderRedundantIndexes(subtract.redundant);
@@ -2644,6 +2668,92 @@ function renderInvalidIndexes(invalid) {
2644
2668
  (0, ui_js_1.newline)();
2645
2669
  }
2646
2670
  }
2671
+ /** Round to a whole number and group it, for the divergence report's estimates. */
2672
+ function divInt(n) {
2673
+ if (!Number.isFinite(n))
2674
+ return 'unbounded';
2675
+ return Math.round(n).toLocaleString('en-US');
2676
+ }
2677
+ /**
2678
+ * The plan-divergence section: columns whose value distribution can flip a
2679
+ * cached plan. Finding-only by design, there is no `--fix` for it: the fix is
2680
+ * application code (scope the plan-cache mode to the affected reads), and the
2681
+ * index that looks like a fix is measured NOT to be one.
2682
+ */
2683
+ function renderPlanDivergence(divergence) {
2684
+ const { findings, notices } = divergence;
2685
+ if (findings.length === 0 && notices.length === 0)
2686
+ return;
2687
+ if (findings.length > 0) {
2688
+ (0, ui_js_1.warn)(`${(0, ui_js_1.bold)(String(findings.length))} column(s) whose value distribution can flip a cached plan`);
2689
+ (0, ui_js_1.newline)();
2690
+ console.log(` ${(0, ui_js_1.dim)('Postgres may promote a named prepared statement to a GENERIC plan from its sixth execution,')}`);
2691
+ console.log(` ${(0, ui_js_1.dim)('but only when the generic plan is not ESTIMATED to cost more than the average custom')}`);
2692
+ console.log(` ${(0, ui_js_1.dim)('plan. A generic plan cannot see your values: it estimates "col = $1" as rows /')}`);
2693
+ console.log(` ${(0, ui_js_1.dim)('n_distinct and an unknown LIMIT as 10% of the child estimate. When those defaults')}`);
2694
+ console.log(` ${(0, ui_js_1.dim)('land on the other side of a plan boundary from the real value, the plan flips.')}`);
2695
+ (0, ui_js_1.newline)();
2696
+ }
2697
+ for (const f of findings) {
2698
+ 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')}`);
2699
+ 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)`)}`);
2700
+ 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)')}`);
2701
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} crossover ${(0, ui_js_1.bold)(divInt(f.crossoverRows))} rows ${(0, ui_js_1.dim)(`(sqrt(limit ${f.assumedLimit} x ${divInt(f.pages)} pages); ${divInt(f.crossoverRowsWide)} at limit ${f.thresholds.wideLimit})`)}`);
2702
+ const analyzed = f.lastAnalyze === null
2703
+ ? 'last ANALYZE unknown'
2704
+ : `last analyzed ${Math.max(0, Math.round((Date.now() - f.lastAnalyze.getTime()) / 86_400_000))}d ago`;
2705
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)(`values below the crossover: ${divInt(f.valuesBelowCrossover)} of ${divInt(f.distinctValues)}, correlation ${f.correlation.toFixed(2)}, ${analyzed}`)}`);
2706
+ 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))} of ${divInt(f.pages)} pages ${(0, ui_js_1.dim)(`(${Math.round(f.walkFraction * 100)}% of the table)`)}`);
2707
+ console.log(` ${(0, ui_js_1.dim)(`for reads shaped WHERE ${f.column} = $1 ORDER BY ${f.orderColumn} LIMIT $n,`)}`);
2708
+ console.log(` ${(0, ui_js_1.dim)("where the custom plan reads only that value's own rows.")}`);
2709
+ 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')}`);
2710
+ console.log(` ${(0, ui_js_1.dim)('value has, not WHERE they sit in the heap, and the second half can move the')}`);
2711
+ console.log(` ${(0, ui_js_1.dim)('real cost by an order of magnitude. Measure it instead:')}`);
2712
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.dim)('confirm with YOUR values before changing anything:')}`);
2713
+ for (const line of f.diagnosticSql.split('\n')) {
2714
+ console.log(` ${(0, ui_js_1.green)(line)}`);
2715
+ }
2716
+ (0, ui_js_1.newline)();
2717
+ }
2718
+ if (findings.length > 0) {
2719
+ console.log(` ${(0, ui_js_1.bold)('What to do, in order:')}`);
2720
+ console.log(` 1. Check that this shape is promoted AT ALL. Step 1 of the block above: while`);
2721
+ console.log(` ${(0, ui_js_1.dim)('generic_plans is 0, Postgres is planning with your real values and there is nothing')}`);
2722
+ console.log(` ${(0, ui_js_1.dim)('to fix. A finding is exposure, not an incident, and many shapes never promote.')}`);
2723
+ console.log(` 2. If it does promote, compare the two plans. Both SETs matter: without them a`);
2724
+ console.log(` ${(0, ui_js_1.dim)('repeated seq scan resumes where the last one stopped and a catastrophic case reads')}`);
2725
+ console.log(` ${(0, ui_js_1.dim)('as harmless.')}`);
2726
+ console.log(` 3. If the flip is real, scope the fix to those reads:`);
2727
+ const first = findings[0];
2728
+ console.log(` ${(0, ui_js_1.cyan)(`db.${first.table}.findMany({ where: { ${first.columnField}: value }, orderBy: { ${first.orderColumnField}: 'asc' },`)}`);
2729
+ console.log(` ${(0, ui_js_1.cyan)(` limit: 20, forceCustomPlan: true })`)}`);
2730
+ console.log(` ${(0, ui_js_1.dim)('That withholds the prepared-statement NAME for that one query, so the driver')}`);
2731
+ console.log(` ${(0, ui_js_1.dim)('re-parses it every execution and it is always planned with the real values. No')}`);
2732
+ console.log(` ${(0, ui_js_1.dim)('GUC, no SET LOCAL, no transaction, no extra round trip.')}`);
2733
+ console.log(` 4. Do NOT set planCacheMode on the client to fix this. There are measured shapes`);
2734
+ console.log(` ${(0, ui_js_1.dim)('where a generic plan is dramatically better (an unordered LIMIT over a value whose')}`);
2735
+ console.log(` ${(0, ui_js_1.dim)('rows are packed at the end of the heap: 4,262 buffers custom vs 71 generic on a')}`);
2736
+ console.log(` ${(0, ui_js_1.dim)('reproducible fixture). A custom plan is not automatically the better plan.')}`);
2737
+ console.log(` 5. A composite index on (${first.column}, ${first.orderColumn}) makes the GOOD plan better. It`);
2738
+ console.log(` ${(0, ui_js_1.dim)('does NOT stop the generic plan from choosing the other one, and it can widen the gap.')}`);
2739
+ console.log(` ${(0, ui_js_1.dim)('Add it for the custom-plan win, not as a fix for this finding.')}`);
2740
+ (0, ui_js_1.newline)();
2741
+ console.log(` ${(0, ui_js_1.dim)('This finding is derived from statistics, not from your traffic: it says the DISTRIBUTION')}`);
2742
+ console.log(` ${(0, ui_js_1.dim)('admits a damaging flip, not that a query is running one today. It models ONE shape,')}`);
2743
+ console.log(` ${(0, ui_js_1.dim)('the rare value that loses its bitmap plan. It cannot see where a value physically')}`);
2744
+ console.log(` ${(0, ui_js_1.dim)('sits in the heap, so a clean report is not evidence of immunity.')}`);
2745
+ console.log(` ${(0, ui_js_1.dim)(`Gates: the wrong plan must walk >= ${plan_divergence_js_1.PLAN_DIVERGENCE_THRESHOLDS.minWalkPages} pages and >= ${Math.round(plan_divergence_js_1.PLAN_DIVERGENCE_THRESHOLDS.minWalkFraction * 100)}% of the table, at an assumed`)}`);
2746
+ console.log(` ${(0, ui_js_1.dim)(`LIMIT ${plan_divergence_js_1.PLAN_DIVERGENCE_THRESHOLDS.assumedLimit}. ${divergence.candidatesConsidered} column(s) were scored. Skip this section with --no-plan-divergence.`)}`);
2747
+ (0, ui_js_1.newline)();
2748
+ }
2749
+ if (notices.length > 0) {
2750
+ console.log(` ${(0, ui_js_1.dim)('Not scored for cached-plan divergence (statistics missing):')}`);
2751
+ for (const n of notices) {
2752
+ console.log(` ${(0, ui_js_1.dim)(`- ${n.table}.${n.column}: ${n.reason}`)}`);
2753
+ }
2754
+ (0, ui_js_1.newline)();
2755
+ }
2756
+ }
2647
2757
  /** Write the --fix migration (CONCURRENTLY + directive by default; plain with --no-concurrently). */
2648
2758
  function renderFixMigration(findings, config, args) {
2649
2759
  const concurrently = args.noConcurrently !== true;
@@ -3117,7 +3227,7 @@ function showHelp() {
3117
3227
  console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
3118
3228
  console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
3119
3229
  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)')}`);
3230
+ console.log(` ${(0, ui_js_1.cyan)('doctor')} Index + cached-plan triage ${(0, ui_js_1.dim)('(--fix, --json, --unused, --audit)')}`);
3121
3231
  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
3232
  console.log(` ${(0, ui_js_1.cyan)('mcp')} Start read-only MCP server over stdio`);
3123
3233
  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
@@ -658,6 +658,10 @@ class TurbineClient {
658
658
  jsonEncoding: config.jsonEncoding,
659
659
  globalFilters: config.globalFilters,
660
660
  preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
661
+ // Forwarded so a per-query `forceCustomPlan` can refuse the combination
662
+ // this client's own connections would silently defeat, see the
663
+ // `planCacheMode` note on QueryInterfaceOptions.
664
+ planCacheMode,
661
665
  sqlCache: config.sqlCache ?? true,
662
666
  sqlCacheSize: config.sqlCacheSize,
663
667
  dialect: config.dialect,
@@ -375,6 +375,15 @@ export interface Dialect {
375
375
  * setting (`SET plan_cache_mode = auto | force_custom_plan |
376
376
  * force_generic_plan`). Gates the opt-in `planCacheMode` client option.
377
377
  *
378
+ * It ALSO gates the per-query `forceCustomPlan` read option, which uses a
379
+ * different mechanism (it withholds the prepared-statement name, so the
380
+ * driver re-parses the statement on every execution and the counter that
381
+ * generic-plan promotion depends on never reaches its threshold) but
382
+ * asks the identical capability question: does this engine have a PostgreSQL
383
+ * plan cache whose generic-plan promotion is worth pinning? An engine that
384
+ * answers no cannot honour either option, so both refuse on the same flag
385
+ * rather than on two flags that could never disagree.
386
+ *
378
387
  * A capability flag rather than a `dialect.name === 'postgresql'` test, for
379
388
  * the same reason every other refusal here is one: the setting is a property
380
389
  * of the PostgreSQL PLAN CACHE, not of the SQL string, so a
@@ -86,6 +86,12 @@ export interface TableStats {
86
86
  table: string;
87
87
  /** pg_class.reltuples. 0 or -1 means never-analyzed → treated as UNKNOWN (null rows). */
88
88
  reltuples: number;
89
+ /**
90
+ * pg_class.relpages. The SIZE input the plan-divergence crossover is computed
91
+ * from (a plan boundary tracks pages relative to the LIMIT, not rows).
92
+ * Absent when the pg_class read degraded; 0 means never analyzed.
93
+ */
94
+ relpages?: number;
89
95
  /** pg_stat_user_tables.n_live_tup, a cross-check for reltuples. */
90
96
  nLiveTup?: number;
91
97
  nTupIns?: number;
@@ -100,6 +106,13 @@ export interface TableStats {
100
106
  tableSizeBytes?: number;
101
107
  /** Count of indexes already on the table (pg_index). */
102
108
  existingIndexCount?: number;
109
+ /**
110
+ * The later of pg_stat_user_tables.last_analyze / last_autoanalyze: when the
111
+ * planner's column statistics were last refreshed. This, NOT stats_reset, is
112
+ * the freshness that matters for anything read out of pg_stats. Null when
113
+ * never analyzed; absent when the pg_stat read degraded.
114
+ */
115
+ lastAnalyze?: Date | null;
103
116
  }
104
117
  /**
105
118
  * Placeholder for an index column that is an EXPRESSION, not a plain column
@@ -133,6 +146,27 @@ export interface IndexStat {
133
146
  /** pg_relation_size of the index heap, bytes. Size reclaimed by a drop. */
134
147
  sizeBytes?: number;
135
148
  }
149
+ /**
150
+ * The value distribution of one column, read from pg_stats with
151
+ * `inherited = false`. Consumed by the plan-divergence advisor, which needs to
152
+ * reproduce the planner's OWN estimates rather than approximate them.
153
+ */
154
+ export interface ColumnDistribution {
155
+ table: string;
156
+ column: string;
157
+ /**
158
+ * pg_stats.n_distinct, RAW: a positive value is a count, a negative value is a
159
+ * fraction of the row count. Kept undecoded so the consumer decodes it exactly
160
+ * the way the planner does. 0 means the column was never analyzed.
161
+ */
162
+ nDistinct: number;
163
+ /** pg_stats.correlation, signed. NULL for types with no ordering. */
164
+ correlation: number | null;
165
+ /** pg_stats.most_common_freqs, or null when the column has no MCV list. */
166
+ mostCommonFreqs: number[] | null;
167
+ /** cardinality(most_common_vals): how many values the MCV list actually covers. */
168
+ mcvCount: number;
169
+ }
136
170
  /**
137
171
  * A point-in-time read of the statistics the triage needs. Every part is
138
172
  * optional at the field level so the pure scorer degrades honestly.
@@ -150,6 +184,13 @@ export interface StatsSnapshot {
150
184
  indexes: IndexStat[];
151
185
  /** null_frac per probed column, keyed `table.column`. */
152
186
  nullFrac: Record<string, number>;
187
+ /**
188
+ * Value distribution per candidate column, keyed `table.column`. Optional at
189
+ * the snapshot level: a caller that never asked for distribution statistics
190
+ * (or whose pg_stats read degraded) leaves it absent, and the plan-divergence
191
+ * advisor reports that as a suppressed candidate rather than scoring a guess.
192
+ */
193
+ columnStats?: Record<string, ColumnDistribution>;
153
194
  /** Per-signal degradation notices (privileges, catalog gaps, timeouts). */
154
195
  notices: string[];
155
196
  }
@@ -347,6 +388,11 @@ export interface CollectSnapshotOptions {
347
388
  tables: string[];
348
389
  /** Columns to read null_frac for (single-column probes). */
349
390
  columns: ProbedColumn[];
391
+ /**
392
+ * Columns to read the full value distribution for (n_distinct, correlation,
393
+ * MCV frequencies). One extra pg_stats query; empty skips it entirely.
394
+ */
395
+ distributionColumns?: ProbedColumn[];
350
396
  /** statement_timeout for each catalog read. Default 5000ms. */
351
397
  statementTimeoutMs?: number;
352
398
  }
@@ -148,6 +148,7 @@ function emptyStatsSnapshot(notices = []) {
148
148
  tables: {},
149
149
  indexes: [],
150
150
  nullFrac: {},
151
+ columnStats: {},
151
152
  notices,
152
153
  };
153
154
  }
@@ -590,12 +591,14 @@ async function collectStatsSnapshot(options) {
590
591
  }
591
592
  }
592
593
  // --- table stats (pg_stat_user_tables) ---------------------------------
593
- const statRows = await run('pg_stat_user_tables', `SELECT relname, n_tup_ins, n_tup_upd, n_tup_del, n_tup_hot_upd, seq_scan, seq_tup_read, n_live_tup
594
+ const statRows = await run('pg_stat_user_tables', `SELECT relname, n_tup_ins, n_tup_upd, n_tup_del, n_tup_hot_upd, seq_scan, seq_tup_read, n_live_tup,
595
+ greatest(last_analyze, last_autoanalyze) AS last_analyze
594
596
  FROM pg_stat_user_tables
595
597
  WHERE schemaname = $1 AND relname = ANY($2)`, [options.schema, options.tables]);
596
598
  // --- class size + existing index count (pg_class) ----------------------
597
599
  const classRows = await run('pg_class size', `SELECT c.relname,
598
600
  c.reltuples::bigint::text AS reltuples,
601
+ c.relpages::bigint::text AS relpages,
599
602
  pg_total_relation_size(c.oid)::text AS total_size,
600
603
  pg_relation_size(c.oid)::text AS table_size,
601
604
  (SELECT count(*) FROM pg_index i WHERE i.indrelid = c.oid)::text AS index_count
@@ -618,6 +621,7 @@ async function collectStatsSnapshot(options) {
618
621
  for (const row of classRows) {
619
622
  const s = ensure(row.relname);
620
623
  s.reltuples = Number(row.reltuples);
624
+ s.relpages = Number(row.relpages);
621
625
  s.totalSizeBytes = Number(row.total_size);
622
626
  s.tableSizeBytes = Number(row.table_size);
623
627
  s.existingIndexCount = Number(row.index_count);
@@ -634,6 +638,7 @@ async function collectStatsSnapshot(options) {
634
638
  s.seqScan = Number(row.seq_scan);
635
639
  s.seqTupRead = Number(row.seq_tup_read);
636
640
  s.nLiveTup = Number(row.n_live_tup);
641
+ s.lastAnalyze = row.last_analyze == null ? null : new Date(row.last_analyze);
637
642
  }
638
643
  }
639
644
  // --- invalid + all indexes (whole schema, for invalid detection) -------
@@ -697,6 +702,42 @@ async function collectStatsSnapshot(options) {
697
702
  }
698
703
  }
699
704
  }
705
+ // --- value distribution for divergence candidates (pg_stats) -----------
706
+ const distCols = options.distributionColumns ?? [];
707
+ if (distCols.length > 0) {
708
+ // `inherited = false` is required: on a partitioned parent the inherited
709
+ // row describes the whole tree, and a per-partition plan is not chosen
710
+ // from it. most_common_vals is an anyarray, so its cardinality is read
711
+ // through a text[] cast (array_length on anyarray cannot resolve a type).
712
+ const distRows = await run('pg_stats.distribution', `SELECT s.tablename, s.attname,
713
+ s.n_distinct::text AS n_distinct,
714
+ s.correlation::text AS correlation,
715
+ s.most_common_freqs,
716
+ coalesce(array_length(s.most_common_vals::text::text[], 1), 0)::text AS mcv_count
717
+ FROM pg_stats s
718
+ JOIN unnest($2::text[], $3::text[]) AS probe(t, c)
719
+ ON probe.t = s.tablename AND probe.c = s.attname
720
+ WHERE s.schemaname = $1 AND s.inherited = false`, [options.schema, distCols.map((c) => c.table), distCols.map((c) => c.column)]);
721
+ if (distRows) {
722
+ const byKey = snapshot.columnStats ?? {};
723
+ for (const row of distRows) {
724
+ byKey[`${row.tablename}.${row.attname}`] = {
725
+ table: row.tablename,
726
+ column: row.attname,
727
+ nDistinct: Number(row.n_distinct),
728
+ correlation: row.correlation == null ? null : Number(row.correlation),
729
+ mostCommonFreqs: row.most_common_freqs == null ? null : row.most_common_freqs.map(Number),
730
+ mcvCount: row.mcv_count == null ? 0 : Number(row.mcv_count),
731
+ };
732
+ }
733
+ snapshot.columnStats = byKey;
734
+ }
735
+ else {
736
+ // The read failed (privileges, catalog gap). Leave columnStats absent so
737
+ // the advisor suppresses every candidate instead of scoring zeros.
738
+ snapshot.columnStats = undefined;
739
+ }
740
+ }
700
741
  }
701
742
  finally {
702
743
  try {