pgexplain 0.1.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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Domain model for pg-explain.
3
+ *
4
+ * Two things flow through the whole program:
5
+ * - PlanTree / PlanNode — the normalized EXPLAIN plan.
6
+ * - Diagnostic — a single actionable message. The SAME shape is used for
7
+ * operational tool errors and for plan-analysis findings, so
8
+ * one renderer serves both and `--format json` exposes both
9
+ * with identical, machine-readable actionability.
10
+ */
11
+ type Severity = "error" | "warn" | "info";
12
+ type Domain = "operational" | "plan";
13
+ /** A copy-pasteable, credential-free command shown to the user. */
14
+ interface RemediationCommand {
15
+ label?: string;
16
+ /** A shell command, e.g. `pg_isready -h <host> -p <port>`. */
17
+ shell?: string;
18
+ /** A SQL statement, e.g. `GRANT SELECT ON orders TO readonly;`. */
19
+ sql?: string;
20
+ }
21
+ /** HOW to fix it. Mandatory on every Diagnostic; `summary` is never empty. */
22
+ interface Remediation {
23
+ summary: string;
24
+ steps?: string[];
25
+ commands?: RemediationCommand[];
26
+ }
27
+ interface DiagnosticLocation {
28
+ kind: "node" | "connection" | "input" | "option";
29
+ /** Pre-order id assigned during parse, for `node` locations. */
30
+ nodeId?: number;
31
+ nodeType?: string;
32
+ relation?: string;
33
+ /** 1-based line/col for `input` (malformed JSON) locations. */
34
+ line?: number;
35
+ col?: number;
36
+ optionName?: string;
37
+ }
38
+ /**
39
+ * The load-bearing type. Every error and every finding is one of these, and every
40
+ * one carries a non-empty `remediation` so the developer always knows what to do next.
41
+ */
42
+ interface Diagnostic {
43
+ /** Stable, greppable identifier, e.g. PGX_AUTH_FAILED, PGX_SEQ_SCAN_LARGE. */
44
+ code: string;
45
+ domain: Domain;
46
+ severity: Severity;
47
+ /** One scannable headline line, no trailing punctuation. */
48
+ title: string;
49
+ /** WHAT happened, in plain language. */
50
+ detail: string;
51
+ /** WHY it happened / why it matters. */
52
+ cause: string;
53
+ remediation: Remediation;
54
+ /** Deep link with an anchor to the authoritative PostgreSQL doc. */
55
+ docsUrl?: string;
56
+ location?: DiagnosticLocation;
57
+ /** Structured extras for machine consumers (timeoutMs, pgVersion, sqlState, …). */
58
+ meta?: Record<string, string | number>;
59
+ }
60
+ /** The original EXPLAIN FORMAT JSON node, untouched. Escape hatch for rare fields. */
61
+ interface RawPlan {
62
+ "Node Type": string;
63
+ Plans?: RawPlan[];
64
+ [key: string]: unknown;
65
+ }
66
+ /** Derived per-node metrics, all per-loop-corrected. Absent without ANALYZE. */
67
+ interface NodeMetrics {
68
+ /** "Actual Rows" × "Actual Loops" — the true total this node produced. */
69
+ totalRows?: number;
70
+ /** "Actual Total Time" × "Actual Loops" — wall time of this subtree, all loops. */
71
+ inclusiveMs?: number;
72
+ /** inclusive − Σ(children inclusive), clamped ≥ 0. The bottleneck-ranking quantity. */
73
+ selfMs?: number;
74
+ /** 100 × selfMs / execution time. */
75
+ pctOfTotal?: number;
76
+ /** Misestimate factor ≥ 1 (max(est,act)/min(est,act)). */
77
+ estimateFactor?: number;
78
+ estimateDirection?: "over" | "under" | "accurate";
79
+ /** shared hit / (hit + read); null when no shared-buffer access here. */
80
+ cacheHitRatio?: number | null;
81
+ /** rows removed / (removed + kept). */
82
+ filterDiscardRatio?: number;
83
+ /** lossy / (lossy + exact). */
84
+ lossyRatio?: number;
85
+ }
86
+ interface PlanNode {
87
+ /** Pre-order index assigned at parse time; stable id for locations/diff. */
88
+ id: number;
89
+ nodeType: string;
90
+ parentRelationship?: string;
91
+ subplanName?: string;
92
+ relationName?: string;
93
+ schema?: string;
94
+ alias?: string;
95
+ indexName?: string;
96
+ planRows: number;
97
+ planWidth?: number;
98
+ startupCost?: number;
99
+ totalCost?: number;
100
+ actualRows?: number;
101
+ actualLoops?: number;
102
+ actualStartupTime?: number;
103
+ actualTotalTime?: number;
104
+ filter?: string;
105
+ rowsRemovedByFilter?: number;
106
+ indexCond?: string;
107
+ recheckCond?: string;
108
+ rowsRemovedByIndexRecheck?: number;
109
+ heapFetches?: number;
110
+ hashCond?: string;
111
+ joinType?: string;
112
+ joinFilter?: string;
113
+ rowsRemovedByJoinFilter?: number;
114
+ /** Projected columns (VERBOSE only). */
115
+ output?: string[];
116
+ sortMethod?: string;
117
+ sortSpaceType?: string;
118
+ sortSpaceUsed?: number;
119
+ sortKey?: string[];
120
+ hashBuckets?: number;
121
+ originalHashBuckets?: number;
122
+ hashBatches?: number;
123
+ originalHashBatches?: number;
124
+ peakMemoryUsage?: number;
125
+ diskUsage?: number;
126
+ exactHeapBlocks?: number;
127
+ lossyHeapBlocks?: number;
128
+ sharedHitBlocks?: number;
129
+ sharedReadBlocks?: number;
130
+ sharedDirtiedBlocks?: number;
131
+ sharedWrittenBlocks?: number;
132
+ localHitBlocks?: number;
133
+ localReadBlocks?: number;
134
+ tempReadBlocks?: number;
135
+ tempWrittenBlocks?: number;
136
+ ioReadTime?: number;
137
+ ioWriteTime?: number;
138
+ workersPlanned?: number;
139
+ workersLaunched?: number;
140
+ children: PlanNode[];
141
+ metrics: NodeMetrics;
142
+ /** Original JSON node — rules may read rare fields not normalized above. */
143
+ raw: RawPlan;
144
+ }
145
+ interface JitInfo {
146
+ functions?: number;
147
+ timing?: {
148
+ total?: number;
149
+ generation?: number;
150
+ inlining?: number;
151
+ optimization?: number;
152
+ emission?: number;
153
+ };
154
+ }
155
+ interface TriggerInfo {
156
+ name?: string;
157
+ constraintName?: string;
158
+ relation?: string;
159
+ calls?: number;
160
+ time?: number;
161
+ }
162
+ interface PlanTree {
163
+ root: PlanNode;
164
+ planningTime?: number;
165
+ executionTime?: number;
166
+ triggers: TriggerInfo[];
167
+ jit?: JitInfo;
168
+ settings?: Record<string, string>;
169
+ /** Actual row/time data present (EXPLAIN ANALYZE was used). */
170
+ hasAnalyze: boolean;
171
+ /** Buffer counters present (BUFFERS was used). */
172
+ hasBuffers: boolean;
173
+ raw: RawPlan;
174
+ }
175
+ interface Thresholds {
176
+ seqScanRows: number;
177
+ nestedLoopOuterRows: number;
178
+ filterDiscardRatio: number;
179
+ filterRemovedAbs: number;
180
+ misestimateFactor: number;
181
+ heapFetchRatio: number;
182
+ heapFetchAbs: number;
183
+ correlatedLoops: number;
184
+ jitPct: number;
185
+ triggerPct: number;
186
+ lowCacheHitRatio: number;
187
+ }
188
+ interface AnalysisContext {
189
+ tree: PlanTree;
190
+ thresholds: Thresholds;
191
+ /** Resolve a rule's severity, honoring config overrides. */
192
+ severityOf(ruleId: string, fallback: Severity): Severity;
193
+ /** Whether a rule is enabled (config can disable rules). */
194
+ isEnabled(ruleId: string): boolean;
195
+ }
196
+ /**
197
+ * One anti-pattern rule. `check` is called once per node in the tree; tree-level
198
+ * rules (JIT, triggers) act only when `node === ctx.tree.root`.
199
+ */
200
+ interface Rule {
201
+ id: string;
202
+ title: string;
203
+ defaultSeverity: Severity;
204
+ requiresAnalyze?: boolean;
205
+ requiresBuffers?: boolean;
206
+ check(node: PlanNode, ctx: AnalysisContext): Diagnostic[];
207
+ }
208
+ interface AnalysisResult {
209
+ tree: PlanTree;
210
+ /** Plan-domain findings, sorted by severity then impact. */
211
+ diagnostics: Diagnostic[];
212
+ /** Top nodes by self time. */
213
+ bottlenecks: PlanNode[];
214
+ /** One-line verdict shown at the top of every report. */
215
+ verdict: string;
216
+ /** Highest severity present, or null if clean. */
217
+ worstSeverity: Severity | null;
218
+ }
219
+
220
+ interface RuleConfig {
221
+ enabled?: boolean;
222
+ severity?: Severity;
223
+ }
224
+ interface PgExplainConfig {
225
+ thresholds: Thresholds;
226
+ /** Per-rule enable/disable and severity overrides, keyed by rule id. */
227
+ rules: Record<string, RuleConfig>;
228
+ }
229
+ declare const DEFAULT_THRESHOLDS: Thresholds;
230
+ declare const DEFAULT_CONFIG: PgExplainConfig;
231
+
232
+ /**
233
+ * Run every enabled rule over the tree and assemble the result. Assumes
234
+ * computeMetrics(tree) has already run. Rules that need data the plan lacks
235
+ * (ANALYZE/BUFFERS) are skipped so cost-only plans degrade gracefully.
236
+ */
237
+ declare function runAdvisor(tree: PlanTree, config?: PgExplainConfig): AnalysisResult;
238
+
239
+ /**
240
+ * Fill `node.metrics` for every node. All row/time figures are PER-LOOP corrected:
241
+ * Postgres reports "Actual Rows"/"Actual Total Time" as the average of a single loop,
242
+ * so the true total is `× "Actual Loops"`. Buffer counters are already cumulative and
243
+ * are NOT multiplied. No-ops cleanly on cost-only plans (leaves metrics empty).
244
+ */
245
+ declare function computeMetrics(tree: PlanTree): void;
246
+ /** Total execution time in ms: prefer the reported value, else the root's inclusive time. */
247
+ declare function executionMs(tree: PlanTree): number | undefined;
248
+ /** Top N nodes by self time (the real bottlenecks), descending. */
249
+ declare function bottlenecks(tree: PlanTree, n?: number): PlanNode[];
250
+ /** A short human label for a node, e.g. "Seq Scan on orders". */
251
+ declare function nodeLabel(node: PlanNode): string;
252
+
253
+ /**
254
+ * Parse EXPLAIN (FORMAT JSON) text into one PlanTree per statement.
255
+ * Accepts the standard `[{ "Plan": … }]`, a bare statement object, or a bare plan node.
256
+ * Throws AppError(PGX_MALFORMED_JSON | PGX_UNEXPECTED_PLAN_SHAPE) with a location.
257
+ */
258
+ declare function parseExplainJson(input: string): PlanTree[];
259
+ /** Depth-first pre-order walk (root first). Used by metrics and the advisor. */
260
+ declare function walk(node: PlanNode, visit: (n: PlanNode) => void): void;
261
+ /** Flatten a tree to an array in pre-order. */
262
+ declare function flatten(node: PlanNode): PlanNode[];
263
+
264
+ /**
265
+ * Process exit codes. Documented in README and `pg-explain --help` so scripts and
266
+ * CI can branch on the *kind* of failure without parsing text.
267
+ */
268
+ declare enum ExitCode {
269
+ /** Report produced. Findings alone do not change this unless --strict/--fail-on. */
270
+ Success = 0,
271
+ /** CI gate tripped: findings present AND --strict / --fail-on threshold met. */
272
+ CiGate = 1,
273
+ /** Usage error: bad flags/args, refused non-SELECT, unsupported option. */
274
+ Usage = 2,
275
+ /** Input error: no/empty stdin and no --file, unreadable file. */
276
+ Input = 3,
277
+ /** Parse/validation error: not valid EXPLAIN JSON or wrong shape. */
278
+ Parse = 4,
279
+ /** Database error: connect/auth/permission/timeout/cancel. */
280
+ Database = 5,
281
+ /** pg-explain itself hit an unexpected error. */
282
+ Internal = 70,
283
+ /** Interrupted by SIGINT. (128 + signal number.) */
284
+ Sigint = 130
285
+ }
286
+
287
+ /**
288
+ * An error that carries a fully-actionable Diagnostic and a process exit code.
289
+ * cli.ts catches these, renders the diagnostic to stderr, and exits with `exitCode`.
290
+ * Anything NOT an AppError that reaches the top level becomes PGX_INTERNAL.
291
+ */
292
+ declare class AppError extends Error {
293
+ readonly diagnostic: Diagnostic;
294
+ readonly exitCode: ExitCode;
295
+ constructor(diagnostic: Diagnostic, exitCode: ExitCode, cause?: unknown);
296
+ }
297
+ /**
298
+ * Remove secrets from any string before it is logged, shown, or written.
299
+ * Targets:
300
+ * - userinfo passwords in connection URLs: postgres://user:secret@host → user:***@host
301
+ * - libpq keyword form: password=secret → password=***
302
+ * - PG* env-style: PGPASSWORD=secret → PGPASSWORD=***
303
+ * - URL query params: ?password=secret&sslmode=… → ?password=***&…
304
+ */
305
+ declare function scrubCredentials(input: string): string;
306
+
307
+ /** Bump on any breaking change to the JSON shape. Consumers can assert on it. */
308
+ declare const JSON_SCHEMA_VERSION = 1;
309
+
310
+ type Format = "terminal" | "markdown" | "json" | "html" | "text";
311
+ declare const FORMATS: Format[];
312
+ declare function isFormat(s: string): s is Format;
313
+ interface RenderOptions {
314
+ format: Format;
315
+ /** Summary + findings only, no plan tree. */
316
+ tldr?: boolean;
317
+ /** ASCII tree glyphs (terminal/text). */
318
+ ascii?: boolean;
319
+ /** Pretty-print JSON. */
320
+ pretty?: boolean;
321
+ }
322
+ /** Render an analysis result to the requested format. Color is configured by the caller. */
323
+ declare function render(result: AnalysisResult, opts: RenderOptions): string;
324
+
325
+ interface AnalyzeOptions {
326
+ config?: PgExplainConfig;
327
+ /** 1-based statement index when the input holds more than one. */
328
+ statement?: number;
329
+ /** Strip literal values from expressions before analysis (no data leaks downstream). */
330
+ redact?: boolean;
331
+ }
332
+ /** Parse → (redact) → compute metrics → run advisor → attach informational notices. */
333
+ declare function analyze(input: string, options?: AnalyzeOptions): AnalysisResult;
334
+
335
+ export { type AnalysisContext, type AnalysisResult, type AnalyzeOptions, AppError, DEFAULT_CONFIG, DEFAULT_THRESHOLDS, type Diagnostic, type DiagnosticLocation, type Domain, ExitCode, FORMATS, type Format, JSON_SCHEMA_VERSION, type JitInfo, type NodeMetrics, type PgExplainConfig, type PlanNode, type PlanTree, type RawPlan, type Remediation, type RemediationCommand, type RenderOptions, type Rule, type Severity, type Thresholds, type TriggerInfo, analyze, bottlenecks, computeMetrics, executionMs, flatten, isFormat, nodeLabel, parseExplainJson, render, runAdvisor, scrubCredentials, walk };