pgexplain 0.2.0 → 0.3.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/CHANGELOG.md +27 -0
- package/README.md +102 -6
- package/dist/cli.js +355 -6
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +98 -3
- package/dist/index.js.map +1 -1
- package/dist/server.js +208 -38
- package/dist/server.js.map +1 -1
- package/dist/web/assets/{PlanGraph-0P3xGm2e.js → PlanGraph-CD8gYPCY.js} +1 -1
- package/dist/web/assets/index-D3fMyvfo.js +237 -0
- package/dist/web/assets/index-p4QC4qQe.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-BqB6p5Pn.js +0 -227
- package/dist/web/assets/index-ByEFSLsN.css +0 -1
package/dist/server.js
CHANGED
|
@@ -23,6 +23,9 @@ var AppError = class extends Error {
|
|
|
23
23
|
if (cause !== void 0) this.cause = cause;
|
|
24
24
|
}
|
|
25
25
|
};
|
|
26
|
+
function finding(code, severity, parts) {
|
|
27
|
+
return { code, domain: "plan", severity, ...parts };
|
|
28
|
+
}
|
|
26
29
|
var SEVERITY_RANK = { error: 0, warn: 1, info: 2 };
|
|
27
30
|
function bySeverity(a, b) {
|
|
28
31
|
return SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
@@ -37,7 +40,7 @@ function scrubCredentials(input) {
|
|
|
37
40
|
|
|
38
41
|
// package.json
|
|
39
42
|
var package_default = {
|
|
40
|
-
version: "0.
|
|
43
|
+
version: "0.3.0"};
|
|
41
44
|
|
|
42
45
|
// src/diagnostics/catalog.ts
|
|
43
46
|
var DOCS = "https://www.postgresql.org/docs/current";
|
|
@@ -798,7 +801,9 @@ var DEFAULT_THRESHOLDS = {
|
|
|
798
801
|
correlatedLoops: 1e3,
|
|
799
802
|
jitPct: 25,
|
|
800
803
|
triggerPct: 10,
|
|
801
|
-
lowCacheHitRatio: 0.9
|
|
804
|
+
lowCacheHitRatio: 0.9,
|
|
805
|
+
limitDiscardRows: 1e4,
|
|
806
|
+
staleStatsModRatio: 0.2
|
|
802
807
|
};
|
|
803
808
|
var DEFAULT_CONFIG = {
|
|
804
809
|
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
@@ -1214,6 +1219,10 @@ function normalizeNode(raw, nextId) {
|
|
|
1214
1219
|
diskUsage: num(raw, "Disk Usage"),
|
|
1215
1220
|
exactHeapBlocks: num(raw, "Exact Heap Blocks"),
|
|
1216
1221
|
lossyHeapBlocks: num(raw, "Lossy Heap Blocks"),
|
|
1222
|
+
cacheHits: num(raw, "Cache Hits"),
|
|
1223
|
+
cacheMisses: num(raw, "Cache Misses"),
|
|
1224
|
+
cacheEvictions: num(raw, "Cache Evictions"),
|
|
1225
|
+
cacheOverflows: num(raw, "Cache Overflows"),
|
|
1217
1226
|
sharedHitBlocks: num(raw, "Shared Hit Blocks"),
|
|
1218
1227
|
sharedReadBlocks: num(raw, "Shared Read Blocks"),
|
|
1219
1228
|
sharedDirtiedBlocks: num(raw, "Shared Dirtied Blocks"),
|
|
@@ -1561,8 +1570,11 @@ var cartesianProduct = {
|
|
|
1561
1570
|
check(node, ctx) {
|
|
1562
1571
|
if (node.nodeType !== "Nested Loop") return [];
|
|
1563
1572
|
if (node.joinFilter) return [];
|
|
1564
|
-
|
|
1573
|
+
let inner = node.children[1];
|
|
1565
1574
|
if (!inner) return [];
|
|
1575
|
+
while ((inner.nodeType === "Memoize" || inner.nodeType === "Materialize") && inner.children[0]) {
|
|
1576
|
+
inner = inner.children[0];
|
|
1577
|
+
}
|
|
1566
1578
|
if (inner.indexCond || inner.recheckCond) return [];
|
|
1567
1579
|
const outer = node.children[0];
|
|
1568
1580
|
if (!outer) return [];
|
|
@@ -1881,6 +1893,47 @@ var indexOnlyHeapFetches = {
|
|
|
1881
1893
|
}
|
|
1882
1894
|
};
|
|
1883
1895
|
|
|
1896
|
+
// src/advisor/rules/limit-large-offset.ts
|
|
1897
|
+
var limitLargeOffset = {
|
|
1898
|
+
id: "PGX_LIMIT_LARGE_OFFSET",
|
|
1899
|
+
title: "LIMIT discards a large prefix (OFFSET pagination)",
|
|
1900
|
+
defaultSeverity: "warn",
|
|
1901
|
+
requiresAnalyze: true,
|
|
1902
|
+
check(node, ctx) {
|
|
1903
|
+
if (node.nodeType !== "Limit") return [];
|
|
1904
|
+
const child = outerChild(node);
|
|
1905
|
+
const emitted = node.metrics.totalRows;
|
|
1906
|
+
const produced = child?.metrics.totalRows;
|
|
1907
|
+
if (emitted === void 0 || produced === void 0) return [];
|
|
1908
|
+
const discarded = produced - emitted;
|
|
1909
|
+
if (discarded < ctx.thresholds.limitDiscardRows) return [];
|
|
1910
|
+
const rel = child?.relationName ?? "the input";
|
|
1911
|
+
return [
|
|
1912
|
+
makeFinding(limitLargeOffset, ctx, node, {
|
|
1913
|
+
title: `LIMIT discarded ${fmtInt(discarded)} rows before returning ${fmtInt(emitted)}`,
|
|
1914
|
+
detail: `The plan produced ${fmtInt(produced)} rows from ${rel} but the Limit node returned only ${fmtInt(emitted)} \u2014 ${fmtInt(discarded)} rows were generated just to be skipped.`,
|
|
1915
|
+
cause: "OFFSET-style pagination makes Postgres compute and discard every row before the requested page, so deep pages get progressively slower (page N costs O(N)).",
|
|
1916
|
+
remediation: {
|
|
1917
|
+
summary: "Switch to keyset (seek) pagination: filter on the last-seen sort key instead of skipping rows, and keep an index on the sort key so each page is a direct index seek.",
|
|
1918
|
+
steps: [
|
|
1919
|
+
"Order by a unique (or tie-broken) key, e.g. ORDER BY created_at, id.",
|
|
1920
|
+
"Pass the last row's key from the previous page instead of an OFFSET.",
|
|
1921
|
+
"Index the sort key so the WHERE clause seeks directly to the page start."
|
|
1922
|
+
],
|
|
1923
|
+
commands: [
|
|
1924
|
+
{
|
|
1925
|
+
label: "Keyset pagination instead of OFFSET",
|
|
1926
|
+
sql: "SELECT \u2026 FROM t WHERE (created_at, id) > ($last_created_at, $last_id) ORDER BY created_at, id LIMIT 50;"
|
|
1927
|
+
}
|
|
1928
|
+
]
|
|
1929
|
+
},
|
|
1930
|
+
docsUrl: `${DOCS2}/queries-limit.html`,
|
|
1931
|
+
meta: { discarded: Math.round(discarded), emitted: Math.round(emitted) }
|
|
1932
|
+
})
|
|
1933
|
+
];
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1884
1937
|
// src/advisor/rules/low-cache-hit.ts
|
|
1885
1938
|
var MIN_READ_BLOCKS = 1e3;
|
|
1886
1939
|
var lowCacheHit = {
|
|
@@ -1927,6 +1980,46 @@ var lowCacheHit = {
|
|
|
1927
1980
|
}
|
|
1928
1981
|
};
|
|
1929
1982
|
|
|
1983
|
+
// src/advisor/rules/memoize-evictions.ts
|
|
1984
|
+
var memoizeEvictions = {
|
|
1985
|
+
id: "PGX_MEMOIZE_EVICTIONS",
|
|
1986
|
+
title: "Memoize cache is thrashing",
|
|
1987
|
+
defaultSeverity: "warn",
|
|
1988
|
+
requiresAnalyze: true,
|
|
1989
|
+
check(node, ctx) {
|
|
1990
|
+
if (node.nodeType !== "Memoize") return [];
|
|
1991
|
+
const hits = node.cacheHits ?? 0;
|
|
1992
|
+
const evictions = node.cacheEvictions ?? 0;
|
|
1993
|
+
const overflows = node.cacheOverflows ?? 0;
|
|
1994
|
+
const thrashing = evictions > hits;
|
|
1995
|
+
if (!thrashing && overflows === 0) return [];
|
|
1996
|
+
return [
|
|
1997
|
+
makeFinding(memoizeEvictions, ctx, node, {
|
|
1998
|
+
title: overflows > 0 ? `Memoize cache overflowed ${fmtInt(overflows)} time(s)` : `Memoize evicted ${fmtInt(evictions)} entries against ${fmtInt(hits)} hits`,
|
|
1999
|
+
detail: `The Memoize cache recorded ${fmtInt(hits)} hits, ${fmtInt(node.cacheMisses ?? 0)} misses, ${fmtInt(evictions)} evictions, and ${fmtInt(overflows)} overflows \u2014 entries are being thrown away before they can be reused.`,
|
|
2000
|
+
cause: "The distinct key values do not fit in the memory Memoize is allowed (derived from work_mem \xD7 hash_mem_multiplier), so the cache churns and the node degenerates into a slower re-executing inner side.",
|
|
2001
|
+
remediation: {
|
|
2002
|
+
summary: "Give the session more cache memory (work_mem / hash_mem_multiplier) so the key space fits, or reduce the number of distinct keys flowing into the Memoize.",
|
|
2003
|
+
steps: [
|
|
2004
|
+
"Estimate the distinct keys: the planner sizes the cache from ndistinct of the join key.",
|
|
2005
|
+
"Raise work_mem (or hash_mem_multiplier on PG 15+) for this workload and re-run.",
|
|
2006
|
+
"If the key space is genuinely huge, an index on the inner side may beat Memoize \u2014 compare with enable_memoize = off."
|
|
2007
|
+
],
|
|
2008
|
+
commands: [
|
|
2009
|
+
{ label: "More cache memory for this session", sql: "SET work_mem = '64MB';" },
|
|
2010
|
+
{
|
|
2011
|
+
label: "Compare the plan without Memoize",
|
|
2012
|
+
sql: "SET enable_memoize = off; EXPLAIN ANALYZE <query>;"
|
|
2013
|
+
}
|
|
2014
|
+
]
|
|
2015
|
+
},
|
|
2016
|
+
docsUrl: `${DOCS2}/runtime-config-resource.html`,
|
|
2017
|
+
meta: { hits, evictions, overflows }
|
|
2018
|
+
})
|
|
2019
|
+
];
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
|
|
1930
2023
|
// src/advisor/rules/nested-loop-large-outer.ts
|
|
1931
2024
|
var nestedLoopLargeOuter = {
|
|
1932
2025
|
id: "PGX_NESTED_LOOP_LARGE_OUTER",
|
|
@@ -2309,8 +2402,10 @@ var ALL_RULES = [
|
|
|
2309
2402
|
seqScanLarge,
|
|
2310
2403
|
nestedLoopLargeOuter,
|
|
2311
2404
|
highFilterDiscard,
|
|
2405
|
+
limitLargeOffset,
|
|
2312
2406
|
sortSpillDisk,
|
|
2313
2407
|
hashSpillDisk,
|
|
2408
|
+
memoizeEvictions,
|
|
2314
2409
|
correlatedSubplan,
|
|
2315
2410
|
rowMisestimate,
|
|
2316
2411
|
filterCouldBeIndexCond,
|
|
@@ -2338,7 +2433,7 @@ function runAdvisor(tree, config = DEFAULT_CONFIG) {
|
|
|
2338
2433
|
if (rule.requiresAnalyze && !tree.hasAnalyze) continue;
|
|
2339
2434
|
if (rule.requiresBuffers && !tree.hasBuffers) continue;
|
|
2340
2435
|
for (const node of nodes) {
|
|
2341
|
-
for (const
|
|
2436
|
+
for (const finding2 of rule.check(node, ctx)) diagnostics.push(finding2);
|
|
2342
2437
|
}
|
|
2343
2438
|
}
|
|
2344
2439
|
diagnostics.sort(bySeverity);
|
|
@@ -3235,35 +3330,6 @@ function diffAnalyses(before, after) {
|
|
|
3235
3330
|
};
|
|
3236
3331
|
}
|
|
3237
3332
|
|
|
3238
|
-
// src/locks/live.ts
|
|
3239
|
-
var SQL = `
|
|
3240
|
-
SELECT a.pid,
|
|
3241
|
-
a.usename AS "user",
|
|
3242
|
-
a.state,
|
|
3243
|
-
a.wait_event_type AS "waitEventType",
|
|
3244
|
-
a.wait_event AS "waitEvent",
|
|
3245
|
-
EXTRACT(EPOCH FROM (now() - a.query_start)) AS "ageSeconds",
|
|
3246
|
-
a.query,
|
|
3247
|
-
pg_blocking_pids(a.pid) AS "blockedBy"
|
|
3248
|
-
FROM pg_stat_activity a
|
|
3249
|
-
WHERE a.backend_type = 'client backend' AND a.pid <> pg_backend_pid()
|
|
3250
|
-
ORDER BY cardinality(pg_blocking_pids(a.pid)) DESC, a.query_start NULLS LAST;
|
|
3251
|
-
`;
|
|
3252
|
-
async function liveLocks(connection, capturedAt) {
|
|
3253
|
-
const rows = await queryReadOnly(connection, SQL);
|
|
3254
|
-
const sessions = rows.map((r) => ({
|
|
3255
|
-
pid: r.pid,
|
|
3256
|
-
user: r.user,
|
|
3257
|
-
state: r.state,
|
|
3258
|
-
waitEventType: r.waitEventType,
|
|
3259
|
-
waitEvent: r.waitEvent,
|
|
3260
|
-
ageSeconds: r.ageSeconds == null ? null : Number(r.ageSeconds),
|
|
3261
|
-
query: r.query,
|
|
3262
|
-
blockedBy: r.blockedBy ?? []
|
|
3263
|
-
}));
|
|
3264
|
-
return { sessions, blocked: sessions.filter((s) => s.blockedBy.length > 0), capturedAt };
|
|
3265
|
-
}
|
|
3266
|
-
|
|
3267
3333
|
// src/server/schema.ts
|
|
3268
3334
|
var CATALOG_SQL = `
|
|
3269
3335
|
SELECT n.nspname AS schema,
|
|
@@ -3284,7 +3350,7 @@ async function catalog(connection) {
|
|
|
3284
3350
|
);
|
|
3285
3351
|
return rows.map((r) => ({ schema: r.schema, name: r.name, columns: r.columns ?? [] }));
|
|
3286
3352
|
}
|
|
3287
|
-
var
|
|
3353
|
+
var SQL = `
|
|
3288
3354
|
SELECT c.relname AS relation,
|
|
3289
3355
|
c.reltuples::bigint AS "estRows",
|
|
3290
3356
|
pg_total_relation_size(c.oid) AS "totalBytes",
|
|
@@ -3295,7 +3361,9 @@ SELECT c.relname AS relation,
|
|
|
3295
3361
|
s.last_vacuum AS "lastVacuum",
|
|
3296
3362
|
s.last_autovacuum AS "lastAutovacuum",
|
|
3297
3363
|
s.last_analyze AS "lastAnalyze",
|
|
3298
|
-
s.last_autoanalyze AS "lastAutoanalyze"
|
|
3364
|
+
s.last_autoanalyze AS "lastAutoanalyze",
|
|
3365
|
+
s.n_mod_since_analyze AS "modSinceAnalyze",
|
|
3366
|
+
s.n_live_tup AS "liveTup"
|
|
3299
3367
|
FROM pg_class c
|
|
3300
3368
|
LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
|
|
3301
3369
|
WHERE c.relkind IN ('r', 'p') AND c.relname = ANY($1);
|
|
@@ -3304,7 +3372,7 @@ var toNum = (v) => v == null ? null : Number(v);
|
|
|
3304
3372
|
async function relationStats(connection, relations) {
|
|
3305
3373
|
const names = [...new Set(relations.filter(Boolean))];
|
|
3306
3374
|
if (names.length === 0) return [];
|
|
3307
|
-
const rows = await queryReadOnly(connection,
|
|
3375
|
+
const rows = await queryReadOnly(connection, SQL, [names]);
|
|
3308
3376
|
return rows.map((r) => ({
|
|
3309
3377
|
relation: r.relation,
|
|
3310
3378
|
estRows: toNum(r.estRows),
|
|
@@ -3314,9 +3382,106 @@ async function relationStats(connection, relations) {
|
|
|
3314
3382
|
lastVacuum: r.lastVacuum,
|
|
3315
3383
|
lastAutovacuum: r.lastAutovacuum,
|
|
3316
3384
|
lastAnalyze: r.lastAnalyze,
|
|
3317
|
-
lastAutoanalyze: r.lastAutoanalyze
|
|
3385
|
+
lastAutoanalyze: r.lastAutoanalyze,
|
|
3386
|
+
modSinceAnalyze: toNum(r.modSinceAnalyze),
|
|
3387
|
+
liveTup: toNum(r.liveTup)
|
|
3318
3388
|
}));
|
|
3319
3389
|
}
|
|
3390
|
+
|
|
3391
|
+
// src/diagnostics/stale-stats.ts
|
|
3392
|
+
var RULE_ID = "PGX_STALE_STATISTICS";
|
|
3393
|
+
var DOCS4 = "https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-STATISTICS";
|
|
3394
|
+
var MIN_ROWS = 1e3;
|
|
3395
|
+
function staleStatsFindings(stats, config) {
|
|
3396
|
+
if (config.rules[RULE_ID]?.enabled === false) return [];
|
|
3397
|
+
const severity = config.rules[RULE_ID]?.severity ?? "warn";
|
|
3398
|
+
const ratioLimit = config.thresholds.staleStatsModRatio;
|
|
3399
|
+
const out = [];
|
|
3400
|
+
for (const s of stats) {
|
|
3401
|
+
const rows = s.liveTup ?? s.estRows ?? 0;
|
|
3402
|
+
if (rows < MIN_ROWS) continue;
|
|
3403
|
+
const neverAnalyzed = !s.lastAnalyze && !s.lastAutoanalyze;
|
|
3404
|
+
const modRatio = s.modSinceAnalyze != null && rows > 0 ? s.modSinceAnalyze / rows : 0;
|
|
3405
|
+
if (!neverAnalyzed && modRatio < ratioLimit) continue;
|
|
3406
|
+
out.push(
|
|
3407
|
+
finding(RULE_ID, severity, {
|
|
3408
|
+
title: neverAnalyzed ? `Table ${s.relation} has never been analyzed` : `Planner statistics on ${s.relation} are stale`,
|
|
3409
|
+
detail: neverAnalyzed ? `${s.relation} (~${Math.round(rows).toLocaleString()} rows) has no planner statistics \u2014 pg_stat_user_tables shows no manual or auto ANALYZE.` : `${s.modSinceAnalyze?.toLocaleString()} rows of ${s.relation} changed since its last ANALYZE (${(modRatio * 100).toFixed(0)}% of ~${Math.round(rows).toLocaleString()} live rows).`,
|
|
3410
|
+
cause: "The planner chooses plans from per-table statistics. When they are missing or stale, row estimates drift, which cascades into bad join orders, wrong scan types, and misestimates like PGX_ROW_MISESTIMATE.",
|
|
3411
|
+
remediation: {
|
|
3412
|
+
summary: `Run ANALYZE on ${s.relation}, and if it keeps going stale, lower its autovacuum analyze threshold.`,
|
|
3413
|
+
steps: [
|
|
3414
|
+
"ANALYZE the table now to refresh statistics.",
|
|
3415
|
+
"If the table churns heavily, tune per-table autovacuum settings so auto-analyze keeps up."
|
|
3416
|
+
],
|
|
3417
|
+
commands: [
|
|
3418
|
+
{ label: "Refresh statistics", sql: `ANALYZE ${s.relation};` },
|
|
3419
|
+
{
|
|
3420
|
+
label: "Analyze more eagerly on churny tables",
|
|
3421
|
+
sql: `ALTER TABLE ${s.relation} SET (autovacuum_analyze_scale_factor = 0.02);`
|
|
3422
|
+
}
|
|
3423
|
+
]
|
|
3424
|
+
},
|
|
3425
|
+
docsUrl: DOCS4,
|
|
3426
|
+
meta: {
|
|
3427
|
+
relation: s.relation,
|
|
3428
|
+
modSinceAnalyze: s.modSinceAnalyze ?? 0,
|
|
3429
|
+
liveTup: s.liveTup ?? 0
|
|
3430
|
+
}
|
|
3431
|
+
})
|
|
3432
|
+
);
|
|
3433
|
+
}
|
|
3434
|
+
return out;
|
|
3435
|
+
}
|
|
3436
|
+
async function checkStaleStats(connection, result, config) {
|
|
3437
|
+
try {
|
|
3438
|
+
const relations = [
|
|
3439
|
+
...new Set(
|
|
3440
|
+
flatten(result.tree.root).map((n) => n.relationName).filter((r) => !!r)
|
|
3441
|
+
)
|
|
3442
|
+
];
|
|
3443
|
+
if (!relations.length) return;
|
|
3444
|
+
appendFindings(result, staleStatsFindings(await relationStats(connection, relations), config));
|
|
3445
|
+
} catch {
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
function appendFindings(result, extra) {
|
|
3449
|
+
if (!extra.length) return;
|
|
3450
|
+
result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
|
|
3451
|
+
result.worstSeverity = result.diagnostics.reduce(
|
|
3452
|
+
(worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
|
|
3453
|
+
null
|
|
3454
|
+
);
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
// src/locks/live.ts
|
|
3458
|
+
var SQL2 = `
|
|
3459
|
+
SELECT a.pid,
|
|
3460
|
+
a.usename AS "user",
|
|
3461
|
+
a.state,
|
|
3462
|
+
a.wait_event_type AS "waitEventType",
|
|
3463
|
+
a.wait_event AS "waitEvent",
|
|
3464
|
+
EXTRACT(EPOCH FROM (now() - a.query_start)) AS "ageSeconds",
|
|
3465
|
+
a.query,
|
|
3466
|
+
pg_blocking_pids(a.pid) AS "blockedBy"
|
|
3467
|
+
FROM pg_stat_activity a
|
|
3468
|
+
WHERE a.backend_type = 'client backend' AND a.pid <> pg_backend_pid()
|
|
3469
|
+
ORDER BY cardinality(pg_blocking_pids(a.pid)) DESC, a.query_start NULLS LAST;
|
|
3470
|
+
`;
|
|
3471
|
+
async function liveLocks(connection, capturedAt) {
|
|
3472
|
+
const rows = await queryReadOnly(connection, SQL2);
|
|
3473
|
+
const sessions = rows.map((r) => ({
|
|
3474
|
+
pid: r.pid,
|
|
3475
|
+
user: r.user,
|
|
3476
|
+
state: r.state,
|
|
3477
|
+
waitEventType: r.waitEventType,
|
|
3478
|
+
waitEvent: r.waitEvent,
|
|
3479
|
+
ageSeconds: r.ageSeconds == null ? null : Number(r.ageSeconds),
|
|
3480
|
+
query: r.query,
|
|
3481
|
+
blockedBy: r.blockedBy ?? []
|
|
3482
|
+
}));
|
|
3483
|
+
return { sessions, blocked: sessions.filter((s) => s.blockedBy.length > 0), capturedAt };
|
|
3484
|
+
}
|
|
3320
3485
|
function dataDir() {
|
|
3321
3486
|
return process.env.PGEXPLAIN_DATA_DIR ?? join(homedir(), ".pgexplain");
|
|
3322
3487
|
}
|
|
@@ -3694,6 +3859,7 @@ function apiRoutes(store, config) {
|
|
|
3694
3859
|
sql: statement,
|
|
3695
3860
|
config: config.current
|
|
3696
3861
|
});
|
|
3862
|
+
await checkStaleStats(connection, result, config.current);
|
|
3697
3863
|
const report = {
|
|
3698
3864
|
...buildReport(result),
|
|
3699
3865
|
server: { major: exec.caps.major, omitted: exec.omitted }
|
|
@@ -3709,7 +3875,11 @@ function apiRoutes(store, config) {
|
|
|
3709
3875
|
const afterPlan = body.afterPlan ?? planTextOf(store, body.afterId);
|
|
3710
3876
|
const before = analyze(beforePlan, { redact: body.redact });
|
|
3711
3877
|
const after = analyze(afterPlan, { redact: body.redact });
|
|
3712
|
-
return c.json(
|
|
3878
|
+
return c.json({
|
|
3879
|
+
...diffAnalyses(before, after),
|
|
3880
|
+
beforePlan: serializeNode(before.tree.root),
|
|
3881
|
+
afterPlan: serializeNode(after.tree.root)
|
|
3882
|
+
});
|
|
3713
3883
|
});
|
|
3714
3884
|
api.post("/api/export", async (c) => {
|
|
3715
3885
|
const body = validate(ExportBodySchema, await c.req.json().catch(() => ({})));
|