turbine-orm 0.56.0 → 0.58.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/dist/cli/index.js CHANGED
@@ -32,7 +32,9 @@ import { generate, generatePrismaMap } from '../generate.js';
32
32
  import { buildCreateIndexSql, buildDropIndexSql, collectDoctorProbeIndexNames, collectRelationProbeColumns, findMissingRelationIndexes, } from '../index-advisor.js';
33
33
  import { auditDoctorIndexes, collectStatsSnapshot, collectTableHeat, findInvalidIndexes, findRedundantIndexes, findUnusedIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
34
34
  import { introspect } from '../introspect.js';
35
- import { collectDivergenceCandidateColumns, findPlanDivergence, PLAN_DIVERGENCE_THRESHOLDS, } from '../plan-divergence.js';
35
+ import { collectDivergenceCandidateColumns, collectDivergenceOrderColumns, findPlanDivergence, PLAN_DIVERGENCE_THRESHOLDS, } from '../plan-divergence.js';
36
+ import { applyFlipVerdicts, emptyFlipProbeResult, needsFlipProbe, probePlanFlips } from '../plan-flip-probe.js';
37
+ import { snakeToCamel } from '../schema.js';
36
38
  import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
37
39
  import { configTemplate, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
38
40
  import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
@@ -2274,8 +2276,14 @@ async function cmdDoctor(args, config) {
2274
2276
  // one-connection snapshot rather than opening a second read.
2275
2277
  const divergenceOn = args.noPlanDivergence !== true;
2276
2278
  const divergenceColumns = divergenceOn ? collectDivergenceCandidateColumns(schema) : [];
2279
+ // The columns a finding could ORDER BY, read alongside the candidates: the
2280
+ // size of an unindexed-filter flip turns on the ORDER column's correlation,
2281
+ // not the filter column's, and reading only the latter is how an earlier
2282
+ // revision printed a statistic about the wrong column.
2283
+ const divergenceOrderColumns = divergenceOn ? collectDivergenceOrderColumns(schema) : [];
2277
2284
  const probedTables = [...new Set(missing.map((m) => m.table))];
2278
2285
  const statsTables = [...new Set([...probedTables, ...divergenceColumns.map((c) => c.table)])];
2286
+ const distributionColumns = [...divergenceColumns, ...divergenceOrderColumns];
2279
2287
  let snapshot;
2280
2288
  try {
2281
2289
  snapshot = await collectStatsSnapshot({
@@ -2283,7 +2291,7 @@ async function cmdDoctor(args, config) {
2283
2291
  schema: config.schema,
2284
2292
  tables: statsTables,
2285
2293
  columns: probedColumns,
2286
- distributionColumns: divergenceColumns,
2294
+ distributionColumns,
2287
2295
  });
2288
2296
  }
2289
2297
  catch (err) {
@@ -2326,9 +2334,18 @@ async function cmdDoctor(args, config) {
2326
2334
  // trustworthy stats_reset age because they normalize write counters by it;
2327
2335
  // this check reads no counter, only pg_stats, whose freshness is ANALYZE. A
2328
2336
  // cluster with a NULL stats_reset (the default) must still get the check.
2329
- const divergence = divergenceOn && snapshot.available
2337
+ const scored = divergenceOn && snapshot.available
2330
2338
  ? findPlanDivergence(schema, snapshot)
2331
- : { findings: [], notices: [], candidatesConsidered: 0 };
2339
+ : { findings: [], notices: [], candidatesConsidered: 0, consideredIndexed: 0, consideredUnindexed: 0 };
2340
+ // Statistics can say how bad a flip WOULD be; only the planner can say whether
2341
+ // it is reachable. The `unindexed-filter` branch shipped in 0.57 without that
2342
+ // question answered and was right 6 times in 13 on a real schema, so every one
2343
+ // of its findings is now put to a plan-only EXPLAIN. Nothing is executed, and a
2344
+ // probe that fails keeps its finding rather than dropping it.
2345
+ const flipProbe = divergenceOn && scored.findings.some(needsFlipProbe)
2346
+ ? await probePlanFlips({ connectionString: url, schema: config.schema, findings: scored.findings })
2347
+ : emptyFlipProbeResult();
2348
+ const divergence = applyFlipVerdicts(scored, flipProbe);
2332
2349
  if (jsonMode) {
2333
2350
  spinner?.stop();
2334
2351
  console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, divergence, args }), null, 2));
@@ -2415,6 +2432,20 @@ function buildDoctorJson(ctx) {
2415
2432
  // just because the section was skipped or found nothing.
2416
2433
  out.planDivergence = ctx.divergence.findings;
2417
2434
  out.planDivergenceNotices = ctx.divergence.notices;
2435
+ // How large the scored population was, and how it split. A consumer counting
2436
+ // findings alone cannot tell "considered and clean" from "never looked", and
2437
+ // the unindexed half of that population did not exist before.
2438
+ out.planDivergenceScored = {
2439
+ considered: ctx.divergence.candidatesConsidered,
2440
+ indexed: ctx.divergence.consideredIndexed,
2441
+ unindexed: ctx.divergence.consideredUnindexed,
2442
+ // Whether the unindexed findings above were put to the planner, and how many
2443
+ // it refuted. `flipProbed: false` means they are statistics-only and carry
2444
+ // 0.57's precision, so a consumer can tell a verified list from an unverified
2445
+ // one instead of inferring it from the count.
2446
+ flipProbed: ctx.divergence.flipProbed === true,
2447
+ flipRefuted: ctx.divergence.flipRefuted ?? 0,
2448
+ };
2418
2449
  return out;
2419
2450
  }
2420
2451
  async function renderDoctorHuman(ctx) {
@@ -2433,6 +2464,11 @@ async function renderDoctorHuman(ctx) {
2433
2464
  newline();
2434
2465
  return;
2435
2466
  }
2467
+ // One column, one place. An unindexed filter column that ALSO diverges is one
2468
+ // problem with one remedy (the index), so the divergence evidence renders as
2469
+ // an extra block on the missing-index finding rather than as a second,
2470
+ // unrelated-looking entry in the cached-plan section.
2471
+ const attached = attachDivergenceToMissingIndexes(findings, divergence);
2436
2472
  if (findings.length > 0) {
2437
2473
  warn(`Found ${bold(String(findings.length))} unindexed relation probe(s)`);
2438
2474
  newline();
@@ -2440,10 +2476,10 @@ async function renderDoctorHuman(ctx) {
2440
2476
  console.log(` ${dim('once per parent row, so an unindexed FK costs a full table scan PER PARENT.')}`);
2441
2477
  newline();
2442
2478
  if (usable) {
2443
- renderTiers(findings, snapshot, args);
2479
+ renderTiers(findings, snapshot, args, attached);
2444
2480
  }
2445
2481
  else {
2446
- renderTopologyFallback(findings, snapshot);
2482
+ renderTopologyFallback(findings, snapshot, attached);
2447
2483
  }
2448
2484
  // Heat honesty: one line when the workload-heat boost could not be sourced.
2449
2485
  if (!heat.available && heat.notice) {
@@ -2452,7 +2488,7 @@ async function renderDoctorHuman(ctx) {
2452
2488
  }
2453
2489
  }
2454
2490
  renderInvalidIndexes(invalid);
2455
- renderPlanDivergence(divergence);
2491
+ renderPlanDivergence(divergence, attached);
2456
2492
  if (subtract.unusedRan) {
2457
2493
  renderUnusedIndexes(subtract.unused, subtract.minScans, snapshot);
2458
2494
  renderRedundantIndexes(subtract.redundant);
@@ -2549,8 +2585,96 @@ function renderDoctorAudit(audit, minScans, snapshot) {
2549
2585
  console.log(` ${dim('Consider dropping the ones you confirm are unused. Nothing here is auto-dropped.')}`);
2550
2586
  newline();
2551
2587
  }
2588
+ function attachDivergenceToMissingIndexes(findings, divergence) {
2589
+ const byColumn = new Map();
2590
+ for (const d of divergence.findings) {
2591
+ if (d.branch !== 'unindexed-filter')
2592
+ continue;
2593
+ byColumn.set(`${d.table}\u0000${d.column}`, d);
2594
+ }
2595
+ const attached = new Map();
2596
+ for (const f of findings) {
2597
+ // Single-column probes only: a composite probe's index is not the thing the
2598
+ // single-column divergence model reasons about.
2599
+ if (f.missing.columns.length !== 1 || f.missing.columns[0] === undefined)
2600
+ continue;
2601
+ const key = `${f.missing.table}\u0000${f.missing.columns[0]}`;
2602
+ const d = byColumn.get(key);
2603
+ if (d)
2604
+ attached.set(key, d);
2605
+ }
2606
+ return attached;
2607
+ }
2608
+ /** The key a missing-index finding is looked up by in {@link AttachedDivergence}. */
2609
+ function attachKey(f) {
2610
+ return `${f.missing.table}\u0000${f.missing.columns[0] ?? ''}`;
2611
+ }
2612
+ /**
2613
+ * The cached-plan evidence block printed UNDER a missing-index finding.
2614
+ *
2615
+ * It never recommends `forceCustomPlan`: the remedy is the index the same
2616
+ * finding already prints, and recommending a per-query plan-cache override for a
2617
+ * missing index would be advice to paper over a table scan.
2618
+ */
2619
+ /**
2620
+ * How big an `unindexed-filter` flip is, and under what condition, as plain
2621
+ * lines both branch-B renderers print.
2622
+ *
2623
+ * The condition is not decoration. The generic plan's cost is one heap fetch per
2624
+ * index entry, so the ratio is the table's rows-per-page when the heap is not in
2625
+ * `orderColumn` order and ~1x when it is: 80x and 1.2x on two fixtures identical
2626
+ * in every scored input. An earlier revision printed the ratio unconditionally
2627
+ * and quoted the FILTER column's correlation next to a sentence about the ORDER
2628
+ * column's physical order, so the one field offered as the reader's escape hatch
2629
+ * was measured on the wrong column.
2630
+ */
2631
+ function divergenceAmplificationLines(d) {
2632
+ const amp = divInt(d.worstCaseAmplification ?? 0);
2633
+ const corr = d.orderColumnCorrelation;
2634
+ const corrLabel = corr === null || corr === undefined
2635
+ ? `no pg_stats correlation available for "${d.orderColumn}"`
2636
+ : `correlation ${corr.toFixed(5)} on "${d.orderColumn}"`;
2637
+ if (d.heapNearlyOrdered === true) {
2638
+ return [
2639
+ `The size of that flip turns on the heap's physical order, and THIS heap is in near-exact`,
2640
+ `"${d.orderColumn}" order (${corrLabel}), so consecutive index entries hit the`,
2641
+ `same pinned page: measured ~1x, not the ~${amp}x an unordered heap reads. Most likely this`,
2642
+ `one is not costing you anything today. It is also one sampled statistic away from the`,
2643
+ `much worse reading, so measure rather than assume in either direction.`,
2644
+ ];
2645
+ }
2646
+ return [
2647
+ `That costs ~${amp}x the buffers of the seq scan, because each index entry is its own heap`,
2648
+ `fetch (${corrLabel}). The one shape that reads ~1x instead is a heap`,
2649
+ `in near-exact "${d.orderColumn}" order; two pages of local disorder already reads ~41x.`,
2650
+ ];
2651
+ }
2652
+ function renderDivergenceEvidence(d) {
2653
+ const tuples = divInt(d.tuplesWalked ?? d.rows);
2654
+ console.log(` ${dim(symbols.tee)} ${yellow('cached-plan risk:')} this unindexed filter column can also flip a cached plan.`);
2655
+ console.log(` ${dim(`${divInt(d.rows)} rows in ${divInt(d.pages)} pages, rarest value ~${divInt(d.rarestBucket)} rows, below the assumed LIMIT ${d.assumedLimit}.`)}`);
2656
+ console.log(` ${dim(`Without the index the good plan is a seq scan (${divInt(d.pages)} pages); a promoted generic`)}`);
2657
+ console.log(` ${dim(`plan cannot see the value is rare, keeps the ordered "${d.orderColumn}" walk, and reads`)}`);
2658
+ console.log(` ${dim(`up to ~${tuples} tuples before it fills the LIMIT.`)}`);
2659
+ console.log(` ${dim('Postgres promotes this shape exactly when the workload keeps asking for the rare')}`);
2660
+ console.log(` ${dim('value: that is the case where the custom plan is expensive enough for the generic')}`);
2661
+ console.log(` ${dim('estimate to look cheaper.')}`);
2662
+ for (const line of divergenceAmplificationLines(d))
2663
+ console.log(` ${dim(line)}`);
2664
+ console.log(` ${dim('Adding the index above is the fix. Confirm first if you want to:')}`);
2665
+ for (const line of d.diagnosticSql.split('\n')) {
2666
+ console.log(` ${green(line)}`);
2667
+ }
2668
+ console.log(` ${dim('After adding this index, re-run doctor. This column is expected to reappear as a')}`);
2669
+ console.log(` ${dim('sparse-value finding in the cached-plan section. That later finding is exposure, not')}`);
2670
+ console.log(` ${dim('a regression: the index makes the good plan much cheaper, which is why the ratio it')}`);
2671
+ console.log(` ${dim('quotes is larger, and on a measured fixture it is also what stops Postgres from')}`);
2672
+ console.log(` ${dim('promoting the generic plan at all. Treat the reappearance as the normal end state;')}`);
2673
+ console.log(` ${dim("use the diagnostic block's generic_plans counter to decide whether anything more is")}`);
2674
+ console.log(` ${dim('warranted.')}`);
2675
+ }
2552
2676
  /** Cost-aware tiered output: three sections, each finding annotated with its numbers. */
2553
- function renderTiers(findings, snapshot, _args) {
2677
+ function renderTiers(findings, snapshot, _args, attached) {
2554
2678
  const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
2555
2679
  console.log(` ${dim(`Cost triage based on live statistics (stats reset ${ageLabel} ago). Thresholds: tiny < ${STATS_THRESHOLDS.tinyTableRows.toLocaleString()} rows,`)}`);
2556
2680
  console.log(` ${dim(`"real" write rate >= ${STATS_THRESHOLDS.highWritesPerDay.toLocaleString()}/day, "many" indexes >= ${STATS_THRESHOLDS.manyIndexes}.`)}`);
@@ -2569,12 +2693,12 @@ function renderTiers(findings, snapshot, _args) {
2569
2693
  console.log(` ${bold(tierColor[tier](`${TIER_LABEL[tier]} (${inTier.length})`))}`);
2570
2694
  newline();
2571
2695
  for (const f of inTier) {
2572
- renderFinding(f, { concurrently: true, withReasons: true });
2696
+ renderFinding(f, { concurrently: true, withReasons: true, divergence: attached.get(attachKey(f)) });
2573
2697
  }
2574
2698
  }
2575
2699
  }
2576
2700
  /** Degraded output: today's size-sorted topology report when stats are absent/young. */
2577
- function renderTopologyFallback(findings, snapshot) {
2701
+ function renderTopologyFallback(findings, snapshot, attached) {
2578
2702
  warn('Statistics unavailable or too young to score cost: showing size-sorted topology only.');
2579
2703
  for (const notice of snapshot.notices)
2580
2704
  console.log(` ${dim(`- ${notice}`)}`);
@@ -2584,7 +2708,7 @@ function renderTopologyFallback(findings, snapshot) {
2584
2708
  newline();
2585
2709
  const sorted = [...findings].sort((a, b) => (b.score.metrics.rows ?? 0) - (a.score.metrics.rows ?? 0));
2586
2710
  for (const f of sorted) {
2587
- renderFinding(f, { concurrently: false, withReasons: false });
2711
+ renderFinding(f, { concurrently: false, withReasons: false, divergence: attached.get(attachKey(f)) });
2588
2712
  }
2589
2713
  }
2590
2714
  /** Render one finding: table + columns, probing relations, reasons, and the create SQL. */
@@ -2602,7 +2726,10 @@ function renderFinding(f, opts) {
2602
2726
  console.log(` ${dim(symbols.tee)} ${dim(reason)}`);
2603
2727
  }
2604
2728
  }
2605
- console.log(` ${dim(symbols.teeEnd)} ${green(doctorCreateSql(f, { concurrently: opts.concurrently }))}`);
2729
+ const last = opts.divergence ? symbols.tee : symbols.teeEnd;
2730
+ console.log(` ${dim(last)} ${green(doctorCreateSql(f, { concurrently: opts.concurrently }))}`);
2731
+ if (opts.divergence)
2732
+ renderDivergenceEvidence(opts.divergence);
2606
2733
  newline();
2607
2734
  }
2608
2735
  /** The invalid-index report section (a failed CONCURRENTLY build leaves these behind). */
@@ -2618,6 +2745,42 @@ function renderInvalidIndexes(invalid) {
2618
2745
  newline();
2619
2746
  }
2620
2747
  }
2748
+ /**
2749
+ * The release in which `turbine-orm/prisma-compat` began forwarding Turbine-only
2750
+ * query options (`forceCustomPlan` among them) to the core client.
2751
+ *
2752
+ * Printed rather than assumed, and the sentence stays even after that release:
2753
+ * doctor's audience routinely runs a CLI newer than the library pinned in the
2754
+ * app, and on an older library the option is accepted and silently ignored.
2755
+ */
2756
+ const COMPAT_PASSTHROUGH_VERSION = '0.57.0';
2757
+ /**
2758
+ * The gates + scored-population footer. Split out because it is printed from two
2759
+ * places: the normal section, and the case where every finding was attached to a
2760
+ * missing-index finding instead.
2761
+ */
2762
+ function renderDivergenceGates(divergence) {
2763
+ const t = PLAN_DIVERGENCE_THRESHOLDS;
2764
+ console.log(` ${dim(`Gates, indexed column: the wrong plan must walk >= ${t.minWalkPages} pages and >= ${Math.round(t.minWalkFraction * 100)}% of the table.`)}`);
2765
+ console.log(` ${dim(`Gates, unindexed column: the rarest value must hold fewer rows than the limit, and the wrong`)}`);
2766
+ console.log(` ${dim(`plan must walk >= ${t.minGenericTupleWalk.toLocaleString('en-US')} tuples. Assumed LIMIT ${t.assumedLimit} throughout.`)}`);
2767
+ console.log(` ${dim(`${divergence.candidatesConsidered} column(s) were scored (${divergence.consideredIndexed} indexed, ${divergence.consideredUnindexed} unindexed). That population is relation-probe`)}`);
2768
+ console.log(` ${dim('and leading-index columns only: a filter column that is neither is not covered. Skip this')}`);
2769
+ console.log(` ${dim('section with --no-plan-divergence.')}`);
2770
+ // Say whether the unindexed findings were verified, and what verification
2771
+ // removed. Statistics can only say how bad a flip would be; a plan-only EXPLAIN
2772
+ // says whether the planner can reach it at all.
2773
+ if (divergence.flipProbed === true) {
2774
+ const refuted = divergence.flipRefuted ?? 0;
2775
+ const wereRefuted = refuted === 1 ? '1 was refuted' : `${refuted} were refuted`;
2776
+ console.log(` ${dim(`Every unindexed finding was put to the planner (EXPLAIN, nothing executed); ${wereRefuted}`)}`);
2777
+ console.log(` ${dim('because the generic plan keeps the same sequential scan, so no flip is reachable.')}`);
2778
+ }
2779
+ else if (divergence.consideredUnindexed > 0) {
2780
+ console.log(` ${dim('Unindexed findings are UNVERIFIED here: the planner probe did not run, so some may name a')}`);
2781
+ console.log(` ${dim('divergence the planner would never choose.')}`);
2782
+ }
2783
+ }
2621
2784
  /** Round to a whole number and group it, for the divergence report's estimates. */
2622
2785
  function divInt(n) {
2623
2786
  if (!Number.isFinite(n))
@@ -2630,8 +2793,26 @@ function divInt(n) {
2630
2793
  * application code (scope the plan-cache mode to the affected reads), and the
2631
2794
  * index that looks like a fix is measured NOT to be one.
2632
2795
  */
2633
- function renderPlanDivergence(divergence) {
2634
- const { findings, notices } = divergence;
2796
+ function renderPlanDivergence(divergence, attached) {
2797
+ const { notices } = divergence;
2798
+ // Anything already rendered as evidence on a missing-index finding is NOT
2799
+ // repeated here: one column, one problem, one remedy.
2800
+ const rendered = new Set(attached.values());
2801
+ const findings = divergence.findings.filter((f) => !rendered.has(f));
2802
+ // Every divergence finding was attached above, so this section has no entries
2803
+ // of its own. The pointer, the gates and the scored population still belong in
2804
+ // the report: they are output of THIS check, and a reader must be able to tell
2805
+ // "considered and clean" from "never looked".
2806
+ //
2807
+ // Printed BEFORE the notices rather than inside an early return: an early
2808
+ // return that also required `notices.length === 0` dropped both blocks
2809
+ // whenever any candidate lacked a pg_stats row, which is the normal reason a
2810
+ // notice exists.
2811
+ if (findings.length === 0 && rendered.size > 0) {
2812
+ console.log(` ${dim(`Cached-plan divergence: ${rendered.size} finding(s), shown with the index findings above.`)}`);
2813
+ renderDivergenceGates(divergence);
2814
+ newline();
2815
+ }
2635
2816
  if (findings.length === 0 && notices.length === 0)
2636
2817
  return;
2637
2818
  if (findings.length > 0) {
@@ -2644,16 +2825,39 @@ function renderPlanDivergence(divergence) {
2644
2825
  console.log(` ${dim('land on the other side of a plan boundary from the real value, the plan flips.')}`);
2645
2826
  newline();
2646
2827
  }
2828
+ const analyzedLabel = (f) => f.lastAnalyze === null
2829
+ ? 'last ANALYZE unknown'
2830
+ : `last analyzed ${Math.max(0, Math.round((Date.now() - f.lastAnalyze.getTime()) / 86_400_000))}d ago`;
2647
2831
  for (const f of findings) {
2832
+ if (f.branch === 'unindexed-filter') {
2833
+ // Only reached when the column has no missing-index finding to hang this
2834
+ // on (an index that exists but cannot serve the equality: partial,
2835
+ // expression, or non-btree). The remedy is still an index, not a
2836
+ // plan-cache setting, so this entry never suggests forceCustomPlan.
2837
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${f.table}.${f.column}`))} ${gray('UNINDEXED-FILTER FLIP')}`);
2838
+ console.log(` ${dim(symbols.tee)} ${divInt(f.rows)} rows in ${divInt(f.pages)} pages, rarest value ~${bold(divInt(f.rarestBucket))} rows, below the assumed LIMIT ${f.assumedLimit}`);
2839
+ console.log(` ${dim(symbols.tee)} no index serves ${f.column} = $1, so the good plan is a seq scan (${divInt(f.pages)} pages);`);
2840
+ console.log(` ${dim(`a promoted generic plan keeps the ordered "${f.orderColumn}" walk and reads up to ~${divInt(f.tuplesWalked ?? f.rows)} tuples`)}`);
2841
+ console.log(` ${dim('before it fills the LIMIT.')}`);
2842
+ for (const line of divergenceAmplificationLines(f))
2843
+ console.log(` ${dim(line)}`);
2844
+ console.log(` ${dim(symbols.tee)} ${dim(`filter-column correlation ${f.correlation.toFixed(2)}, ${analyzedLabel(f)}`)}`);
2845
+ console.log(` ${dim(symbols.tee)} ${dim('the fix is an index that can serve this equality. A partial or expression index')}`);
2846
+ console.log(` ${dim('on the column does not: the planner has no path for the bare predicate. A hash')}`);
2847
+ console.log(` ${dim('index does, and a column served by one is scored by the other rule instead.')}`);
2848
+ console.log(` ${dim(symbols.teeEnd)} ${dim('confirm with YOUR values before changing anything:')}`);
2849
+ for (const line of f.diagnosticSql.split('\n')) {
2850
+ console.log(` ${green(line)}`);
2851
+ }
2852
+ newline();
2853
+ continue;
2854
+ }
2648
2855
  console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${f.table}.${f.column}`))} ${gray('SPARSE-VALUE FLIP')}`);
2649
2856
  console.log(` ${dim(symbols.tee)} generic estimate ${bold(divInt(f.genericEstimate))} rows ${dim(`(${divInt(f.rows)} rows / ${divInt(f.distinctValues)} distinct values)`)}`);
2650
2857
  console.log(` ${dim(symbols.tee)} rarest value bucket ${bold(divInt(f.rarestBucket))} rows ${dim('(pg_stats most_common_freqs / residual bucket)')}`);
2651
- console.log(` ${dim(symbols.tee)} crossover ${bold(divInt(f.crossoverRows))} rows ${dim(`(sqrt(limit ${f.assumedLimit} x ${divInt(f.pages)} pages); ${divInt(f.crossoverRowsWide)} at limit ${f.thresholds.wideLimit})`)}`);
2652
- const analyzed = f.lastAnalyze === null
2653
- ? 'last ANALYZE unknown'
2654
- : `last analyzed ${Math.max(0, Math.round((Date.now() - f.lastAnalyze.getTime()) / 86_400_000))}d ago`;
2655
- console.log(` ${dim(symbols.tee)} ${dim(`values below the crossover: ${divInt(f.valuesBelowCrossover)} of ${divInt(f.distinctValues)}, correlation ${f.correlation.toFixed(2)}, ${analyzed}`)}`);
2656
- console.log(` ${dim(symbols.tee)} for such a value the generic plan walks ~${bold(divInt(f.walkPages))} of ${divInt(f.pages)} pages ${dim(`(${Math.round(f.walkFraction * 100)}% of the table)`)}`);
2858
+ console.log(` ${dim(symbols.tee)} crossover ${bold(divInt(f.crossoverRows ?? 0))} rows ${dim(`(sqrt(limit ${f.assumedLimit} x ${divInt(f.pages)} pages); ${divInt(f.crossoverRowsWide ?? 0)} at limit ${f.thresholds.wideLimit})`)}`);
2859
+ console.log(` ${dim(symbols.tee)} ${dim(`values below the crossover: ${divInt(f.valuesBelowCrossover ?? 0)} of ${divInt(f.distinctValues)}, filter-column correlation ${f.correlation.toFixed(2)}, ${analyzedLabel(f)}`)}`);
2860
+ console.log(` ${dim(symbols.tee)} for such a value the generic plan walks ~${bold(divInt(f.walkPages ?? 0))} of ${divInt(f.pages)} pages ${dim(`(${Math.round((f.walkFraction ?? 0) * 100)}% of the table)`)}`);
2657
2861
  console.log(` ${dim(`for reads shaped WHERE ${f.column} = $1 ORDER BY ${f.orderColumn} LIMIT $n,`)}`);
2658
2862
  console.log(` ${dim("where the custom plan reads only that value's own rows.")}`);
2659
2863
  console.log(` ${dim(symbols.tee)} ${dim('No amplification figure is printed, deliberately. This models how many rows a')}`);
@@ -2666,6 +2870,7 @@ function renderPlanDivergence(divergence) {
2666
2870
  newline();
2667
2871
  }
2668
2872
  if (findings.length > 0) {
2873
+ const first = findings.find((f) => f.branch === 'sparse-value');
2669
2874
  console.log(` ${bold('What to do, in order:')}`);
2670
2875
  console.log(` 1. Check that this shape is promoted AT ALL. Step 1 of the block above: while`);
2671
2876
  console.log(` ${dim('generic_plans is 0, Postgres is planning with your real values and there is nothing')}`);
@@ -2673,27 +2878,68 @@ function renderPlanDivergence(divergence) {
2673
2878
  console.log(` 2. If it does promote, compare the two plans. Both SETs matter: without them a`);
2674
2879
  console.log(` ${dim('repeated seq scan resumes where the last one stopped and a catastrophic case reads')}`);
2675
2880
  console.log(` ${dim('as harmless.')}`);
2676
- console.log(` 3. If the flip is real, scope the fix to those reads:`);
2677
- const first = findings[0];
2678
- console.log(` ${cyan(`db.${first.table}.findMany({ where: { ${first.columnField}: value }, orderBy: { ${first.orderColumnField}: 'asc' },`)}`);
2679
- console.log(` ${cyan(` limit: 20, forceCustomPlan: true })`)}`);
2680
- console.log(` ${dim('That withholds the prepared-statement NAME for that one query, so the driver')}`);
2681
- console.log(` ${dim('re-parses it every execution and it is always planned with the real values. No')}`);
2682
- console.log(` ${dim('GUC, no SET LOCAL, no transaction, no extra round trip.')}`);
2683
- console.log(` 4. Do NOT set planCacheMode on the client to fix this. There are measured shapes`);
2684
- console.log(` ${dim('where a generic plan is dramatically better (an unordered LIMIT over a value whose')}`);
2685
- console.log(` ${dim('rows are packed at the end of the heap: 4,262 buffers custom vs 71 generic on a')}`);
2686
- console.log(` ${dim('reproducible fixture). A custom plan is not automatically the better plan.')}`);
2687
- console.log(` 5. A composite index on (${first.column}, ${first.orderColumn}) makes the GOOD plan better. It`);
2688
- console.log(` ${dim('does NOT stop the generic plan from choosing the other one, and it can widen the gap.')}`);
2689
- console.log(` ${dim('Add it for the custom-plan win, not as a fix for this finding.')}`);
2881
+ if (first) {
2882
+ // The OPTION is named first and both call shapes follow, so no step
2883
+ // assumes which client the reader is holding. The compat example uses
2884
+ // Prisma's `take`: printing `limit` there would be a second wrong
2885
+ // instruction, since `limit` is a Turbine spelling compat does not read.
2886
+ console.log(` 3. If the flip is real, scope the fix to those reads with ${cyan('forceCustomPlan')}. It`);
2887
+ console.log(` ${dim('withholds the prepared-statement NAME for that one query, so the driver re-parses')}`);
2888
+ console.log(` ${dim('it every execution and it is always planned with the real values. No GUC, no SET')}`);
2889
+ console.log(` ${dim('LOCAL, no transaction, no extra round trip.')}`);
2890
+ // Hanging indent rather than an alignment that pretends to line up: the
2891
+ // call's own width depends on the table name, so a fixed padding column
2892
+ // misaligns on every schema but the one it was written against.
2893
+ const args = `where: { ${first.columnField}: value }, orderBy: { ${first.orderColumnField}: 'asc' },`;
2894
+ // The accessor is the camelCase FIELD spelling, not the raw table name:
2895
+ // TurbineClient and the code generator both define table accessors through
2896
+ // snakeToCamel, so `db.inventory_location` is undefined on every
2897
+ // snake_case schema. The finding's own `columnField` / `orderColumnField`
2898
+ // are already field-space for the same reason.
2899
+ console.log(` ${dim('On the core client:')}`);
2900
+ console.log(` ${cyan(`db.${snakeToCamel(first.table)}.findMany({`)}`);
2901
+ console.log(` ${cyan(args)}`);
2902
+ console.log(` ${cyan('limit: 20, forceCustomPlan: true,')}`);
2903
+ console.log(` ${cyan('})')}`);
2904
+ console.log(` ${dim("Through turbine-orm/prisma-compat, the same option on the delegate call (Prisma's")}`);
2905
+ console.log(` ${dim('`take`, not `limit`). The Prisma MODEL name is not knowable from the schema side,')}`);
2906
+ console.log(` ${dim('so substitute your own:')}`);
2907
+ console.log(` ${cyan('compat.<Model>.findMany({')}`);
2908
+ console.log(` ${cyan(args)}`);
2909
+ console.log(` ${cyan('take: 20, forceCustomPlan: true,')}`);
2910
+ console.log(` ${cyan('})')}`);
2911
+ console.log(` ${dim(`The compat passthrough requires turbine >= ${COMPAT_PASSTHROUGH_VERSION}. On an older version the option`)}`);
2912
+ console.log(` ${dim('is accepted and ignored there, so confirm at the wire with the same')}`);
2913
+ console.log(` ${dim('pg_prepared_statements check in step 1 rather than assuming it took effect.')}`);
2914
+ console.log(` 4. Reaching for a database-wide plan_cache_mode is not the fix, whichever client you`);
2915
+ console.log(` ${dim('use. There are measured shapes where a generic plan is dramatically better: an')}`);
2916
+ console.log(` ${dim('unordered LIMIT over a value whose rows are packed at the end of the heap reads')}`);
2917
+ console.log(` ${dim('4,262 buffers under a custom plan against 71 under a generic one. That fixture is')}`);
2918
+ console.log(` ${dim('printed in full at turbineorm.dev/relations, so the number is checkable rather')}`);
2919
+ console.log(` ${dim('than asserted. Pinning every statement')}`);
2920
+ console.log(` ${dim('in one direction trades this finding for its mirror image. That applies equally to')}`);
2921
+ console.log(` ${dim("Turbine's client-level `planCacheMode` and to a SET or ALTER ROLE applied outside")}`);
2922
+ console.log(` ${dim('Turbine.')}`);
2923
+ console.log(` 5. A composite index on (${first.column}, ${first.orderColumn}) makes the GOOD plan better. It`);
2924
+ console.log(` ${dim('does NOT stop the generic plan from choosing the other one, and it can widen the gap.')}`);
2925
+ console.log(` ${dim('Add it for the custom-plan win, not as a fix for this finding.')}`);
2926
+ }
2927
+ // Stated separately because the first remedy genuinely differs by branch: an
2928
+ // UNINDEXED column's flip is fixed by the index, and a per-query plan-cache
2929
+ // override there would only paper over a table scan.
2930
+ if (findings.some((f) => f.branch === 'unindexed-filter')) {
2931
+ console.log(` ${first ? 6 : 3}. A finding on an UNINDEXED column has a different FIRST remedy: add an index that`);
2932
+ console.log(` ${dim('serves the equality, then re-run doctor and re-score. The index moves the')}`);
2933
+ console.log(` ${dim('divergence in both directions at once (it makes the good plan much cheaper, which')}`);
2934
+ console.log(` ${dim('widens the ratio, and on a measured fixture it also stopped Postgres promoting the')}`);
2935
+ console.log(` ${dim('generic plan at all), so do not assume the finding is closed by adding it.')}`);
2936
+ }
2690
2937
  newline();
2691
2938
  console.log(` ${dim('This finding is derived from statistics, not from your traffic: it says the DISTRIBUTION')}`);
2692
- console.log(` ${dim('admits a damaging flip, not that a query is running one today. It models ONE shape,')}`);
2693
- console.log(` ${dim('the rare value that loses its bitmap plan. It cannot see where a value physically')}`);
2694
- console.log(` ${dim('sits in the heap, so a clean report is not evidence of immunity.')}`);
2695
- console.log(` ${dim(`Gates: the wrong plan must walk >= ${PLAN_DIVERGENCE_THRESHOLDS.minWalkPages} pages and >= ${Math.round(PLAN_DIVERGENCE_THRESHOLDS.minWalkFraction * 100)}% of the table, at an assumed`)}`);
2696
- console.log(` ${dim(`LIMIT ${PLAN_DIVERGENCE_THRESHOLDS.assumedLimit}. ${divergence.candidatesConsidered} column(s) were scored. Skip this section with --no-plan-divergence.`)}`);
2939
+ console.log(` ${dim('admits a damaging flip, not that a query is running one today. It cannot see where a')}`);
2940
+ console.log(` ${dim('value physically sits in the heap, so a clean report is not evidence of immunity, and')}`);
2941
+ console.log(` ${dim('a column that is neither an FK nor indexed is not in the scored population at all.')}`);
2942
+ renderDivergenceGates(divergence);
2697
2943
  newline();
2698
2944
  }
2699
2945
  if (notices.length > 0) {
package/dist/client.js CHANGED
@@ -27,7 +27,7 @@ import { setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationE
27
27
  import { ObserveEngine } from './observe.js';
28
28
  import { executePipeline, pipelineSupported } from './pipeline.js';
29
29
  import { QueryInterface, } from './query/index.js';
30
- import { closestName, markTurbineParser, quoteIdent, registerUtcTemporalParsers, warnParserOverwrite, } from './query/utils.js';
30
+ import { markTurbineParser, quoteIdent, registerUtcTemporalParsers, suggestKey, warnParserOverwrite, } from './query/utils.js';
31
31
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
32
32
  import { createSubscription, validateChannel, } from './realtime.js';
33
33
  import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
@@ -141,50 +141,14 @@ const CONFIG_KEY_SET = new Set(Object.keys(TURBINE_CONFIG_KEYS));
141
141
  * engine factories' first argument. Same story as `schema`.
142
142
  */
143
143
  const NON_CONFIG_KEYS = new Set(['queryInterfaceFactory', 'schema', 'url']);
144
- /** camelCase name → its lowercased words (`logQueryParams` → log, query, params). */
145
- function camelWords(name) {
146
- return name
147
- .split(/(?=[A-Z])/)
148
- .map((w) => w.toLowerCase())
149
- .filter(Boolean);
150
- }
151
144
  /**
152
145
  * The real config key `key` most likely meant, or null when nothing is close.
153
146
  *
154
- * {@link closestName} (the same helper the unknown-COLUMN message uses) decides
155
- * first, so both diagnostics rank near-misses identically. It is bounded by edit
156
- * distance, which covers typos but not the miss this warning exists for: a
157
- * guessed name that omits a whole word. `logParams` is five edits from
158
- * `logQueryParams`, past the bound, yet it names the same words in the same
159
- * order, so a second pass accepts a candidate whose camelCase words CONTAIN the
160
- * guess's words in order, preferring the one that adds fewest words.
147
+ * {@link suggestKey} is shared with the prisma-compat query-option warner, so
148
+ * both diagnostics rank near-misses identically.
161
149
  */
162
150
  function suggestConfigKey(key) {
163
- const direct = closestName(key, CONFIG_KEY_SET);
164
- if (direct)
165
- return direct;
166
- const wanted = camelWords(key);
167
- if (wanted.length < 2)
168
- return null;
169
- let best = null;
170
- let bestExtra = Number.POSITIVE_INFINITY;
171
- for (const candidate of CONFIG_KEY_SET) {
172
- const words = camelWords(candidate);
173
- if (words.length <= wanted.length)
174
- continue;
175
- let i = 0;
176
- for (const w of words)
177
- if (w === wanted[i])
178
- i++;
179
- if (i !== wanted.length)
180
- continue;
181
- const extra = words.length - wanted.length;
182
- if (extra < bestExtra) {
183
- bestExtra = extra;
184
- best = candidate;
185
- }
186
- }
187
- return best;
151
+ return suggestKey(key, CONFIG_KEY_SET);
188
152
  }
189
153
  /**
190
154
  * Dev-mode notice for a key on the config object that is not part of the config