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/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
|
|
89
|
-
- Interactive **plan
|
|
90
|
-
- **
|
|
91
|
-
|
|
92
|
-
|
|
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 **
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
});
|