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.
- package/README.md +16 -1
- package/dist/cjs/cli/index.d.ts +3 -1
- package/dist/cjs/cli/index.js +341 -14
- package/dist/cjs/client.d.ts +16 -1
- package/dist/cjs/client.js +7 -39
- package/dist/cjs/dialect.d.ts +9 -0
- package/dist/cjs/index-stats.d.ts +46 -0
- package/dist/cjs/index-stats.js +42 -1
- package/dist/cjs/plan-divergence.d.ts +511 -0
- package/dist/cjs/plan-divergence.js +790 -0
- package/dist/cjs/powql.d.ts +11 -0
- package/dist/cjs/powql.js +22 -0
- package/dist/cjs/prisma-compat.d.ts +32 -1
- package/dist/cjs/prisma-compat.js +297 -41
- package/dist/cjs/query/builder.d.ts +45 -0
- package/dist/cjs/query/builder.js +90 -17
- package/dist/cjs/query/deferred.d.ts +9 -0
- package/dist/cjs/query/index.d.ts +2 -0
- package/dist/cjs/query/index.js +18 -1
- package/dist/cjs/query/option-surface.d.ts +100 -0
- package/dist/cjs/query/option-surface.js +214 -0
- package/dist/cjs/query/types.d.ts +140 -0
- package/dist/cjs/query/utils.d.ts +30 -0
- package/dist/cjs/query/utils.js +67 -3
- package/dist/cjs/query/warn-registry.d.ts +8 -0
- package/dist/cjs/query/warn-registry.js +8 -0
- package/dist/cli/index.d.ts +3 -1
- package/dist/cli/index.js +341 -14
- package/dist/client.d.ts +16 -1
- package/dist/client.js +8 -40
- package/dist/dialect.d.ts +9 -0
- package/dist/index-stats.d.ts +46 -0
- package/dist/index-stats.js +42 -1
- package/dist/plan-divergence.d.ts +511 -0
- package/dist/plan-divergence.js +783 -0
- package/dist/powql.d.ts +11 -0
- package/dist/powql.js +22 -0
- package/dist/prisma-compat.d.ts +32 -1
- package/dist/prisma-compat.js +297 -41
- package/dist/query/builder.d.ts +45 -0
- package/dist/query/builder.js +90 -17
- package/dist/query/deferred.d.ts +9 -0
- package/dist/query/index.d.ts +2 -0
- package/dist/query/index.js +1 -0
- package/dist/query/option-surface.d.ts +100 -0
- package/dist/query/option-surface.js +209 -0
- package/dist/query/types.d.ts +140 -0
- package/dist/query/utils.d.ts +30 -0
- package/dist/query/utils.js +66 -3
- package/dist/query/warn-registry.d.ts +8 -0
- package/dist/query/warn-registry.js +8 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* turbine migrate status , Show migration status
|
|
15
15
|
* turbine seed , Run seed file
|
|
16
16
|
* turbine status , Show schema summary
|
|
17
|
-
* turbine doctor -
|
|
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)
|
|
@@ -32,6 +32,8 @@ 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, collectDivergenceOrderColumns, findPlanDivergence, PLAN_DIVERGENCE_THRESHOLDS, } from '../plan-divergence.js';
|
|
36
|
+
import { snakeToCamel } from '../schema.js';
|
|
35
37
|
import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
|
|
36
38
|
import { configTemplate, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
|
|
37
39
|
import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
|
|
@@ -142,6 +144,9 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
142
144
|
result.metricsUrl = next;
|
|
143
145
|
i++;
|
|
144
146
|
break;
|
|
147
|
+
case '--no-plan-divergence':
|
|
148
|
+
result.noPlanDivergence = true;
|
|
149
|
+
break;
|
|
145
150
|
case '--zod':
|
|
146
151
|
result.zod = true;
|
|
147
152
|
break;
|
|
@@ -2259,20 +2264,33 @@ async function cmdDoctor(args, config) {
|
|
|
2259
2264
|
const missing = findMissingRelationIndexes(schema);
|
|
2260
2265
|
// Collect live statistics. The collector reads whole-schema indexes (for
|
|
2261
2266
|
// invalid-index detection) plus per-table stats + probed-column null_frac.
|
|
2262
|
-
const probedTables = [...new Set(missing.map((m) => m.table))];
|
|
2263
2267
|
const probedColumns = [];
|
|
2264
2268
|
for (const m of missing) {
|
|
2265
2269
|
if (m.columns.length === 1 && m.columns[0] !== undefined) {
|
|
2266
2270
|
probedColumns.push({ table: m.table, column: m.columns[0] });
|
|
2267
2271
|
}
|
|
2268
2272
|
}
|
|
2273
|
+
// Plan-divergence candidates are the columns that ARE indexed, so their tables
|
|
2274
|
+
// are usually disjoint from the missing-index set: both lists feed the same
|
|
2275
|
+
// one-connection snapshot rather than opening a second read.
|
|
2276
|
+
const divergenceOn = args.noPlanDivergence !== true;
|
|
2277
|
+
const divergenceColumns = divergenceOn ? collectDivergenceCandidateColumns(schema) : [];
|
|
2278
|
+
// The columns a finding could ORDER BY, read alongside the candidates: the
|
|
2279
|
+
// size of an unindexed-filter flip turns on the ORDER column's correlation,
|
|
2280
|
+
// not the filter column's, and reading only the latter is how an earlier
|
|
2281
|
+
// revision printed a statistic about the wrong column.
|
|
2282
|
+
const divergenceOrderColumns = divergenceOn ? collectDivergenceOrderColumns(schema) : [];
|
|
2283
|
+
const probedTables = [...new Set(missing.map((m) => m.table))];
|
|
2284
|
+
const statsTables = [...new Set([...probedTables, ...divergenceColumns.map((c) => c.table)])];
|
|
2285
|
+
const distributionColumns = [...divergenceColumns, ...divergenceOrderColumns];
|
|
2269
2286
|
let snapshot;
|
|
2270
2287
|
try {
|
|
2271
2288
|
snapshot = await collectStatsSnapshot({
|
|
2272
2289
|
connectionString: url,
|
|
2273
2290
|
schema: config.schema,
|
|
2274
|
-
tables:
|
|
2291
|
+
tables: statsTables,
|
|
2275
2292
|
columns: probedColumns,
|
|
2293
|
+
distributionColumns,
|
|
2276
2294
|
});
|
|
2277
2295
|
}
|
|
2278
2296
|
catch (err) {
|
|
@@ -2311,9 +2329,16 @@ async function cmdDoctor(args, config) {
|
|
|
2311
2329
|
? auditDoctorIndexes(snapshot, collectDoctorProbeIndexNames(schema), { minScans, relationProbes })
|
|
2312
2330
|
: [];
|
|
2313
2331
|
const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
|
|
2332
|
+
// Plan divergence has its OWN freshness gate. The cost tiers require a
|
|
2333
|
+
// trustworthy stats_reset age because they normalize write counters by it;
|
|
2334
|
+
// this check reads no counter, only pg_stats, whose freshness is ANALYZE. A
|
|
2335
|
+
// cluster with a NULL stats_reset (the default) must still get the check.
|
|
2336
|
+
const divergence = divergenceOn && snapshot.available
|
|
2337
|
+
? findPlanDivergence(schema, snapshot)
|
|
2338
|
+
: { findings: [], notices: [], candidatesConsidered: 0, consideredIndexed: 0, consideredUnindexed: 0 };
|
|
2314
2339
|
if (jsonMode) {
|
|
2315
2340
|
spinner?.stop();
|
|
2316
|
-
console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, args }), null, 2));
|
|
2341
|
+
console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, divergence, args }), null, 2));
|
|
2317
2342
|
return;
|
|
2318
2343
|
}
|
|
2319
2344
|
await renderDoctorHuman({
|
|
@@ -2325,6 +2350,7 @@ async function cmdDoctor(args, config) {
|
|
|
2325
2350
|
usable,
|
|
2326
2351
|
heat,
|
|
2327
2352
|
subtract,
|
|
2353
|
+
divergence,
|
|
2328
2354
|
args,
|
|
2329
2355
|
config,
|
|
2330
2356
|
});
|
|
@@ -2392,13 +2418,25 @@ function buildDoctorJson(ctx) {
|
|
|
2392
2418
|
out.redundant = ctx.subtract.unusedRan ? ctx.subtract.redundant : [];
|
|
2393
2419
|
out.audit = ctx.subtract.auditRan ? ctx.subtract.audit : [];
|
|
2394
2420
|
out.invalid = ctx.invalid;
|
|
2421
|
+
// Always an array, never absent: a consumer must not have to write `?? []`
|
|
2422
|
+
// just because the section was skipped or found nothing.
|
|
2423
|
+
out.planDivergence = ctx.divergence.findings;
|
|
2424
|
+
out.planDivergenceNotices = ctx.divergence.notices;
|
|
2425
|
+
// How large the scored population was, and how it split. A consumer counting
|
|
2426
|
+
// findings alone cannot tell "considered and clean" from "never looked", and
|
|
2427
|
+
// the unindexed half of that population did not exist before.
|
|
2428
|
+
out.planDivergenceScored = {
|
|
2429
|
+
considered: ctx.divergence.candidatesConsidered,
|
|
2430
|
+
indexed: ctx.divergence.consideredIndexed,
|
|
2431
|
+
unindexed: ctx.divergence.consideredUnindexed,
|
|
2432
|
+
};
|
|
2395
2433
|
return out;
|
|
2396
2434
|
}
|
|
2397
2435
|
async function renderDoctorHuman(ctx) {
|
|
2398
|
-
const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, args, config } = ctx;
|
|
2436
|
+
const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, divergence, args, config } = ctx;
|
|
2399
2437
|
spinner.succeed(`Scanned ${bold(String(Object.keys(schema.tables).length))} tables`);
|
|
2400
2438
|
const subtractRan = subtract.unusedRan || subtract.auditRan;
|
|
2401
|
-
const nothingToAdd = findings.length === 0 && invalid.length === 0;
|
|
2439
|
+
const nothingToAdd = findings.length === 0 && invalid.length === 0 && divergence.findings.length === 0;
|
|
2402
2440
|
const nothingToSubtract = subtract.unused.length === 0 && subtract.redundant.length === 0 && subtract.audit.length === 0;
|
|
2403
2441
|
if (nothingToAdd && (!subtractRan || nothingToSubtract)) {
|
|
2404
2442
|
if (subtractRan) {
|
|
@@ -2410,6 +2448,11 @@ async function renderDoctorHuman(ctx) {
|
|
|
2410
2448
|
newline();
|
|
2411
2449
|
return;
|
|
2412
2450
|
}
|
|
2451
|
+
// One column, one place. An unindexed filter column that ALSO diverges is one
|
|
2452
|
+
// problem with one remedy (the index), so the divergence evidence renders as
|
|
2453
|
+
// an extra block on the missing-index finding rather than as a second,
|
|
2454
|
+
// unrelated-looking entry in the cached-plan section.
|
|
2455
|
+
const attached = attachDivergenceToMissingIndexes(findings, divergence);
|
|
2413
2456
|
if (findings.length > 0) {
|
|
2414
2457
|
warn(`Found ${bold(String(findings.length))} unindexed relation probe(s)`);
|
|
2415
2458
|
newline();
|
|
@@ -2417,10 +2460,10 @@ async function renderDoctorHuman(ctx) {
|
|
|
2417
2460
|
console.log(` ${dim('once per parent row, so an unindexed FK costs a full table scan PER PARENT.')}`);
|
|
2418
2461
|
newline();
|
|
2419
2462
|
if (usable) {
|
|
2420
|
-
renderTiers(findings, snapshot, args);
|
|
2463
|
+
renderTiers(findings, snapshot, args, attached);
|
|
2421
2464
|
}
|
|
2422
2465
|
else {
|
|
2423
|
-
renderTopologyFallback(findings, snapshot);
|
|
2466
|
+
renderTopologyFallback(findings, snapshot, attached);
|
|
2424
2467
|
}
|
|
2425
2468
|
// Heat honesty: one line when the workload-heat boost could not be sourced.
|
|
2426
2469
|
if (!heat.available && heat.notice) {
|
|
@@ -2429,6 +2472,7 @@ async function renderDoctorHuman(ctx) {
|
|
|
2429
2472
|
}
|
|
2430
2473
|
}
|
|
2431
2474
|
renderInvalidIndexes(invalid);
|
|
2475
|
+
renderPlanDivergence(divergence, attached);
|
|
2432
2476
|
if (subtract.unusedRan) {
|
|
2433
2477
|
renderUnusedIndexes(subtract.unused, subtract.minScans, snapshot);
|
|
2434
2478
|
renderRedundantIndexes(subtract.redundant);
|
|
@@ -2525,8 +2569,96 @@ function renderDoctorAudit(audit, minScans, snapshot) {
|
|
|
2525
2569
|
console.log(` ${dim('Consider dropping the ones you confirm are unused. Nothing here is auto-dropped.')}`);
|
|
2526
2570
|
newline();
|
|
2527
2571
|
}
|
|
2572
|
+
function attachDivergenceToMissingIndexes(findings, divergence) {
|
|
2573
|
+
const byColumn = new Map();
|
|
2574
|
+
for (const d of divergence.findings) {
|
|
2575
|
+
if (d.branch !== 'unindexed-filter')
|
|
2576
|
+
continue;
|
|
2577
|
+
byColumn.set(`${d.table}\u0000${d.column}`, d);
|
|
2578
|
+
}
|
|
2579
|
+
const attached = new Map();
|
|
2580
|
+
for (const f of findings) {
|
|
2581
|
+
// Single-column probes only: a composite probe's index is not the thing the
|
|
2582
|
+
// single-column divergence model reasons about.
|
|
2583
|
+
if (f.missing.columns.length !== 1 || f.missing.columns[0] === undefined)
|
|
2584
|
+
continue;
|
|
2585
|
+
const key = `${f.missing.table}\u0000${f.missing.columns[0]}`;
|
|
2586
|
+
const d = byColumn.get(key);
|
|
2587
|
+
if (d)
|
|
2588
|
+
attached.set(key, d);
|
|
2589
|
+
}
|
|
2590
|
+
return attached;
|
|
2591
|
+
}
|
|
2592
|
+
/** The key a missing-index finding is looked up by in {@link AttachedDivergence}. */
|
|
2593
|
+
function attachKey(f) {
|
|
2594
|
+
return `${f.missing.table}\u0000${f.missing.columns[0] ?? ''}`;
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* The cached-plan evidence block printed UNDER a missing-index finding.
|
|
2598
|
+
*
|
|
2599
|
+
* It never recommends `forceCustomPlan`: the remedy is the index the same
|
|
2600
|
+
* finding already prints, and recommending a per-query plan-cache override for a
|
|
2601
|
+
* missing index would be advice to paper over a table scan.
|
|
2602
|
+
*/
|
|
2603
|
+
/**
|
|
2604
|
+
* How big an `unindexed-filter` flip is, and under what condition, as plain
|
|
2605
|
+
* lines both branch-B renderers print.
|
|
2606
|
+
*
|
|
2607
|
+
* The condition is not decoration. The generic plan's cost is one heap fetch per
|
|
2608
|
+
* index entry, so the ratio is the table's rows-per-page when the heap is not in
|
|
2609
|
+
* `orderColumn` order and ~1x when it is: 80x and 1.2x on two fixtures identical
|
|
2610
|
+
* in every scored input. An earlier revision printed the ratio unconditionally
|
|
2611
|
+
* and quoted the FILTER column's correlation next to a sentence about the ORDER
|
|
2612
|
+
* column's physical order, so the one field offered as the reader's escape hatch
|
|
2613
|
+
* was measured on the wrong column.
|
|
2614
|
+
*/
|
|
2615
|
+
function divergenceAmplificationLines(d) {
|
|
2616
|
+
const amp = divInt(d.worstCaseAmplification ?? 0);
|
|
2617
|
+
const corr = d.orderColumnCorrelation;
|
|
2618
|
+
const corrLabel = corr === null || corr === undefined
|
|
2619
|
+
? `no pg_stats correlation available for "${d.orderColumn}"`
|
|
2620
|
+
: `correlation ${corr.toFixed(5)} on "${d.orderColumn}"`;
|
|
2621
|
+
if (d.heapNearlyOrdered === true) {
|
|
2622
|
+
return [
|
|
2623
|
+
`The size of that flip turns on the heap's physical order, and THIS heap is in near-exact`,
|
|
2624
|
+
`"${d.orderColumn}" order (${corrLabel}), so consecutive index entries hit the`,
|
|
2625
|
+
`same pinned page: measured ~1x, not the ~${amp}x an unordered heap reads. Most likely this`,
|
|
2626
|
+
`one is not costing you anything today. It is also one sampled statistic away from the`,
|
|
2627
|
+
`much worse reading, so measure rather than assume in either direction.`,
|
|
2628
|
+
];
|
|
2629
|
+
}
|
|
2630
|
+
return [
|
|
2631
|
+
`That costs ~${amp}x the buffers of the seq scan, because each index entry is its own heap`,
|
|
2632
|
+
`fetch (${corrLabel}). The one shape that reads ~1x instead is a heap`,
|
|
2633
|
+
`in near-exact "${d.orderColumn}" order; two pages of local disorder already reads ~41x.`,
|
|
2634
|
+
];
|
|
2635
|
+
}
|
|
2636
|
+
function renderDivergenceEvidence(d) {
|
|
2637
|
+
const tuples = divInt(d.tuplesWalked ?? d.rows);
|
|
2638
|
+
console.log(` ${dim(symbols.tee)} ${yellow('cached-plan risk:')} this unindexed filter column can also flip a cached plan.`);
|
|
2639
|
+
console.log(` ${dim(`${divInt(d.rows)} rows in ${divInt(d.pages)} pages, rarest value ~${divInt(d.rarestBucket)} rows, below the assumed LIMIT ${d.assumedLimit}.`)}`);
|
|
2640
|
+
console.log(` ${dim(`Without the index the good plan is a seq scan (${divInt(d.pages)} pages); a promoted generic`)}`);
|
|
2641
|
+
console.log(` ${dim(`plan cannot see the value is rare, keeps the ordered "${d.orderColumn}" walk, and reads`)}`);
|
|
2642
|
+
console.log(` ${dim(`up to ~${tuples} tuples before it fills the LIMIT.`)}`);
|
|
2643
|
+
console.log(` ${dim('Postgres promotes this shape exactly when the workload keeps asking for the rare')}`);
|
|
2644
|
+
console.log(` ${dim('value: that is the case where the custom plan is expensive enough for the generic')}`);
|
|
2645
|
+
console.log(` ${dim('estimate to look cheaper.')}`);
|
|
2646
|
+
for (const line of divergenceAmplificationLines(d))
|
|
2647
|
+
console.log(` ${dim(line)}`);
|
|
2648
|
+
console.log(` ${dim('Adding the index above is the fix. Confirm first if you want to:')}`);
|
|
2649
|
+
for (const line of d.diagnosticSql.split('\n')) {
|
|
2650
|
+
console.log(` ${green(line)}`);
|
|
2651
|
+
}
|
|
2652
|
+
console.log(` ${dim('After adding this index, re-run doctor. This column is expected to reappear as a')}`);
|
|
2653
|
+
console.log(` ${dim('sparse-value finding in the cached-plan section. That later finding is exposure, not')}`);
|
|
2654
|
+
console.log(` ${dim('a regression: the index makes the good plan much cheaper, which is why the ratio it')}`);
|
|
2655
|
+
console.log(` ${dim('quotes is larger, and on a measured fixture it is also what stops Postgres from')}`);
|
|
2656
|
+
console.log(` ${dim('promoting the generic plan at all. Treat the reappearance as the normal end state;')}`);
|
|
2657
|
+
console.log(` ${dim("use the diagnostic block's generic_plans counter to decide whether anything more is")}`);
|
|
2658
|
+
console.log(` ${dim('warranted.')}`);
|
|
2659
|
+
}
|
|
2528
2660
|
/** Cost-aware tiered output: three sections, each finding annotated with its numbers. */
|
|
2529
|
-
function renderTiers(findings, snapshot, _args) {
|
|
2661
|
+
function renderTiers(findings, snapshot, _args, attached) {
|
|
2530
2662
|
const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
|
|
2531
2663
|
console.log(` ${dim(`Cost triage based on live statistics (stats reset ${ageLabel} ago). Thresholds: tiny < ${STATS_THRESHOLDS.tinyTableRows.toLocaleString()} rows,`)}`);
|
|
2532
2664
|
console.log(` ${dim(`"real" write rate >= ${STATS_THRESHOLDS.highWritesPerDay.toLocaleString()}/day, "many" indexes >= ${STATS_THRESHOLDS.manyIndexes}.`)}`);
|
|
@@ -2545,12 +2677,12 @@ function renderTiers(findings, snapshot, _args) {
|
|
|
2545
2677
|
console.log(` ${bold(tierColor[tier](`${TIER_LABEL[tier]} (${inTier.length})`))}`);
|
|
2546
2678
|
newline();
|
|
2547
2679
|
for (const f of inTier) {
|
|
2548
|
-
renderFinding(f, { concurrently: true, withReasons: true });
|
|
2680
|
+
renderFinding(f, { concurrently: true, withReasons: true, divergence: attached.get(attachKey(f)) });
|
|
2549
2681
|
}
|
|
2550
2682
|
}
|
|
2551
2683
|
}
|
|
2552
2684
|
/** Degraded output: today's size-sorted topology report when stats are absent/young. */
|
|
2553
|
-
function renderTopologyFallback(findings, snapshot) {
|
|
2685
|
+
function renderTopologyFallback(findings, snapshot, attached) {
|
|
2554
2686
|
warn('Statistics unavailable or too young to score cost: showing size-sorted topology only.');
|
|
2555
2687
|
for (const notice of snapshot.notices)
|
|
2556
2688
|
console.log(` ${dim(`- ${notice}`)}`);
|
|
@@ -2560,7 +2692,7 @@ function renderTopologyFallback(findings, snapshot) {
|
|
|
2560
2692
|
newline();
|
|
2561
2693
|
const sorted = [...findings].sort((a, b) => (b.score.metrics.rows ?? 0) - (a.score.metrics.rows ?? 0));
|
|
2562
2694
|
for (const f of sorted) {
|
|
2563
|
-
renderFinding(f, { concurrently: false, withReasons: false });
|
|
2695
|
+
renderFinding(f, { concurrently: false, withReasons: false, divergence: attached.get(attachKey(f)) });
|
|
2564
2696
|
}
|
|
2565
2697
|
}
|
|
2566
2698
|
/** Render one finding: table + columns, probing relations, reasons, and the create SQL. */
|
|
@@ -2578,7 +2710,10 @@ function renderFinding(f, opts) {
|
|
|
2578
2710
|
console.log(` ${dim(symbols.tee)} ${dim(reason)}`);
|
|
2579
2711
|
}
|
|
2580
2712
|
}
|
|
2581
|
-
|
|
2713
|
+
const last = opts.divergence ? symbols.tee : symbols.teeEnd;
|
|
2714
|
+
console.log(` ${dim(last)} ${green(doctorCreateSql(f, { concurrently: opts.concurrently }))}`);
|
|
2715
|
+
if (opts.divergence)
|
|
2716
|
+
renderDivergenceEvidence(opts.divergence);
|
|
2582
2717
|
newline();
|
|
2583
2718
|
}
|
|
2584
2719
|
/** The invalid-index report section (a failed CONCURRENTLY build leaves these behind). */
|
|
@@ -2594,6 +2729,198 @@ function renderInvalidIndexes(invalid) {
|
|
|
2594
2729
|
newline();
|
|
2595
2730
|
}
|
|
2596
2731
|
}
|
|
2732
|
+
/**
|
|
2733
|
+
* The release in which `turbine-orm/prisma-compat` began forwarding Turbine-only
|
|
2734
|
+
* query options (`forceCustomPlan` among them) to the core client.
|
|
2735
|
+
*
|
|
2736
|
+
* Printed rather than assumed, and the sentence stays even after that release:
|
|
2737
|
+
* doctor's audience routinely runs a CLI newer than the library pinned in the
|
|
2738
|
+
* app, and on an older library the option is accepted and silently ignored.
|
|
2739
|
+
*/
|
|
2740
|
+
const COMPAT_PASSTHROUGH_VERSION = '0.57.0';
|
|
2741
|
+
/**
|
|
2742
|
+
* The gates + scored-population footer. Split out because it is printed from two
|
|
2743
|
+
* places: the normal section, and the case where every finding was attached to a
|
|
2744
|
+
* missing-index finding instead.
|
|
2745
|
+
*/
|
|
2746
|
+
function renderDivergenceGates(divergence) {
|
|
2747
|
+
const t = PLAN_DIVERGENCE_THRESHOLDS;
|
|
2748
|
+
console.log(` ${dim(`Gates, indexed column: the wrong plan must walk >= ${t.minWalkPages} pages and >= ${Math.round(t.minWalkFraction * 100)}% of the table.`)}`);
|
|
2749
|
+
console.log(` ${dim(`Gates, unindexed column: the rarest value must hold fewer rows than the limit, and the wrong`)}`);
|
|
2750
|
+
console.log(` ${dim(`plan must walk >= ${t.minGenericTupleWalk.toLocaleString('en-US')} tuples. Assumed LIMIT ${t.assumedLimit} throughout.`)}`);
|
|
2751
|
+
console.log(` ${dim(`${divergence.candidatesConsidered} column(s) were scored (${divergence.consideredIndexed} indexed, ${divergence.consideredUnindexed} unindexed). That population is relation-probe`)}`);
|
|
2752
|
+
console.log(` ${dim('and leading-index columns only: a filter column that is neither is not covered. Skip this')}`);
|
|
2753
|
+
console.log(` ${dim('section with --no-plan-divergence.')}`);
|
|
2754
|
+
}
|
|
2755
|
+
/** Round to a whole number and group it, for the divergence report's estimates. */
|
|
2756
|
+
function divInt(n) {
|
|
2757
|
+
if (!Number.isFinite(n))
|
|
2758
|
+
return 'unbounded';
|
|
2759
|
+
return Math.round(n).toLocaleString('en-US');
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* The plan-divergence section: columns whose value distribution can flip a
|
|
2763
|
+
* cached plan. Finding-only by design, there is no `--fix` for it: the fix is
|
|
2764
|
+
* application code (scope the plan-cache mode to the affected reads), and the
|
|
2765
|
+
* index that looks like a fix is measured NOT to be one.
|
|
2766
|
+
*/
|
|
2767
|
+
function renderPlanDivergence(divergence, attached) {
|
|
2768
|
+
const { notices } = divergence;
|
|
2769
|
+
// Anything already rendered as evidence on a missing-index finding is NOT
|
|
2770
|
+
// repeated here: one column, one problem, one remedy.
|
|
2771
|
+
const rendered = new Set(attached.values());
|
|
2772
|
+
const findings = divergence.findings.filter((f) => !rendered.has(f));
|
|
2773
|
+
// Every divergence finding was attached above, so this section has no entries
|
|
2774
|
+
// of its own. The pointer, the gates and the scored population still belong in
|
|
2775
|
+
// the report: they are output of THIS check, and a reader must be able to tell
|
|
2776
|
+
// "considered and clean" from "never looked".
|
|
2777
|
+
//
|
|
2778
|
+
// Printed BEFORE the notices rather than inside an early return: an early
|
|
2779
|
+
// return that also required `notices.length === 0` dropped both blocks
|
|
2780
|
+
// whenever any candidate lacked a pg_stats row, which is the normal reason a
|
|
2781
|
+
// notice exists.
|
|
2782
|
+
if (findings.length === 0 && rendered.size > 0) {
|
|
2783
|
+
console.log(` ${dim(`Cached-plan divergence: ${rendered.size} finding(s), shown with the index findings above.`)}`);
|
|
2784
|
+
renderDivergenceGates(divergence);
|
|
2785
|
+
newline();
|
|
2786
|
+
}
|
|
2787
|
+
if (findings.length === 0 && notices.length === 0)
|
|
2788
|
+
return;
|
|
2789
|
+
if (findings.length > 0) {
|
|
2790
|
+
warn(`${bold(String(findings.length))} column(s) whose value distribution can flip a cached plan`);
|
|
2791
|
+
newline();
|
|
2792
|
+
console.log(` ${dim('Postgres may promote a named prepared statement to a GENERIC plan from its sixth execution,')}`);
|
|
2793
|
+
console.log(` ${dim('but only when the generic plan is not ESTIMATED to cost more than the average custom')}`);
|
|
2794
|
+
console.log(` ${dim('plan. A generic plan cannot see your values: it estimates "col = $1" as rows /')}`);
|
|
2795
|
+
console.log(` ${dim('n_distinct and an unknown LIMIT as 10% of the child estimate. When those defaults')}`);
|
|
2796
|
+
console.log(` ${dim('land on the other side of a plan boundary from the real value, the plan flips.')}`);
|
|
2797
|
+
newline();
|
|
2798
|
+
}
|
|
2799
|
+
const analyzedLabel = (f) => f.lastAnalyze === null
|
|
2800
|
+
? 'last ANALYZE unknown'
|
|
2801
|
+
: `last analyzed ${Math.max(0, Math.round((Date.now() - f.lastAnalyze.getTime()) / 86_400_000))}d ago`;
|
|
2802
|
+
for (const f of findings) {
|
|
2803
|
+
if (f.branch === 'unindexed-filter') {
|
|
2804
|
+
// Only reached when the column has no missing-index finding to hang this
|
|
2805
|
+
// on (an index that exists but cannot serve the equality: partial,
|
|
2806
|
+
// expression, or non-btree). The remedy is still an index, not a
|
|
2807
|
+
// plan-cache setting, so this entry never suggests forceCustomPlan.
|
|
2808
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${f.table}.${f.column}`))} ${gray('UNINDEXED-FILTER FLIP')}`);
|
|
2809
|
+
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}`);
|
|
2810
|
+
console.log(` ${dim(symbols.tee)} no index serves ${f.column} = $1, so the good plan is a seq scan (${divInt(f.pages)} pages);`);
|
|
2811
|
+
console.log(` ${dim(`a promoted generic plan keeps the ordered "${f.orderColumn}" walk and reads up to ~${divInt(f.tuplesWalked ?? f.rows)} tuples`)}`);
|
|
2812
|
+
console.log(` ${dim('before it fills the LIMIT.')}`);
|
|
2813
|
+
for (const line of divergenceAmplificationLines(f))
|
|
2814
|
+
console.log(` ${dim(line)}`);
|
|
2815
|
+
console.log(` ${dim(symbols.tee)} ${dim(`filter-column correlation ${f.correlation.toFixed(2)}, ${analyzedLabel(f)}`)}`);
|
|
2816
|
+
console.log(` ${dim(symbols.tee)} ${dim('the fix is an index that can serve this equality. A partial or expression index')}`);
|
|
2817
|
+
console.log(` ${dim('on the column does not: the planner has no path for the bare predicate. A hash')}`);
|
|
2818
|
+
console.log(` ${dim('index does, and a column served by one is scored by the other rule instead.')}`);
|
|
2819
|
+
console.log(` ${dim(symbols.teeEnd)} ${dim('confirm with YOUR values before changing anything:')}`);
|
|
2820
|
+
for (const line of f.diagnosticSql.split('\n')) {
|
|
2821
|
+
console.log(` ${green(line)}`);
|
|
2822
|
+
}
|
|
2823
|
+
newline();
|
|
2824
|
+
continue;
|
|
2825
|
+
}
|
|
2826
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${f.table}.${f.column}`))} ${gray('SPARSE-VALUE FLIP')}`);
|
|
2827
|
+
console.log(` ${dim(symbols.tee)} generic estimate ${bold(divInt(f.genericEstimate))} rows ${dim(`(${divInt(f.rows)} rows / ${divInt(f.distinctValues)} distinct values)`)}`);
|
|
2828
|
+
console.log(` ${dim(symbols.tee)} rarest value bucket ${bold(divInt(f.rarestBucket))} rows ${dim('(pg_stats most_common_freqs / residual bucket)')}`);
|
|
2829
|
+
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})`)}`);
|
|
2830
|
+
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)}`)}`);
|
|
2831
|
+
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)`)}`);
|
|
2832
|
+
console.log(` ${dim(`for reads shaped WHERE ${f.column} = $1 ORDER BY ${f.orderColumn} LIMIT $n,`)}`);
|
|
2833
|
+
console.log(` ${dim("where the custom plan reads only that value's own rows.")}`);
|
|
2834
|
+
console.log(` ${dim(symbols.tee)} ${dim('No amplification figure is printed, deliberately. This models how many rows a')}`);
|
|
2835
|
+
console.log(` ${dim('value has, not WHERE they sit in the heap, and the second half can move the')}`);
|
|
2836
|
+
console.log(` ${dim('real cost by an order of magnitude. Measure it instead:')}`);
|
|
2837
|
+
console.log(` ${dim(symbols.teeEnd)} ${dim('confirm with YOUR values before changing anything:')}`);
|
|
2838
|
+
for (const line of f.diagnosticSql.split('\n')) {
|
|
2839
|
+
console.log(` ${green(line)}`);
|
|
2840
|
+
}
|
|
2841
|
+
newline();
|
|
2842
|
+
}
|
|
2843
|
+
if (findings.length > 0) {
|
|
2844
|
+
const first = findings.find((f) => f.branch === 'sparse-value');
|
|
2845
|
+
console.log(` ${bold('What to do, in order:')}`);
|
|
2846
|
+
console.log(` 1. Check that this shape is promoted AT ALL. Step 1 of the block above: while`);
|
|
2847
|
+
console.log(` ${dim('generic_plans is 0, Postgres is planning with your real values and there is nothing')}`);
|
|
2848
|
+
console.log(` ${dim('to fix. A finding is exposure, not an incident, and many shapes never promote.')}`);
|
|
2849
|
+
console.log(` 2. If it does promote, compare the two plans. Both SETs matter: without them a`);
|
|
2850
|
+
console.log(` ${dim('repeated seq scan resumes where the last one stopped and a catastrophic case reads')}`);
|
|
2851
|
+
console.log(` ${dim('as harmless.')}`);
|
|
2852
|
+
if (first) {
|
|
2853
|
+
// The OPTION is named first and both call shapes follow, so no step
|
|
2854
|
+
// assumes which client the reader is holding. The compat example uses
|
|
2855
|
+
// Prisma's `take`: printing `limit` there would be a second wrong
|
|
2856
|
+
// instruction, since `limit` is a Turbine spelling compat does not read.
|
|
2857
|
+
console.log(` 3. If the flip is real, scope the fix to those reads with ${cyan('forceCustomPlan')}. It`);
|
|
2858
|
+
console.log(` ${dim('withholds the prepared-statement NAME for that one query, so the driver re-parses')}`);
|
|
2859
|
+
console.log(` ${dim('it every execution and it is always planned with the real values. No GUC, no SET')}`);
|
|
2860
|
+
console.log(` ${dim('LOCAL, no transaction, no extra round trip.')}`);
|
|
2861
|
+
// Hanging indent rather than an alignment that pretends to line up: the
|
|
2862
|
+
// call's own width depends on the table name, so a fixed padding column
|
|
2863
|
+
// misaligns on every schema but the one it was written against.
|
|
2864
|
+
const args = `where: { ${first.columnField}: value }, orderBy: { ${first.orderColumnField}: 'asc' },`;
|
|
2865
|
+
// The accessor is the camelCase FIELD spelling, not the raw table name:
|
|
2866
|
+
// TurbineClient and the code generator both define table accessors through
|
|
2867
|
+
// snakeToCamel, so `db.inventory_location` is undefined on every
|
|
2868
|
+
// snake_case schema. The finding's own `columnField` / `orderColumnField`
|
|
2869
|
+
// are already field-space for the same reason.
|
|
2870
|
+
console.log(` ${dim('On the core client:')}`);
|
|
2871
|
+
console.log(` ${cyan(`db.${snakeToCamel(first.table)}.findMany({`)}`);
|
|
2872
|
+
console.log(` ${cyan(args)}`);
|
|
2873
|
+
console.log(` ${cyan('limit: 20, forceCustomPlan: true,')}`);
|
|
2874
|
+
console.log(` ${cyan('})')}`);
|
|
2875
|
+
console.log(` ${dim("Through turbine-orm/prisma-compat, the same option on the delegate call (Prisma's")}`);
|
|
2876
|
+
console.log(` ${dim('`take`, not `limit`). The Prisma MODEL name is not knowable from the schema side,')}`);
|
|
2877
|
+
console.log(` ${dim('so substitute your own:')}`);
|
|
2878
|
+
console.log(` ${cyan('compat.<Model>.findMany({')}`);
|
|
2879
|
+
console.log(` ${cyan(args)}`);
|
|
2880
|
+
console.log(` ${cyan('take: 20, forceCustomPlan: true,')}`);
|
|
2881
|
+
console.log(` ${cyan('})')}`);
|
|
2882
|
+
console.log(` ${dim(`The compat passthrough requires turbine >= ${COMPAT_PASSTHROUGH_VERSION}. On an older version the option`)}`);
|
|
2883
|
+
console.log(` ${dim('is accepted and ignored there, so confirm at the wire with the same')}`);
|
|
2884
|
+
console.log(` ${dim('pg_prepared_statements check in step 1 rather than assuming it took effect.')}`);
|
|
2885
|
+
console.log(` 4. Reaching for a database-wide plan_cache_mode is not the fix, whichever client you`);
|
|
2886
|
+
console.log(` ${dim('use. There are measured shapes where a generic plan is dramatically better: an')}`);
|
|
2887
|
+
console.log(` ${dim('unordered LIMIT over a value whose rows are packed at the end of the heap reads')}`);
|
|
2888
|
+
console.log(` ${dim('4,262 buffers under a custom plan against 71 under a generic one. That fixture is')}`);
|
|
2889
|
+
console.log(` ${dim('printed in full at turbineorm.dev/relations, so the number is checkable rather')}`);
|
|
2890
|
+
console.log(` ${dim('than asserted. Pinning every statement')}`);
|
|
2891
|
+
console.log(` ${dim('in one direction trades this finding for its mirror image. That applies equally to')}`);
|
|
2892
|
+
console.log(` ${dim("Turbine's client-level `planCacheMode` and to a SET or ALTER ROLE applied outside")}`);
|
|
2893
|
+
console.log(` ${dim('Turbine.')}`);
|
|
2894
|
+
console.log(` 5. A composite index on (${first.column}, ${first.orderColumn}) makes the GOOD plan better. It`);
|
|
2895
|
+
console.log(` ${dim('does NOT stop the generic plan from choosing the other one, and it can widen the gap.')}`);
|
|
2896
|
+
console.log(` ${dim('Add it for the custom-plan win, not as a fix for this finding.')}`);
|
|
2897
|
+
}
|
|
2898
|
+
// Stated separately because the first remedy genuinely differs by branch: an
|
|
2899
|
+
// UNINDEXED column's flip is fixed by the index, and a per-query plan-cache
|
|
2900
|
+
// override there would only paper over a table scan.
|
|
2901
|
+
if (findings.some((f) => f.branch === 'unindexed-filter')) {
|
|
2902
|
+
console.log(` ${first ? 6 : 3}. A finding on an UNINDEXED column has a different FIRST remedy: add an index that`);
|
|
2903
|
+
console.log(` ${dim('serves the equality, then re-run doctor and re-score. The index moves the')}`);
|
|
2904
|
+
console.log(` ${dim('divergence in both directions at once (it makes the good plan much cheaper, which')}`);
|
|
2905
|
+
console.log(` ${dim('widens the ratio, and on a measured fixture it also stopped Postgres promoting the')}`);
|
|
2906
|
+
console.log(` ${dim('generic plan at all), so do not assume the finding is closed by adding it.')}`);
|
|
2907
|
+
}
|
|
2908
|
+
newline();
|
|
2909
|
+
console.log(` ${dim('This finding is derived from statistics, not from your traffic: it says the DISTRIBUTION')}`);
|
|
2910
|
+
console.log(` ${dim('admits a damaging flip, not that a query is running one today. It cannot see where a')}`);
|
|
2911
|
+
console.log(` ${dim('value physically sits in the heap, so a clean report is not evidence of immunity, and')}`);
|
|
2912
|
+
console.log(` ${dim('a column that is neither an FK nor indexed is not in the scored population at all.')}`);
|
|
2913
|
+
renderDivergenceGates(divergence);
|
|
2914
|
+
newline();
|
|
2915
|
+
}
|
|
2916
|
+
if (notices.length > 0) {
|
|
2917
|
+
console.log(` ${dim('Not scored for cached-plan divergence (statistics missing):')}`);
|
|
2918
|
+
for (const n of notices) {
|
|
2919
|
+
console.log(` ${dim(`- ${n.table}.${n.column}: ${n.reason}`)}`);
|
|
2920
|
+
}
|
|
2921
|
+
newline();
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2597
2924
|
/** Write the --fix migration (CONCURRENTLY + directive by default; plain with --no-concurrently). */
|
|
2598
2925
|
function renderFixMigration(findings, config, args) {
|
|
2599
2926
|
const concurrently = args.noConcurrently !== true;
|
|
@@ -3067,7 +3394,7 @@ function showHelp() {
|
|
|
3067
3394
|
console.log(` ${dim('status')} Show applied/pending migrations`);
|
|
3068
3395
|
console.log(` ${cyan('seed')} Run seed file`);
|
|
3069
3396
|
console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
|
|
3070
|
-
console.log(` ${cyan('doctor')}
|
|
3397
|
+
console.log(` ${cyan('doctor')} Index + cached-plan triage ${dim('(--fix, --json, --unused, --audit)')}`);
|
|
3071
3398
|
console.log(` ${cyan('studio')} Launch local read-only web UI ${dim('(--write for writes, --demo for a sample DB)')}`);
|
|
3072
3399
|
console.log(` ${cyan('mcp')} Start read-only MCP server over stdio`);
|
|
3073
3400
|
console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
|
package/dist/client.d.ts
CHANGED
|
@@ -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
|
|
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
|
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 {
|
|
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
|
|
155
|
-
*
|
|
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
|
-
|
|
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
|
|
@@ -650,6 +614,10 @@ export class TurbineClient {
|
|
|
650
614
|
jsonEncoding: config.jsonEncoding,
|
|
651
615
|
globalFilters: config.globalFilters,
|
|
652
616
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
617
|
+
// Forwarded so a per-query `forceCustomPlan` can refuse the combination
|
|
618
|
+
// this client's own connections would silently defeat, see the
|
|
619
|
+
// `planCacheMode` note on QueryInterfaceOptions.
|
|
620
|
+
planCacheMode,
|
|
653
621
|
sqlCache: config.sqlCache ?? true,
|
|
654
622
|
sqlCacheSize: config.sqlCacheSize,
|
|
655
623
|
dialect: config.dialect,
|
package/dist/dialect.d.ts
CHANGED
|
@@ -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
|