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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5a1be66: - **New rule `PGX_MEMOIZE_EVICTIONS`**: flags a thrashing Memoize cache (evictions outpacing hits, or cache overflows) with a `work_mem` / `hash_mem_multiplier` remediation. The parser now normalizes Memoize cache counters (`Cache Hits/Misses/Evictions/Overflows`).
8
+ - **Studio component tests**: React Testing Library + happy-dom cover FindingCard, the side-by-side DiffPanel, and toasts; the web test project runs in CI via `pnpm test`.
9
+ - **Fix `PGX_CARTESIAN_PRODUCT` false positive**: the rule now looks through Memoize/Materialize to the real inner scan, so `Nested Loop → Memoize → Index Scan (parameterized)` is no longer misreported as a cross join.
10
+ - 5a1be66: New analysis capabilities:
11
+
12
+ - **New rule `PGX_LIMIT_LARGE_OFFSET`**: flags OFFSET-style pagination where the plan generates and discards a large row prefix; recommends keyset pagination. Tunable via `limitDiscardRows`.
13
+ - **New check `PGX_STALE_STATISTICS`** (run path only): flags tables in the plan that were never analyzed or churned past `staleStatsModRatio` (default 20%) since their last ANALYZE — the usual root cause behind row misestimates.
14
+ - **New command `pg-explain locks`**: live lock-contention snapshot (who is blocked, by whom, for how long) with cancel/terminate remediation; `--fail-on-blocked` exits 1 for scripting; terminal and JSON output.
15
+ - **Studio: side-by-side plan diff** — the diff view now renders both plan trees with slower/faster/added/removed nodes highlighted.
16
+ - **Studio: shareable run URLs** — every stored run gets a `#run=<id>` deep link plus a copy-link button.
17
+ - Shell completion now includes the `locks` and `studio` subcommands.
18
+
19
+ ### Patch Changes
20
+
21
+ - 5a1be66: Studio & DX quality pass:
22
+
23
+ - Studio: toast notifications — export failures and settings saves are no longer silent
24
+ - Studio: keyboard shortcuts (⌘/Ctrl+K focus editor, ⇧⌘/Ctrl+F format SQL, `?` help overlay) and ARIA tablist/landmark roles
25
+ - Studio: collapsible sidebar and history filter box
26
+ - Library: export `severityAtLeast` for CI-gate scripting; README library examples expanded
27
+ - Tests: snapshot coverage for all five render formats, command-flow tests for `analyze`/`diff` exit codes, and a new `web` vitest project covering studio helpers
28
+ - Dev: `pnpm dev:studio` runs core rebuild + API restart + Vite HMR in one terminal
29
+
3
30
  All notable changes to this project will be documented in this file.
4
31
 
5
32
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
package/README.md CHANGED
@@ -24,6 +24,31 @@ The same philosophy applies to operational errors: auth failures, timeouts, unre
24
24
 
25
25
  ---
26
26
 
27
+ ## Features
28
+
29
+ **Advisor & analysis**
30
+ - **18 plan-anti-pattern rules** (`PGX_*`) — cartesian products, large seq scans, disk-spilling sorts/hashes, thrashing Memoize caches, misestimates, lossy bitmaps, OFFSET pagination, low cache-hit ratio, JIT/trigger overhead, and more.
31
+ - **`PGX_STALE_STATISTICS`** — on the `run` path, flags tables that were never analyzed or have churned since their last `ANALYZE`, explaining the usual root cause behind misestimates.
32
+ - **Lock advisor** — static warnings for risky DDL/DML (table rewrites, missing `CONCURRENTLY`, unbounded `FOR UPDATE`, …) plus a **live lock-contention** snapshot (who is blocked, by whom).
33
+ - Every finding ships **what / why / fix**, with copy-pasteable SQL/shell commands and a Postgres docs link.
34
+ - Config file (`.pgexplainrc[.json]` or `package.json#pgExplain`) to tune thresholds and enable/disable/re-sever individual rules.
35
+
36
+ **CLI**
37
+ - `pg-explain [FILE]` — analyze a plan from a file, `< stdin`, or a whole directory (batch mode).
38
+ - `pg-explain run` — connect, `EXPLAIN` safely (rolled back, read-only unless `--force`), analyze.
39
+ - `pg-explain diff` — compare two plans and gate CI on regressions or new findings.
40
+ - `pg-explain locks` — snapshot live lock contention from the terminal; `--fail-on-blocked` for scripting.
41
+ - `pg-explain studio` — launch the local web UI.
42
+ - 5 output formats (terminal, markdown, HTML, JSON, plain text), stable exit codes for scripting, shell completion.
43
+
44
+ **Studio (local web UI)** — see screenshots below.
45
+ - Run a query or paste a plan; interactive **plan graph** (heat-colored by time/rows/cost/buffers) with per-node detail, or a text tree view.
46
+ - **History** of every run, **side-by-side plan diff** between any two, and **shareable `#run=` links**.
47
+ - **Stats** by node type / table / index, table catalog with autocomplete, **Settings** to tune thresholds live.
48
+ - Keyboard shortcuts (`⌘/Ctrl+Enter` run, `⌘/Ctrl+K` focus editor, `⇧⌘/Ctrl+F` format, `?` for help), light/dark theme, collapsible sidebar with history search.
49
+
50
+ ---
51
+
27
52
  ## Install
28
53
 
29
54
  ```sh
@@ -85,16 +110,63 @@ just works — the PostgreSQL driver and the UI are only loaded on demand.
85
110
  rollback-wrapped, read-only; non-`SELECT` refused unless forced).
86
111
  - **Findings** as plain-language cards (what / why / fix + copy-paste commands + docs links).
87
112
  - **Lock advisor** — static warnings (rewrites, missing `CONCURRENTLY`, unindexed `UPDATE/DELETE`,
88
- unbounded `FOR UPDATE`, …) plus a **🔒 Live locks** view of current blocking chains.
89
- - Interactive **plan tree** (heat-colored by self-time), **bottlenecks**, raw JSON.
90
- - **History** of every run (SQLite under `~/.pgexplain`), **Compare** any two runs (structured
91
- diff), and **Export** to Markdown / HTML / JSON.
92
- - **Saved connections** and a **Settings** page to tune advisor thresholds (applied live).
113
+ unbounded `FOR UPDATE`, …) plus a **live locks** view of current blocking chains.
114
+ - Interactive **plan graph** (heat-colored by time/rows/cost/buffers, click a node for full detail)
115
+ or a plain-text tree, **Stats** by node type/table/index, table catalog with autocomplete.
116
+ - **History** of every run (SQLite under `~/.pgexplain`), a **side-by-side diff** between any two
117
+ runs with regressed/improved/added/removed nodes highlighted, and **shareable `#run=` links**.
118
+ - **Export** to Markdown / HTML / JSON, and a **Settings** page to tune advisor thresholds live.
119
+ - Light/dark theme, keyboard shortcuts (`?` for the full list), collapsible sidebar with history search.
93
120
 
94
121
  Flags: `pg-explain studio [--port 5177] [--host 127.0.0.1] [--no-open] [--unsafe-host]`.
95
122
  Binding a non-loopback host requires `--unsafe-host` (the studio can reach arbitrary databases,
96
123
  so exposing it is an SSRF/credential risk). Set `PGEXPLAIN_DATA_DIR` to relocate the local store.
97
124
 
125
+ <table>
126
+ <tr>
127
+ <td width="50%">
128
+
129
+ **Findings** — what / why / fix, with copy-pasteable SQL
130
+ <img src="docs/screenshots/findings-light.png" alt="Studio findings tab showing plain-language diagnostics with remediation SQL">
131
+
132
+ </td>
133
+ <td width="50%">
134
+
135
+ **Plan graph** — heat-colored, click a node for full detail
136
+ <img src="docs/screenshots/plan-graph.png" alt="Studio interactive plan graph with a node detail panel open">
137
+
138
+ </td>
139
+ </tr>
140
+ <tr>
141
+ <td width="50%">
142
+
143
+ **Side-by-side diff** — before/after plans with regressions highlighted
144
+ <img src="docs/screenshots/diff-side-by-side.png" alt="Studio diff view comparing two runs with a side-by-side plan tree">
145
+
146
+ </td>
147
+ <td width="50%">
148
+
149
+ **Stats** — self-time rolled up by node type, table, and index
150
+ <img src="docs/screenshots/stats-tab.png" alt="Studio stats tab with self-time breakdowns">
151
+
152
+ </td>
153
+ </tr>
154
+ <tr>
155
+ <td width="50%">
156
+
157
+ **Dark mode**
158
+ <img src="docs/screenshots/plan-graph-dark.png" alt="Studio in dark mode showing the plan graph">
159
+
160
+ </td>
161
+ <td width="50%">
162
+
163
+ **Keyboard shortcuts** (press `?`)
164
+ <img src="docs/screenshots/keyboard-shortcuts.png" alt="Studio keyboard shortcuts overlay">
165
+
166
+ </td>
167
+ </tr>
168
+ </table>
169
+
98
170
  ---
99
171
 
100
172
  ## Example output
@@ -177,6 +249,8 @@ Total execution time: 321.0 ms
177
249
  | `pg-explain [FILE]` | **Analyze** a plan from a file, `< stdin`, or every plan in a directory (batch mode). This is the default command. |
178
250
  | `pg-explain run` | **Connect** to PostgreSQL, run `EXPLAIN` safely, and analyze the result. Needs the optional `pg` driver. |
179
251
  | `pg-explain diff <before> <after>` | **Compare** two plan JSON files and report regressions. Designed as a CI gate. |
252
+ | `pg-explain locks` | Snapshot **live lock contention**: who is blocked and by whom (`pg_blocking_pids`), with a cancel/terminate remediation. `--fail-on-blocked` exits 1 for scripting. Needs the optional `pg` driver. |
253
+ | `pg-explain studio` | Launch the local **web UI** (history, plan graph, diff, live locks). |
180
254
  | `pg-explain completion <bash\|zsh\|fish>` | Print a shell **completion** script for the given shell. |
181
255
 
182
256
  Run `pg-explain --help`, `pg-explain run --help`, or `pg-explain diff --help` for the full flag list.
@@ -239,7 +313,7 @@ Shared output flags: `--tldr` (summary + findings, no plan tree), `--redact` (st
239
313
 
240
314
  ## The advisor
241
315
 
242
- The advisor ships **16 rules**, each identified by a stable, greppable `PGX_*` code (the rule id is the diagnostic code, and the config key). They run in roughly most-actionable-first order:
316
+ The advisor ships **18 rules**, each identified by a stable, greppable `PGX_*` code (the rule id is the diagnostic code, and the config key). They run in roughly most-actionable-first order:
243
317
 
244
318
  | Code | Flags when… |
245
319
  | --- | --- |
@@ -247,8 +321,10 @@ The advisor ships **16 rules**, each identified by a stable, greppable `PGX_*` c
247
321
  | `PGX_SEQ_SCAN_LARGE` | A sequential scan reads a large table that an index could narrow. |
248
322
  | `PGX_NESTED_LOOP_LARGE_OUTER` | A nested loop is driven by a large outer side (re-probes inner repeatedly). |
249
323
  | `PGX_HIGH_FILTER_DISCARD` | A node reads many rows then discards most of them via a filter. |
324
+ | `PGX_LIMIT_LARGE_OFFSET` | A `LIMIT` discards a large generated prefix (OFFSET pagination — use keyset). |
250
325
  | `PGX_SORT_SPILL_DISK` | A sort spilled to disk instead of staying in `work_mem`. |
251
326
  | `PGX_HASH_SPILL_DISK` | A hash join's build side spilled to disk (multiple batches). |
327
+ | `PGX_MEMOIZE_EVICTIONS` | A Memoize cache is thrashing (evictions outpace hits, or entries overflow `work_mem`). |
252
328
  | `PGX_CORRELATED_SUBPLAN` | A correlated subplan is re-executed once per outer row. |
253
329
  | `PGX_ROW_MISESTIMATE` | Estimated vs actual row counts diverge sharply (stale/missing stats). |
254
330
  | `PGX_FILTER_COULD_BE_INDEX_COND` | A residual filter could be pushed into an index condition. |
@@ -262,6 +338,8 @@ The advisor ships **16 rules**, each identified by a stable, greppable `PGX_*` c
262
338
 
263
339
  Every finding includes the *what / why / fix* triad shown in the example above. Rules can be tuned or disabled per project (see [Config](#config-file)).
264
340
 
341
+ One additional check runs only on the `run` path (it needs a live connection, not just a plan): `PGX_STALE_STATISTICS` flags tables in the plan that were never analyzed or have churned past `staleStatsModRatio` (default 20%) since their last ANALYZE — the most common root cause behind `PGX_ROW_MISESTIMATE`. It is configured like any other rule.
342
+
265
343
  > pgexplain also has an **operational error catalog** of stable `PGX_*` codes — auth failures, unreachable hosts, SSL problems, timeouts, malformed/empty input, missing driver, and more — each with a title, cause, remediation, and Postgres docs link.
266
344
 
267
345
  ---
@@ -345,6 +423,24 @@ console.log(markdown);
345
423
 
346
424
  `analyze(input, options?)` parses the EXPLAIN JSON, optionally redacts literals, computes metrics, and runs the advisor. `render(result, options?)` emits any supported format. Other exports include `runAdvisor`, `parseExplainJson`, `computeMetrics`, `DEFAULT_CONFIG`, `FORMATS`, `JSON_SCHEMA_VERSION`, `ExitCode`, and the full type set.
347
425
 
426
+ For finer control — e.g. custom thresholds or gating a deploy script on severity:
427
+
428
+ ```ts
429
+ import { analyze, DEFAULT_CONFIG, severityAtLeast } from "pgexplain";
430
+
431
+ const result = analyze(explainJson, {
432
+ config: {
433
+ ...DEFAULT_CONFIG,
434
+ thresholds: { ...DEFAULT_CONFIG.thresholds, seqScanRows: 10_000 },
435
+ rules: { PGX_LOW_CACHE_HIT: { enabled: false } },
436
+ },
437
+ });
438
+
439
+ if (result.worstSeverity && severityAtLeast(result.worstSeverity, "warn")) {
440
+ process.exit(1); // same behaviour as `pg-explain --fail-on warn`
441
+ }
442
+ ```
443
+
348
444
  ---
349
445
 
350
446
  ## Exit codes
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { spawn } from 'child_process';
9
9
 
10
10
  // package.json
11
11
  var package_default = {
12
- version: "0.2.0"};
12
+ version: "0.3.0"};
13
13
 
14
14
  // src/diagnostics/diagnostic.ts
15
15
  var AppError = class extends Error {
@@ -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];
@@ -430,7 +433,9 @@ var DEFAULT_THRESHOLDS = {
430
433
  correlatedLoops: 1e3,
431
434
  jitPct: 25,
432
435
  triggerPct: 10,
433
- lowCacheHitRatio: 0.9
436
+ lowCacheHitRatio: 0.9,
437
+ limitDiscardRows: 1e4,
438
+ staleStatsModRatio: 0.2
434
439
  };
435
440
  var DEFAULT_CONFIG = {
436
441
  thresholds: { ...DEFAULT_THRESHOLDS },
@@ -894,6 +899,10 @@ function normalizeNode(raw, nextId) {
894
899
  diskUsage: num(raw, "Disk Usage"),
895
900
  exactHeapBlocks: num(raw, "Exact Heap Blocks"),
896
901
  lossyHeapBlocks: num(raw, "Lossy Heap Blocks"),
902
+ cacheHits: num(raw, "Cache Hits"),
903
+ cacheMisses: num(raw, "Cache Misses"),
904
+ cacheEvictions: num(raw, "Cache Evictions"),
905
+ cacheOverflows: num(raw, "Cache Overflows"),
897
906
  sharedHitBlocks: num(raw, "Shared Hit Blocks"),
898
907
  sharedReadBlocks: num(raw, "Shared Read Blocks"),
899
908
  sharedDirtiedBlocks: num(raw, "Shared Dirtied Blocks"),
@@ -1241,8 +1250,11 @@ var cartesianProduct = {
1241
1250
  check(node, ctx) {
1242
1251
  if (node.nodeType !== "Nested Loop") return [];
1243
1252
  if (node.joinFilter) return [];
1244
- const inner = node.children[1];
1253
+ let inner = node.children[1];
1245
1254
  if (!inner) return [];
1255
+ while ((inner.nodeType === "Memoize" || inner.nodeType === "Materialize") && inner.children[0]) {
1256
+ inner = inner.children[0];
1257
+ }
1246
1258
  if (inner.indexCond || inner.recheckCond) return [];
1247
1259
  const outer = node.children[0];
1248
1260
  if (!outer) return [];
@@ -1561,6 +1573,47 @@ var indexOnlyHeapFetches = {
1561
1573
  }
1562
1574
  };
1563
1575
 
1576
+ // src/advisor/rules/limit-large-offset.ts
1577
+ var limitLargeOffset = {
1578
+ id: "PGX_LIMIT_LARGE_OFFSET",
1579
+ title: "LIMIT discards a large prefix (OFFSET pagination)",
1580
+ defaultSeverity: "warn",
1581
+ requiresAnalyze: true,
1582
+ check(node, ctx) {
1583
+ if (node.nodeType !== "Limit") return [];
1584
+ const child = outerChild(node);
1585
+ const emitted = node.metrics.totalRows;
1586
+ const produced = child?.metrics.totalRows;
1587
+ if (emitted === void 0 || produced === void 0) return [];
1588
+ const discarded = produced - emitted;
1589
+ if (discarded < ctx.thresholds.limitDiscardRows) return [];
1590
+ const rel = child?.relationName ?? "the input";
1591
+ return [
1592
+ makeFinding(limitLargeOffset, ctx, node, {
1593
+ title: `LIMIT discarded ${fmtInt(discarded)} rows before returning ${fmtInt(emitted)}`,
1594
+ 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.`,
1595
+ 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)).",
1596
+ remediation: {
1597
+ 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.",
1598
+ steps: [
1599
+ "Order by a unique (or tie-broken) key, e.g. ORDER BY created_at, id.",
1600
+ "Pass the last row's key from the previous page instead of an OFFSET.",
1601
+ "Index the sort key so the WHERE clause seeks directly to the page start."
1602
+ ],
1603
+ commands: [
1604
+ {
1605
+ label: "Keyset pagination instead of OFFSET",
1606
+ sql: "SELECT \u2026 FROM t WHERE (created_at, id) > ($last_created_at, $last_id) ORDER BY created_at, id LIMIT 50;"
1607
+ }
1608
+ ]
1609
+ },
1610
+ docsUrl: `${DOCS2}/queries-limit.html`,
1611
+ meta: { discarded: Math.round(discarded), emitted: Math.round(emitted) }
1612
+ })
1613
+ ];
1614
+ }
1615
+ };
1616
+
1564
1617
  // src/advisor/rules/low-cache-hit.ts
1565
1618
  var MIN_READ_BLOCKS = 1e3;
1566
1619
  var lowCacheHit = {
@@ -1607,6 +1660,46 @@ var lowCacheHit = {
1607
1660
  }
1608
1661
  };
1609
1662
 
1663
+ // src/advisor/rules/memoize-evictions.ts
1664
+ var memoizeEvictions = {
1665
+ id: "PGX_MEMOIZE_EVICTIONS",
1666
+ title: "Memoize cache is thrashing",
1667
+ defaultSeverity: "warn",
1668
+ requiresAnalyze: true,
1669
+ check(node, ctx) {
1670
+ if (node.nodeType !== "Memoize") return [];
1671
+ const hits = node.cacheHits ?? 0;
1672
+ const evictions = node.cacheEvictions ?? 0;
1673
+ const overflows = node.cacheOverflows ?? 0;
1674
+ const thrashing = evictions > hits;
1675
+ if (!thrashing && overflows === 0) return [];
1676
+ return [
1677
+ makeFinding(memoizeEvictions, ctx, node, {
1678
+ title: overflows > 0 ? `Memoize cache overflowed ${fmtInt(overflows)} time(s)` : `Memoize evicted ${fmtInt(evictions)} entries against ${fmtInt(hits)} hits`,
1679
+ 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.`,
1680
+ 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.",
1681
+ remediation: {
1682
+ 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.",
1683
+ steps: [
1684
+ "Estimate the distinct keys: the planner sizes the cache from ndistinct of the join key.",
1685
+ "Raise work_mem (or hash_mem_multiplier on PG 15+) for this workload and re-run.",
1686
+ "If the key space is genuinely huge, an index on the inner side may beat Memoize \u2014 compare with enable_memoize = off."
1687
+ ],
1688
+ commands: [
1689
+ { label: "More cache memory for this session", sql: "SET work_mem = '64MB';" },
1690
+ {
1691
+ label: "Compare the plan without Memoize",
1692
+ sql: "SET enable_memoize = off; EXPLAIN ANALYZE <query>;"
1693
+ }
1694
+ ]
1695
+ },
1696
+ docsUrl: `${DOCS2}/runtime-config-resource.html`,
1697
+ meta: { hits, evictions, overflows }
1698
+ })
1699
+ ];
1700
+ }
1701
+ };
1702
+
1610
1703
  // src/advisor/rules/nested-loop-large-outer.ts
1611
1704
  var nestedLoopLargeOuter = {
1612
1705
  id: "PGX_NESTED_LOOP_LARGE_OUTER",
@@ -1989,8 +2082,10 @@ var ALL_RULES = [
1989
2082
  seqScanLarge,
1990
2083
  nestedLoopLargeOuter,
1991
2084
  highFilterDiscard,
2085
+ limitLargeOffset,
1992
2086
  sortSpillDisk,
1993
2087
  hashSpillDisk,
2088
+ memoizeEvictions,
1994
2089
  correlatedSubplan,
1995
2090
  rowMisestimate,
1996
2091
  filterCouldBeIndexCond,
@@ -2018,7 +2113,7 @@ function runAdvisor(tree, config = DEFAULT_CONFIG) {
2018
2113
  if (rule.requiresAnalyze && !tree.hasAnalyze) continue;
2019
2114
  if (rule.requiresBuffers && !tree.hasBuffers) continue;
2020
2115
  for (const node of nodes) {
2021
- for (const finding of rule.check(node, ctx)) diagnostics.push(finding);
2116
+ for (const finding2 of rule.check(node, ctx)) diagnostics.push(finding2);
2022
2117
  }
2023
2118
  }
2024
2119
  diagnostics.sort(bySeverity);
@@ -2807,7 +2902,7 @@ function gateTrips(result, failOn) {
2807
2902
  }
2808
2903
 
2809
2904
  // src/commands/completion.ts
2810
- var SUBCOMMANDS = "run diff completion";
2905
+ var SUBCOMMANDS = "run diff locks studio completion";
2811
2906
  var FLAGS = "--format --output --tldr --redact --ascii --color --no-color --fail-on --strict --config --statement --quiet --verbose --debug --help --version";
2812
2907
  var FORMATS2 = "terminal markdown json html text";
2813
2908
  var BASH = `# pg-explain bash completion
@@ -3274,6 +3369,25 @@ async function runExplain(opts) {
3274
3369
  function msInt(ms) {
3275
3370
  return Math.max(0, Math.floor(ms));
3276
3371
  }
3372
+ async function queryReadOnly(connection, sql, params = [], timeoutMs = 1e4) {
3373
+ const ca = connection.sslrootcert ? await readFile(connection.sslrootcert, "utf8").catch(() => void 0) : void 0;
3374
+ const client = await newClient(buildClientConfig(connection, ca));
3375
+ try {
3376
+ await client.connect();
3377
+ } catch (err) {
3378
+ throw mapConnectError(err);
3379
+ }
3380
+ try {
3381
+ await client.query(`SET statement_timeout = ${msInt(timeoutMs)}`);
3382
+ const res = await client.query({ text: sql, values: params });
3383
+ return res.rows;
3384
+ } catch (err) {
3385
+ throw mapQueryError(err);
3386
+ } finally {
3387
+ await client.end().catch(() => {
3388
+ });
3389
+ }
3390
+ }
3277
3391
  async function explainScript(connection, units, opts) {
3278
3392
  const ca = connection.sslrootcert ? await readFile(connection.sslrootcert, "utf8").catch(() => void 0) : void 0;
3279
3393
  const client = await newClient(buildClientConfig(connection, ca));
@@ -3406,6 +3520,180 @@ function mapQueryError(err) {
3406
3520
  }
3407
3521
  }
3408
3522
 
3523
+ // src/locks/live.ts
3524
+ var SQL = `
3525
+ SELECT a.pid,
3526
+ a.usename AS "user",
3527
+ a.state,
3528
+ a.wait_event_type AS "waitEventType",
3529
+ a.wait_event AS "waitEvent",
3530
+ EXTRACT(EPOCH FROM (now() - a.query_start)) AS "ageSeconds",
3531
+ a.query,
3532
+ pg_blocking_pids(a.pid) AS "blockedBy"
3533
+ FROM pg_stat_activity a
3534
+ WHERE a.backend_type = 'client backend' AND a.pid <> pg_backend_pid()
3535
+ ORDER BY cardinality(pg_blocking_pids(a.pid)) DESC, a.query_start NULLS LAST;
3536
+ `;
3537
+ async function liveLocks(connection, capturedAt) {
3538
+ const rows = await queryReadOnly(connection, SQL);
3539
+ const sessions = rows.map((r) => ({
3540
+ pid: r.pid,
3541
+ user: r.user,
3542
+ state: r.state,
3543
+ waitEventType: r.waitEventType,
3544
+ waitEvent: r.waitEvent,
3545
+ ageSeconds: r.ageSeconds == null ? null : Number(r.ageSeconds),
3546
+ query: r.query,
3547
+ blockedBy: r.blockedBy ?? []
3548
+ }));
3549
+ return { sessions, blocked: sessions.filter((s) => s.blockedBy.length > 0), capturedAt };
3550
+ }
3551
+
3552
+ // src/commands/locks.ts
3553
+ async function runLocks(args) {
3554
+ const snapshot = await liveLocks(args.connection, Date.now());
3555
+ configureColor(args.format === "terminal" ? args.color : "never");
3556
+ const text = args.format === "json" ? `${JSON.stringify(snapshot, null, 2)}
3557
+ ` : renderLocks(snapshot);
3558
+ if (args.output) await writeFile(args.output, text);
3559
+ else process.stdout.write(text);
3560
+ return args.failOnBlocked && snapshot.blocked.length > 0 ? 1 /* CiGate */ : 0 /* Success */;
3561
+ }
3562
+ function renderLocks(live) {
3563
+ const c = colors();
3564
+ const out = [
3565
+ c.bold("Live locks"),
3566
+ c.dim(`${live.sessions.length} client session(s) \xB7 ${live.blocked.length} blocked`),
3567
+ ""
3568
+ ];
3569
+ if (live.blocked.length === 0) {
3570
+ out.push("No lock contention right now \u2014 nothing is waiting on another session.");
3571
+ return `${out.join("\n")}
3572
+ `;
3573
+ }
3574
+ for (const s of live.blocked) {
3575
+ const age = s.ageSeconds != null ? ` \xB7 waiting ${s.ageSeconds.toFixed(0)}s` : "";
3576
+ const wait = s.waitEvent ? ` \xB7 ${s.waitEventType ?? "?"}/${s.waitEvent}` : "";
3577
+ out.push(
3578
+ `${c.yellow("\u26A0")} pid ${c.bold(String(s.pid))} (${s.user ?? "?"}) blocked by pid ${s.blockedBy.join(", ")}${age}${wait}`
3579
+ );
3580
+ if (s.query) out.push(c.dim(` ${s.query.replace(/\s+/g, " ").slice(0, 200)}`));
3581
+ out.push(
3582
+ c.dim(
3583
+ ` inspect the blocker; cancel with SELECT pg_cancel_backend(${s.blockedBy[0]}); or terminate with pg_terminate_backend(\u2026).`
3584
+ ),
3585
+ ""
3586
+ );
3587
+ }
3588
+ return `${out.join("\n")}
3589
+ `;
3590
+ }
3591
+
3592
+ // src/server/schema.ts
3593
+ var SQL2 = `
3594
+ SELECT c.relname AS relation,
3595
+ c.reltuples::bigint AS "estRows",
3596
+ pg_total_relation_size(c.oid) AS "totalBytes",
3597
+ pg_relation_size(c.oid) AS "tableBytes",
3598
+ (SELECT array_agg(ir.relname::text ORDER BY ir.relname)
3599
+ FROM pg_index i JOIN pg_class ir ON ir.oid = i.indexrelid
3600
+ WHERE i.indrelid = c.oid) AS indexes,
3601
+ s.last_vacuum AS "lastVacuum",
3602
+ s.last_autovacuum AS "lastAutovacuum",
3603
+ s.last_analyze AS "lastAnalyze",
3604
+ s.last_autoanalyze AS "lastAutoanalyze",
3605
+ s.n_mod_since_analyze AS "modSinceAnalyze",
3606
+ s.n_live_tup AS "liveTup"
3607
+ FROM pg_class c
3608
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
3609
+ WHERE c.relkind IN ('r', 'p') AND c.relname = ANY($1);
3610
+ `;
3611
+ var toNum = (v) => v == null ? null : Number(v);
3612
+ async function relationStats(connection, relations) {
3613
+ const names = [...new Set(relations.filter(Boolean))];
3614
+ if (names.length === 0) return [];
3615
+ const rows = await queryReadOnly(connection, SQL2, [names]);
3616
+ return rows.map((r) => ({
3617
+ relation: r.relation,
3618
+ estRows: toNum(r.estRows),
3619
+ totalBytes: toNum(r.totalBytes),
3620
+ tableBytes: toNum(r.tableBytes),
3621
+ indexes: r.indexes ?? [],
3622
+ lastVacuum: r.lastVacuum,
3623
+ lastAutovacuum: r.lastAutovacuum,
3624
+ lastAnalyze: r.lastAnalyze,
3625
+ lastAutoanalyze: r.lastAutoanalyze,
3626
+ modSinceAnalyze: toNum(r.modSinceAnalyze),
3627
+ liveTup: toNum(r.liveTup)
3628
+ }));
3629
+ }
3630
+
3631
+ // src/diagnostics/stale-stats.ts
3632
+ var RULE_ID = "PGX_STALE_STATISTICS";
3633
+ var DOCS4 = "https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-STATISTICS";
3634
+ var MIN_ROWS = 1e3;
3635
+ function staleStatsFindings(stats, config) {
3636
+ if (config.rules[RULE_ID]?.enabled === false) return [];
3637
+ const severity = config.rules[RULE_ID]?.severity ?? "warn";
3638
+ const ratioLimit = config.thresholds.staleStatsModRatio;
3639
+ const out = [];
3640
+ for (const s of stats) {
3641
+ const rows = s.liveTup ?? s.estRows ?? 0;
3642
+ if (rows < MIN_ROWS) continue;
3643
+ const neverAnalyzed = !s.lastAnalyze && !s.lastAutoanalyze;
3644
+ const modRatio = s.modSinceAnalyze != null && rows > 0 ? s.modSinceAnalyze / rows : 0;
3645
+ if (!neverAnalyzed && modRatio < ratioLimit) continue;
3646
+ out.push(
3647
+ finding(RULE_ID, severity, {
3648
+ title: neverAnalyzed ? `Table ${s.relation} has never been analyzed` : `Planner statistics on ${s.relation} are stale`,
3649
+ 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).`,
3650
+ 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.",
3651
+ remediation: {
3652
+ summary: `Run ANALYZE on ${s.relation}, and if it keeps going stale, lower its autovacuum analyze threshold.`,
3653
+ steps: [
3654
+ "ANALYZE the table now to refresh statistics.",
3655
+ "If the table churns heavily, tune per-table autovacuum settings so auto-analyze keeps up."
3656
+ ],
3657
+ commands: [
3658
+ { label: "Refresh statistics", sql: `ANALYZE ${s.relation};` },
3659
+ {
3660
+ label: "Analyze more eagerly on churny tables",
3661
+ sql: `ALTER TABLE ${s.relation} SET (autovacuum_analyze_scale_factor = 0.02);`
3662
+ }
3663
+ ]
3664
+ },
3665
+ docsUrl: DOCS4,
3666
+ meta: {
3667
+ relation: s.relation,
3668
+ modSinceAnalyze: s.modSinceAnalyze ?? 0,
3669
+ liveTup: s.liveTup ?? 0
3670
+ }
3671
+ })
3672
+ );
3673
+ }
3674
+ return out;
3675
+ }
3676
+ async function checkStaleStats(connection, result, config) {
3677
+ try {
3678
+ const relations = [
3679
+ ...new Set(
3680
+ flatten(result.tree.root).map((n) => n.relationName).filter((r) => !!r)
3681
+ )
3682
+ ];
3683
+ if (!relations.length) return;
3684
+ appendFindings(result, staleStatsFindings(await relationStats(connection, relations), config));
3685
+ } catch {
3686
+ }
3687
+ }
3688
+ function appendFindings(result, extra) {
3689
+ if (!extra.length) return;
3690
+ result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
3691
+ result.worstSeverity = result.diagnostics.reduce(
3692
+ (worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
3693
+ null
3694
+ );
3695
+ }
3696
+
3409
3697
  // src/sql/extract.ts
3410
3698
  var DML = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "VALUES", "TABLE", "WITH"]);
3411
3699
  function classifyStatement(sql) {
@@ -3707,6 +3995,7 @@ async function runRun(args) {
3707
3995
  redact: args.redact,
3708
3996
  sql: single.sql
3709
3997
  });
3998
+ await checkStaleStats(args.connection, analysis2, args.config);
3710
3999
  return emit(analysis2, args);
3711
4000
  }
3712
4001
  const analysis = await analyzeScript(args.connection, sql, {
@@ -4079,6 +4368,66 @@ var diffCmd = defineCommand({
4079
4368
  }
4080
4369
  }
4081
4370
  });
4371
+ var locksCmd = defineCommand({
4372
+ meta: {
4373
+ name: "locks",
4374
+ description: "Snapshot live lock contention: who is blocked, and by whom."
4375
+ },
4376
+ args: {
4377
+ dsn: {
4378
+ type: "string",
4379
+ description: "Connection string (or use --host/--port/\u2026 or PG* env vars)"
4380
+ },
4381
+ host: { type: "string", description: "Server host" },
4382
+ port: { type: "string", description: "Server port" },
4383
+ dbname: { type: "string", alias: "d", description: "Database name" },
4384
+ user: { type: "string", alias: "U", description: "Role name" },
4385
+ sslmode: { type: "string", description: "disable | require | verify-ca | verify-full" },
4386
+ sslrootcert: { type: "string", description: "Path to a CA certificate (PEM)" },
4387
+ "connect-timeout": {
4388
+ type: "string",
4389
+ default: "10s",
4390
+ description: "Connection timeout (e.g. 30s)"
4391
+ },
4392
+ format: { type: "string", default: "terminal", alias: "f", description: "terminal | json" },
4393
+ output: { type: "string", alias: "o", description: "Write to a file instead of stdout" },
4394
+ color: { type: "string", default: "auto", description: "auto | always | never" },
4395
+ "no-color": { type: "boolean", description: "Disable color" },
4396
+ "fail-on-blocked": {
4397
+ type: "boolean",
4398
+ description: "Exit 1 if any session is currently blocked"
4399
+ },
4400
+ quiet: { type: "boolean", alias: "q", description: "Suppress non-error logs" },
4401
+ debug: { type: "boolean", description: "Print stack traces on internal errors" }
4402
+ },
4403
+ async run({ args }) {
4404
+ try {
4405
+ applyGlobalFlags(args);
4406
+ if (!["terminal", "json"].includes(args.format)) {
4407
+ throw usageError(`Unknown locks --format '${args.format}'`, "Use terminal or json.");
4408
+ }
4409
+ const connection = {
4410
+ connectTimeoutMs: parseDurationMs(args["connect-timeout"])
4411
+ };
4412
+ if (args.dsn) connection.dsn = args.dsn;
4413
+ if (args.host) connection.host = args.host;
4414
+ if (args.port) connection.port = Number(args.port);
4415
+ if (args.dbname) connection.database = args.dbname;
4416
+ if (args.user) connection.user = args.user;
4417
+ if (args.sslmode) connection.sslmode = args.sslmode;
4418
+ if (args.sslrootcert) connection.sslrootcert = args.sslrootcert;
4419
+ process.exitCode = await runLocks({
4420
+ connection,
4421
+ format: args.format,
4422
+ output: args.output,
4423
+ color: args["no-color"] ? "never" : args.color,
4424
+ failOnBlocked: !!args["fail-on-blocked"]
4425
+ });
4426
+ } catch (err) {
4427
+ process.exitCode = handleFatal(err);
4428
+ }
4429
+ }
4430
+ });
4082
4431
  var studioCmd = defineCommand({
4083
4432
  meta: { name: "studio", description: "Launch the local pgexplain Studio web app." },
4084
4433
  args: {
@@ -4152,7 +4501,7 @@ var argv = process.argv.slice(2);
4152
4501
  if (argv[0] === "completion") {
4153
4502
  process.exitCode = runCompletion(argv[1]);
4154
4503
  } else {
4155
- const started = argv[0] === "run" ? runMain(runCmd, { rawArgs: argv.slice(1) }) : argv[0] === "diff" ? runMain(diffCmd, { rawArgs: argv.slice(1) }) : argv[0] === "studio" ? runMain(studioCmd, { rawArgs: argv.slice(1) }) : runMain(main, { rawArgs: argv });
4504
+ const started = argv[0] === "run" ? runMain(runCmd, { rawArgs: argv.slice(1) }) : argv[0] === "diff" ? runMain(diffCmd, { rawArgs: argv.slice(1) }) : argv[0] === "locks" ? runMain(locksCmd, { rawArgs: argv.slice(1) }) : argv[0] === "studio" ? runMain(studioCmd, { rawArgs: argv.slice(1) }) : runMain(main, { rawArgs: argv });
4156
4505
  started.catch((err) => {
4157
4506
  process.exitCode = handleFatal(err);
4158
4507
  });