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 +1 -1
- package/dist/cjs/cli/index.d.ts +3 -1
- package/dist/cjs/cli/index.js +117 -7
- package/dist/cjs/client.d.ts +16 -1
- package/dist/cjs/client.js +4 -0
- 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 +244 -0
- package/dist/cjs/plan-divergence.js +444 -0
- package/dist/cjs/powql.d.ts +11 -0
- package/dist/cjs/powql.js +22 -0
- 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/types.d.ts +140 -0
- package/dist/cjs/query/utils.d.ts +14 -0
- package/dist/cjs/query/utils.js +17 -3
- package/dist/cli/index.d.ts +3 -1
- package/dist/cli/index.js +117 -7
- package/dist/client.d.ts +16 -1
- package/dist/client.js +4 -0
- 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 +244 -0
- package/dist/plan-divergence.js +438 -0
- package/dist/powql.d.ts +11 -0
- package/dist/powql.js +22 -0
- 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/types.d.ts +140 -0
- package/dist/query/utils.d.ts +14 -0
- package/dist/query/utils.js +17 -3
- 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,7 @@ 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
36
|
import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
|
|
36
37
|
import { configTemplate, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
|
|
37
38
|
import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
|
|
@@ -142,6 +143,9 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
|
|
142
143
|
result.metricsUrl = next;
|
|
143
144
|
i++;
|
|
144
145
|
break;
|
|
146
|
+
case '--no-plan-divergence':
|
|
147
|
+
result.noPlanDivergence = true;
|
|
148
|
+
break;
|
|
145
149
|
case '--zod':
|
|
146
150
|
result.zod = true;
|
|
147
151
|
break;
|
|
@@ -2259,20 +2263,27 @@ async function cmdDoctor(args, config) {
|
|
|
2259
2263
|
const missing = findMissingRelationIndexes(schema);
|
|
2260
2264
|
// Collect live statistics. The collector reads whole-schema indexes (for
|
|
2261
2265
|
// invalid-index detection) plus per-table stats + probed-column null_frac.
|
|
2262
|
-
const probedTables = [...new Set(missing.map((m) => m.table))];
|
|
2263
2266
|
const probedColumns = [];
|
|
2264
2267
|
for (const m of missing) {
|
|
2265
2268
|
if (m.columns.length === 1 && m.columns[0] !== undefined) {
|
|
2266
2269
|
probedColumns.push({ table: m.table, column: m.columns[0] });
|
|
2267
2270
|
}
|
|
2268
2271
|
}
|
|
2272
|
+
// Plan-divergence candidates are the columns that ARE indexed, so their tables
|
|
2273
|
+
// are usually disjoint from the missing-index set: both lists feed the same
|
|
2274
|
+
// one-connection snapshot rather than opening a second read.
|
|
2275
|
+
const divergenceOn = args.noPlanDivergence !== true;
|
|
2276
|
+
const divergenceColumns = divergenceOn ? collectDivergenceCandidateColumns(schema) : [];
|
|
2277
|
+
const probedTables = [...new Set(missing.map((m) => m.table))];
|
|
2278
|
+
const statsTables = [...new Set([...probedTables, ...divergenceColumns.map((c) => c.table)])];
|
|
2269
2279
|
let snapshot;
|
|
2270
2280
|
try {
|
|
2271
2281
|
snapshot = await collectStatsSnapshot({
|
|
2272
2282
|
connectionString: url,
|
|
2273
2283
|
schema: config.schema,
|
|
2274
|
-
tables:
|
|
2284
|
+
tables: statsTables,
|
|
2275
2285
|
columns: probedColumns,
|
|
2286
|
+
distributionColumns: divergenceColumns,
|
|
2276
2287
|
});
|
|
2277
2288
|
}
|
|
2278
2289
|
catch (err) {
|
|
@@ -2311,9 +2322,16 @@ async function cmdDoctor(args, config) {
|
|
|
2311
2322
|
? auditDoctorIndexes(snapshot, collectDoctorProbeIndexNames(schema), { minScans, relationProbes })
|
|
2312
2323
|
: [];
|
|
2313
2324
|
const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
|
|
2325
|
+
// Plan divergence has its OWN freshness gate. The cost tiers require a
|
|
2326
|
+
// trustworthy stats_reset age because they normalize write counters by it;
|
|
2327
|
+
// this check reads no counter, only pg_stats, whose freshness is ANALYZE. A
|
|
2328
|
+
// cluster with a NULL stats_reset (the default) must still get the check.
|
|
2329
|
+
const divergence = divergenceOn && snapshot.available
|
|
2330
|
+
? findPlanDivergence(schema, snapshot)
|
|
2331
|
+
: { findings: [], notices: [], candidatesConsidered: 0 };
|
|
2314
2332
|
if (jsonMode) {
|
|
2315
2333
|
spinner?.stop();
|
|
2316
|
-
console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, args }), null, 2));
|
|
2334
|
+
console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, divergence, args }), null, 2));
|
|
2317
2335
|
return;
|
|
2318
2336
|
}
|
|
2319
2337
|
await renderDoctorHuman({
|
|
@@ -2325,6 +2343,7 @@ async function cmdDoctor(args, config) {
|
|
|
2325
2343
|
usable,
|
|
2326
2344
|
heat,
|
|
2327
2345
|
subtract,
|
|
2346
|
+
divergence,
|
|
2328
2347
|
args,
|
|
2329
2348
|
config,
|
|
2330
2349
|
});
|
|
@@ -2392,13 +2411,17 @@ function buildDoctorJson(ctx) {
|
|
|
2392
2411
|
out.redundant = ctx.subtract.unusedRan ? ctx.subtract.redundant : [];
|
|
2393
2412
|
out.audit = ctx.subtract.auditRan ? ctx.subtract.audit : [];
|
|
2394
2413
|
out.invalid = ctx.invalid;
|
|
2414
|
+
// Always an array, never absent: a consumer must not have to write `?? []`
|
|
2415
|
+
// just because the section was skipped or found nothing.
|
|
2416
|
+
out.planDivergence = ctx.divergence.findings;
|
|
2417
|
+
out.planDivergenceNotices = ctx.divergence.notices;
|
|
2395
2418
|
return out;
|
|
2396
2419
|
}
|
|
2397
2420
|
async function renderDoctorHuman(ctx) {
|
|
2398
|
-
const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, args, config } = ctx;
|
|
2421
|
+
const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, divergence, args, config } = ctx;
|
|
2399
2422
|
spinner.succeed(`Scanned ${bold(String(Object.keys(schema.tables).length))} tables`);
|
|
2400
2423
|
const subtractRan = subtract.unusedRan || subtract.auditRan;
|
|
2401
|
-
const nothingToAdd = findings.length === 0 && invalid.length === 0;
|
|
2424
|
+
const nothingToAdd = findings.length === 0 && invalid.length === 0 && divergence.findings.length === 0;
|
|
2402
2425
|
const nothingToSubtract = subtract.unused.length === 0 && subtract.redundant.length === 0 && subtract.audit.length === 0;
|
|
2403
2426
|
if (nothingToAdd && (!subtractRan || nothingToSubtract)) {
|
|
2404
2427
|
if (subtractRan) {
|
|
@@ -2429,6 +2452,7 @@ async function renderDoctorHuman(ctx) {
|
|
|
2429
2452
|
}
|
|
2430
2453
|
}
|
|
2431
2454
|
renderInvalidIndexes(invalid);
|
|
2455
|
+
renderPlanDivergence(divergence);
|
|
2432
2456
|
if (subtract.unusedRan) {
|
|
2433
2457
|
renderUnusedIndexes(subtract.unused, subtract.minScans, snapshot);
|
|
2434
2458
|
renderRedundantIndexes(subtract.redundant);
|
|
@@ -2594,6 +2618,92 @@ function renderInvalidIndexes(invalid) {
|
|
|
2594
2618
|
newline();
|
|
2595
2619
|
}
|
|
2596
2620
|
}
|
|
2621
|
+
/** Round to a whole number and group it, for the divergence report's estimates. */
|
|
2622
|
+
function divInt(n) {
|
|
2623
|
+
if (!Number.isFinite(n))
|
|
2624
|
+
return 'unbounded';
|
|
2625
|
+
return Math.round(n).toLocaleString('en-US');
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* The plan-divergence section: columns whose value distribution can flip a
|
|
2629
|
+
* cached plan. Finding-only by design, there is no `--fix` for it: the fix is
|
|
2630
|
+
* application code (scope the plan-cache mode to the affected reads), and the
|
|
2631
|
+
* index that looks like a fix is measured NOT to be one.
|
|
2632
|
+
*/
|
|
2633
|
+
function renderPlanDivergence(divergence) {
|
|
2634
|
+
const { findings, notices } = divergence;
|
|
2635
|
+
if (findings.length === 0 && notices.length === 0)
|
|
2636
|
+
return;
|
|
2637
|
+
if (findings.length > 0) {
|
|
2638
|
+
warn(`${bold(String(findings.length))} column(s) whose value distribution can flip a cached plan`);
|
|
2639
|
+
newline();
|
|
2640
|
+
console.log(` ${dim('Postgres may promote a named prepared statement to a GENERIC plan from its sixth execution,')}`);
|
|
2641
|
+
console.log(` ${dim('but only when the generic plan is not ESTIMATED to cost more than the average custom')}`);
|
|
2642
|
+
console.log(` ${dim('plan. A generic plan cannot see your values: it estimates "col = $1" as rows /')}`);
|
|
2643
|
+
console.log(` ${dim('n_distinct and an unknown LIMIT as 10% of the child estimate. When those defaults')}`);
|
|
2644
|
+
console.log(` ${dim('land on the other side of a plan boundary from the real value, the plan flips.')}`);
|
|
2645
|
+
newline();
|
|
2646
|
+
}
|
|
2647
|
+
for (const f of findings) {
|
|
2648
|
+
console.log(` ${yellow(symbols.warning)} ${bold(cyan(`${f.table}.${f.column}`))} ${gray('SPARSE-VALUE FLIP')}`);
|
|
2649
|
+
console.log(` ${dim(symbols.tee)} generic estimate ${bold(divInt(f.genericEstimate))} rows ${dim(`(${divInt(f.rows)} rows / ${divInt(f.distinctValues)} distinct values)`)}`);
|
|
2650
|
+
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)`)}`);
|
|
2657
|
+
console.log(` ${dim(`for reads shaped WHERE ${f.column} = $1 ORDER BY ${f.orderColumn} LIMIT $n,`)}`);
|
|
2658
|
+
console.log(` ${dim("where the custom plan reads only that value's own rows.")}`);
|
|
2659
|
+
console.log(` ${dim(symbols.tee)} ${dim('No amplification figure is printed, deliberately. This models how many rows a')}`);
|
|
2660
|
+
console.log(` ${dim('value has, not WHERE they sit in the heap, and the second half can move the')}`);
|
|
2661
|
+
console.log(` ${dim('real cost by an order of magnitude. Measure it instead:')}`);
|
|
2662
|
+
console.log(` ${dim(symbols.teeEnd)} ${dim('confirm with YOUR values before changing anything:')}`);
|
|
2663
|
+
for (const line of f.diagnosticSql.split('\n')) {
|
|
2664
|
+
console.log(` ${green(line)}`);
|
|
2665
|
+
}
|
|
2666
|
+
newline();
|
|
2667
|
+
}
|
|
2668
|
+
if (findings.length > 0) {
|
|
2669
|
+
console.log(` ${bold('What to do, in order:')}`);
|
|
2670
|
+
console.log(` 1. Check that this shape is promoted AT ALL. Step 1 of the block above: while`);
|
|
2671
|
+
console.log(` ${dim('generic_plans is 0, Postgres is planning with your real values and there is nothing')}`);
|
|
2672
|
+
console.log(` ${dim('to fix. A finding is exposure, not an incident, and many shapes never promote.')}`);
|
|
2673
|
+
console.log(` 2. If it does promote, compare the two plans. Both SETs matter: without them a`);
|
|
2674
|
+
console.log(` ${dim('repeated seq scan resumes where the last one stopped and a catastrophic case reads')}`);
|
|
2675
|
+
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.')}`);
|
|
2690
|
+
newline();
|
|
2691
|
+
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.`)}`);
|
|
2697
|
+
newline();
|
|
2698
|
+
}
|
|
2699
|
+
if (notices.length > 0) {
|
|
2700
|
+
console.log(` ${dim('Not scored for cached-plan divergence (statistics missing):')}`);
|
|
2701
|
+
for (const n of notices) {
|
|
2702
|
+
console.log(` ${dim(`- ${n.table}.${n.column}: ${n.reason}`)}`);
|
|
2703
|
+
}
|
|
2704
|
+
newline();
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2597
2707
|
/** Write the --fix migration (CONCURRENTLY + directive by default; plain with --no-concurrently). */
|
|
2598
2708
|
function renderFixMigration(findings, config, args) {
|
|
2599
2709
|
const concurrently = args.noConcurrently !== true;
|
|
@@ -3067,7 +3177,7 @@ function showHelp() {
|
|
|
3067
3177
|
console.log(` ${dim('status')} Show applied/pending migrations`);
|
|
3068
3178
|
console.log(` ${cyan('seed')} Run seed file`);
|
|
3069
3179
|
console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
|
|
3070
|
-
console.log(` ${cyan('doctor')}
|
|
3180
|
+
console.log(` ${cyan('doctor')} Index + cached-plan triage ${dim('(--fix, --json, --unused, --audit)')}`);
|
|
3071
3181
|
console.log(` ${cyan('studio')} Launch local read-only web UI ${dim('(--write for writes, --demo for a sample DB)')}`);
|
|
3072
3182
|
console.log(` ${cyan('mcp')} Start read-only MCP server over stdio`);
|
|
3073
3183
|
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
|
@@ -650,6 +650,10 @@ export class TurbineClient {
|
|
|
650
650
|
jsonEncoding: config.jsonEncoding,
|
|
651
651
|
globalFilters: config.globalFilters,
|
|
652
652
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
|
653
|
+
// Forwarded so a per-query `forceCustomPlan` can refuse the combination
|
|
654
|
+
// this client's own connections would silently defeat, see the
|
|
655
|
+
// `planCacheMode` note on QueryInterfaceOptions.
|
|
656
|
+
planCacheMode,
|
|
653
657
|
sqlCache: config.sqlCache ?? true,
|
|
654
658
|
sqlCacheSize: config.sqlCacheSize,
|
|
655
659
|
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
|
package/dist/index-stats.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/index-stats.js
CHANGED
|
@@ -102,6 +102,7 @@ export function emptyStatsSnapshot(notices = []) {
|
|
|
102
102
|
tables: {},
|
|
103
103
|
indexes: [],
|
|
104
104
|
nullFrac: {},
|
|
105
|
+
columnStats: {},
|
|
105
106
|
notices,
|
|
106
107
|
};
|
|
107
108
|
}
|
|
@@ -544,12 +545,14 @@ export async function collectStatsSnapshot(options) {
|
|
|
544
545
|
}
|
|
545
546
|
}
|
|
546
547
|
// --- table stats (pg_stat_user_tables) ---------------------------------
|
|
547
|
-
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
|
|
548
|
+
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,
|
|
549
|
+
greatest(last_analyze, last_autoanalyze) AS last_analyze
|
|
548
550
|
FROM pg_stat_user_tables
|
|
549
551
|
WHERE schemaname = $1 AND relname = ANY($2)`, [options.schema, options.tables]);
|
|
550
552
|
// --- class size + existing index count (pg_class) ----------------------
|
|
551
553
|
const classRows = await run('pg_class size', `SELECT c.relname,
|
|
552
554
|
c.reltuples::bigint::text AS reltuples,
|
|
555
|
+
c.relpages::bigint::text AS relpages,
|
|
553
556
|
pg_total_relation_size(c.oid)::text AS total_size,
|
|
554
557
|
pg_relation_size(c.oid)::text AS table_size,
|
|
555
558
|
(SELECT count(*) FROM pg_index i WHERE i.indrelid = c.oid)::text AS index_count
|
|
@@ -572,6 +575,7 @@ export async function collectStatsSnapshot(options) {
|
|
|
572
575
|
for (const row of classRows) {
|
|
573
576
|
const s = ensure(row.relname);
|
|
574
577
|
s.reltuples = Number(row.reltuples);
|
|
578
|
+
s.relpages = Number(row.relpages);
|
|
575
579
|
s.totalSizeBytes = Number(row.total_size);
|
|
576
580
|
s.tableSizeBytes = Number(row.table_size);
|
|
577
581
|
s.existingIndexCount = Number(row.index_count);
|
|
@@ -588,6 +592,7 @@ export async function collectStatsSnapshot(options) {
|
|
|
588
592
|
s.seqScan = Number(row.seq_scan);
|
|
589
593
|
s.seqTupRead = Number(row.seq_tup_read);
|
|
590
594
|
s.nLiveTup = Number(row.n_live_tup);
|
|
595
|
+
s.lastAnalyze = row.last_analyze == null ? null : new Date(row.last_analyze);
|
|
591
596
|
}
|
|
592
597
|
}
|
|
593
598
|
// --- invalid + all indexes (whole schema, for invalid detection) -------
|
|
@@ -651,6 +656,42 @@ export async function collectStatsSnapshot(options) {
|
|
|
651
656
|
}
|
|
652
657
|
}
|
|
653
658
|
}
|
|
659
|
+
// --- value distribution for divergence candidates (pg_stats) -----------
|
|
660
|
+
const distCols = options.distributionColumns ?? [];
|
|
661
|
+
if (distCols.length > 0) {
|
|
662
|
+
// `inherited = false` is required: on a partitioned parent the inherited
|
|
663
|
+
// row describes the whole tree, and a per-partition plan is not chosen
|
|
664
|
+
// from it. most_common_vals is an anyarray, so its cardinality is read
|
|
665
|
+
// through a text[] cast (array_length on anyarray cannot resolve a type).
|
|
666
|
+
const distRows = await run('pg_stats.distribution', `SELECT s.tablename, s.attname,
|
|
667
|
+
s.n_distinct::text AS n_distinct,
|
|
668
|
+
s.correlation::text AS correlation,
|
|
669
|
+
s.most_common_freqs,
|
|
670
|
+
coalesce(array_length(s.most_common_vals::text::text[], 1), 0)::text AS mcv_count
|
|
671
|
+
FROM pg_stats s
|
|
672
|
+
JOIN unnest($2::text[], $3::text[]) AS probe(t, c)
|
|
673
|
+
ON probe.t = s.tablename AND probe.c = s.attname
|
|
674
|
+
WHERE s.schemaname = $1 AND s.inherited = false`, [options.schema, distCols.map((c) => c.table), distCols.map((c) => c.column)]);
|
|
675
|
+
if (distRows) {
|
|
676
|
+
const byKey = snapshot.columnStats ?? {};
|
|
677
|
+
for (const row of distRows) {
|
|
678
|
+
byKey[`${row.tablename}.${row.attname}`] = {
|
|
679
|
+
table: row.tablename,
|
|
680
|
+
column: row.attname,
|
|
681
|
+
nDistinct: Number(row.n_distinct),
|
|
682
|
+
correlation: row.correlation == null ? null : Number(row.correlation),
|
|
683
|
+
mostCommonFreqs: row.most_common_freqs == null ? null : row.most_common_freqs.map(Number),
|
|
684
|
+
mcvCount: row.mcv_count == null ? 0 : Number(row.mcv_count),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
snapshot.columnStats = byKey;
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
// The read failed (privileges, catalog gap). Leave columnStats absent so
|
|
691
|
+
// the advisor suppresses every candidate instead of scoring zeros.
|
|
692
|
+
snapshot.columnStats = undefined;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
654
695
|
}
|
|
655
696
|
finally {
|
|
656
697
|
try {
|