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.
package/dist/cli.js ADDED
@@ -0,0 +1,3205 @@
1
+ #!/usr/bin/env node
2
+ import { defineCommand, runMain } from 'citty';
3
+ import { readFile, writeFile, stat, readdir } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import { z } from 'zod';
6
+ import pc from 'picocolors';
7
+
8
+ // package.json
9
+ var package_default = {
10
+ version: "0.1.0"};
11
+
12
+ // src/diagnostics/diagnostic.ts
13
+ var AppError = class extends Error {
14
+ diagnostic;
15
+ exitCode;
16
+ constructor(diagnostic, exitCode, cause) {
17
+ super(diagnostic.title);
18
+ this.name = "AppError";
19
+ this.diagnostic = diagnostic;
20
+ this.exitCode = exitCode;
21
+ if (cause !== void 0) this.cause = cause;
22
+ }
23
+ };
24
+ var SEVERITY_RANK = { error: 0, warn: 1, info: 2 };
25
+ function bySeverity(a, b) {
26
+ return SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
27
+ }
28
+ function maxSeverity(a, b) {
29
+ return SEVERITY_RANK[a] <= SEVERITY_RANK[b] ? a : b;
30
+ }
31
+ function severityAtLeast(s, threshold) {
32
+ return SEVERITY_RANK[s] <= SEVERITY_RANK[threshold];
33
+ }
34
+ function scrubCredentials(input) {
35
+ if (!input) return input;
36
+ return input.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^:/?#@\s]+:)([^@\s]+)(@)/gi, "$1***$3").replace(/\bpassword\s*=\s*'[^']*'/gi, "password='***'").replace(/(\bpassword\s*=\s*)([^\s&'"]+)/gi, "$1***").replace(/(\bPGPASSWORD\s*=\s*)([^\s&'"]+)/gi, "$1***");
37
+ }
38
+
39
+ // src/diagnostics/catalog.ts
40
+ var DOCS = "https://www.postgresql.org/docs/current";
41
+ var CATALOG = {
42
+ PGX_AUTH_FAILED: {
43
+ severity: "error",
44
+ exit: 5 /* Database */,
45
+ title: "Authentication failed",
46
+ detail: "The server rejected the supplied credentials.",
47
+ cause: "The password or role is wrong, or pg_hba.conf does not permit this role from your host.",
48
+ remediation: {
49
+ summary: "Verify the credentials and supply the password safely (never on the command line).",
50
+ steps: [
51
+ "Confirm the username and password are correct.",
52
+ "Provide the password via PGPASSWORD or ~/.pgpass instead of the command line.",
53
+ "Check that pg_hba.conf allows this role from your client host."
54
+ ],
55
+ commands: [
56
+ { label: "Set password via env", shell: "export PGPASSWORD=<password>" },
57
+ {
58
+ label: "Or store it (chmod 600)",
59
+ shell: 'echo "<host>:<port>:<db>:<user>:<password>" >> ~/.pgpass && chmod 600 ~/.pgpass'
60
+ }
61
+ ]
62
+ },
63
+ docsUrl: `${DOCS}/auth-pg-hba-conf.html`
64
+ },
65
+ PGX_HOST_UNREACHABLE: {
66
+ severity: "error",
67
+ exit: 5 /* Database */,
68
+ title: "Cannot reach the PostgreSQL server",
69
+ detail: "DNS resolution failed or the TCP connection was refused.",
70
+ cause: "Wrong host/port, the server is down, or a firewall/VPN/security group is blocking the port.",
71
+ remediation: {
72
+ summary: "Verify the host and port, then probe reachability.",
73
+ steps: [
74
+ "Double-check --host and --port (or the DSN) for typos.",
75
+ "Confirm the server is running and accepts TCP connections.",
76
+ "Check VPN, firewall, and cloud security-group rules for the port."
77
+ ],
78
+ commands: [{ label: "Probe reachability", shell: "pg_isready -h <host> -p <port>" }]
79
+ },
80
+ docsUrl: `${DOCS}/libpq-connect.html`
81
+ },
82
+ PGX_DB_NOT_FOUND: {
83
+ severity: "error",
84
+ exit: 5 /* Database */,
85
+ title: "Database does not exist",
86
+ detail: "The named database was not found on the server.",
87
+ cause: "The database name is misspelled or the database has not been created.",
88
+ remediation: {
89
+ summary: "List the available databases and correct the name.",
90
+ commands: [
91
+ { label: "List databases", shell: "psql -h <host> -U <user> -l" },
92
+ { label: "Re-run with the right name", shell: "pg-explain run --dbname <name> ..." }
93
+ ]
94
+ }
95
+ },
96
+ PGX_SSL_REQUIRED: {
97
+ severity: "error",
98
+ exit: 5 /* Database */,
99
+ title: "Server requires SSL",
100
+ detail: "The server requires an encrypted connection but a plaintext one was offered.",
101
+ cause: "pg_hba.conf mandates SSL (e.g. `hostssl`) for this role/host.",
102
+ remediation: {
103
+ summary: "Enable SSL on the connection.",
104
+ commands: [
105
+ { label: "Require encryption", shell: "pg-explain run --sslmode require ..." },
106
+ {
107
+ label: "Or verify the certificate too",
108
+ shell: "pg-explain run --sslmode verify-full --sslrootcert <ca.pem> ..."
109
+ }
110
+ ]
111
+ },
112
+ docsUrl: `${DOCS}/libpq-ssl.html`
113
+ },
114
+ PGX_SSL_VERIFY_FAILED: {
115
+ severity: "error",
116
+ exit: 5 /* Database */,
117
+ title: "TLS certificate verification failed",
118
+ detail: "Under verify-full the certificate chain is untrusted or the hostname does not match.",
119
+ cause: "The CA is not trusted locally, or the certificate's CN/SAN does not match the host you connect to.",
120
+ remediation: {
121
+ summary: "Point at the CA bundle and confirm the hostname matches the certificate.",
122
+ steps: [
123
+ "Provide the CA certificate the server's cert chains to.",
124
+ "Confirm the certificate CN/SAN matches the --host value.",
125
+ "Only fall back to `--sslmode require` (encryption without identity check) if you accept the risk."
126
+ ],
127
+ commands: [
128
+ {
129
+ label: "Trust a CA",
130
+ shell: "pg-explain run --sslmode verify-full --sslrootcert <ca.pem> ..."
131
+ }
132
+ ]
133
+ },
134
+ docsUrl: `${DOCS}/libpq-ssl.html`
135
+ },
136
+ PGX_CONN_TIMEOUT: {
137
+ severity: "error",
138
+ exit: 5 /* Database */,
139
+ title: "Connection timed out",
140
+ detail: "The connect handshake did not complete within the connect deadline.",
141
+ cause: "High network latency, an overloaded server, or a firewall silently dropping packets.",
142
+ remediation: {
143
+ summary: "Raise the connect budget and investigate the network path.",
144
+ commands: [
145
+ { label: "Increase connect timeout", shell: "pg-explain run --connect-timeout 30s ..." }
146
+ ]
147
+ },
148
+ docsUrl: `${DOCS}/libpq-connect.html`
149
+ },
150
+ PGX_PERMISSION_DENIED: {
151
+ severity: "error",
152
+ exit: 5 /* Database */,
153
+ title: "Permission denied",
154
+ detail: "The connecting role lacks a privilege the query needs.",
155
+ cause: "EXPLAIN must plan the query, which requires SELECT (and any referenced privileges) on the relations.",
156
+ remediation: {
157
+ summary: "Grant the missing privilege, or connect with a role that already has it.",
158
+ commands: [
159
+ { label: "Grant SELECT (run as owner)", sql: "GRANT SELECT ON <table> TO <role>;" }
160
+ ]
161
+ },
162
+ docsUrl: `${DOCS}/sql-grant.html`
163
+ },
164
+ PGX_RELATION_NOT_FOUND: {
165
+ severity: "error",
166
+ exit: 5 /* Database */,
167
+ title: "Relation does not exist",
168
+ detail: "A table or view referenced by the query was not found.",
169
+ cause: "The name is misspelled, or it lives in a schema that is not on the search_path.",
170
+ remediation: {
171
+ summary: "Schema-qualify the relation or set the search_path.",
172
+ steps: ["Check spelling and the schema.", "List tables with `\\dt` in psql."],
173
+ commands: [{ label: "Set the search path", sql: "SET search_path = <schema>, public;" }]
174
+ }
175
+ },
176
+ PGX_STATEMENT_TIMEOUT: {
177
+ severity: "error",
178
+ exit: 5 /* Database */,
179
+ title: "Statement timeout reached",
180
+ detail: "statement_timeout fired while EXPLAIN ANALYZE was executing the query.",
181
+ cause: "The query genuinely takes longer than the configured statement_timeout to run.",
182
+ remediation: {
183
+ summary: "Raise the timeout, or avoid executing the query at all.",
184
+ steps: [
185
+ "Raise the per-run statement timeout.",
186
+ "Or get an estimate-only plan that never executes (drop --analyze).",
187
+ "Or reduce measurement overhead with --timing off."
188
+ ],
189
+ commands: [
190
+ { label: "Raise the timeout", shell: "pg-explain run --statement-timeout 60s ..." }
191
+ ]
192
+ },
193
+ docsUrl: `${DOCS}/runtime-config-client.html#GUC-STATEMENT-TIMEOUT`
194
+ },
195
+ PGX_LOCK_TIMEOUT: {
196
+ severity: "error",
197
+ exit: 5 /* Database */,
198
+ title: "Lock timeout reached",
199
+ detail: "lock_timeout elapsed while waiting to acquire a lock on a relation.",
200
+ cause: "Another transaction holds a conflicting lock on a relation the query touches.",
201
+ remediation: {
202
+ summary: "Raise the lock timeout, identify the blocker, or retry off-peak.",
203
+ commands: [
204
+ { label: "Raise the lock timeout", shell: "pg-explain run --lock-timeout 30s ..." },
205
+ {
206
+ label: "Find blockers",
207
+ sql: "SELECT * FROM pg_locks l JOIN pg_stat_activity a USING (pid) WHERE NOT l.granted;"
208
+ }
209
+ ]
210
+ },
211
+ docsUrl: `${DOCS}/runtime-config-client.html#GUC-LOCK-TIMEOUT`
212
+ },
213
+ PGX_QUERY_CANCELED: {
214
+ severity: "error",
215
+ exit: 5 /* Database */,
216
+ title: "Query was canceled",
217
+ detail: "The query was canceled by an administrator or a signal before completing.",
218
+ cause: "An admin pg_cancel_backend call, a resource group, or a pool limit canceled the statement.",
219
+ remediation: {
220
+ summary: "Re-run the command; if it recurs, check for admin cancellation or pool limits."
221
+ }
222
+ },
223
+ PGX_UNSUPPORTED_PG_VERSION: {
224
+ severity: "error",
225
+ exit: 2 /* Usage */,
226
+ title: "EXPLAIN option not supported by this server",
227
+ detail: "A requested EXPLAIN option requires a newer PostgreSQL major version.",
228
+ cause: "Options are version-gated (e.g. SETTINGS\u226512, WAL\u226513, GENERIC_PLAN\u226516, SERIALIZE/MEMORY\u226517).",
229
+ remediation: {
230
+ summary: "Drop the unsupported option, target a newer server, or let pg-explain auto-omit it.",
231
+ commands: [{ label: "Auto-omit unsupported options", shell: "pg-explain run --compat ..." }]
232
+ },
233
+ docsUrl: `${DOCS}/sql-explain.html`
234
+ },
235
+ PGX_INVALID_EXPLAIN_OPTION: {
236
+ severity: "error",
237
+ exit: 2 /* Usage */,
238
+ title: "Invalid EXPLAIN option combination",
239
+ detail: "The server rejected an EXPLAIN option or a mutually-exclusive combination.",
240
+ cause: "Some options require ANALYZE (WAL, SERIALIZE, TIMING) and GENERIC_PLAN is incompatible with ANALYZE.",
241
+ remediation: {
242
+ summary: "Fix the option combination; see `pg-explain --help` for valid combinations.",
243
+ steps: [
244
+ "WAL/SERIALIZE/TIMING require --analyze.",
245
+ "GENERIC_PLAN cannot be combined with --analyze."
246
+ ]
247
+ },
248
+ docsUrl: `${DOCS}/sql-explain.html`
249
+ },
250
+ PGX_MALFORMED_JSON: {
251
+ severity: "error",
252
+ exit: 4 /* Parse */,
253
+ title: "Input is not valid JSON",
254
+ detail: "The plan input could not be parsed as JSON.",
255
+ cause: "The input was truncated when captured, or it is not EXPLAIN (FORMAT JSON) output.",
256
+ remediation: {
257
+ summary: "Validate the input and make sure it is FORMAT JSON output.",
258
+ commands: [{ label: "Validate JSON", shell: "jq . plan.json" }]
259
+ }
260
+ },
261
+ PGX_UNEXPECTED_PLAN_SHAPE: {
262
+ severity: "error",
263
+ exit: 4 /* Parse */,
264
+ title: "Input is not an EXPLAIN plan",
265
+ detail: "The JSON parsed but does not contain a recognizable EXPLAIN plan tree.",
266
+ cause: "The 'Plan' node is missing \u2014 this may be query result rows rather than a plan.",
267
+ remediation: {
268
+ summary: "Regenerate the plan with FORMAT JSON and pipe that in.",
269
+ commands: [
270
+ { label: "Capture a plan", sql: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) <your query>;" }
271
+ ]
272
+ },
273
+ docsUrl: `${DOCS}/sql-explain.html`
274
+ },
275
+ PGX_EMPTY_INPUT: {
276
+ severity: "error",
277
+ exit: 3 /* Input */,
278
+ title: "No plan input received",
279
+ detail: "stdin and the named file were both empty.",
280
+ cause: "No plan was piped in and no query/file was provided.",
281
+ remediation: {
282
+ summary: "Pipe a plan, or provide SQL to run.",
283
+ commands: [
284
+ { label: "Analyze a saved plan", shell: "pg-explain < plan.json" },
285
+ { label: "Or run a query", shell: 'pg-explain run --query "<sql>" --dsn <dsn>' }
286
+ ]
287
+ }
288
+ },
289
+ PGX_NON_SELECT_REFUSED: {
290
+ severity: "error",
291
+ exit: 2 /* Usage */,
292
+ title: "Refusing to ANALYZE a data-modifying statement",
293
+ detail: "EXPLAIN ANALYZE executes the statement, and this one would modify data.",
294
+ cause: "INSERT/UPDATE/DELETE/MERGE/DDL run for real under ANALYZE; running it could change your data.",
295
+ remediation: {
296
+ summary: "Use --force to run it inside an automatically rolled-back transaction, or drop --analyze.",
297
+ steps: [
298
+ "With --force, pg-explain wraps it as `BEGIN; <stmt>; ROLLBACK;` so nothing is committed.",
299
+ "Without --analyze you get an estimate-only plan that never executes."
300
+ ],
301
+ commands: [
302
+ {
303
+ label: "Run safely (auto-rollback)",
304
+ shell: "pg-explain run --force --file mutation.sql --dsn <dsn>"
305
+ }
306
+ ]
307
+ }
308
+ },
309
+ PGX_MULTIPLE_STATEMENTS: {
310
+ severity: "error",
311
+ exit: 2 /* Usage */,
312
+ title: "Multiple SQL statements found",
313
+ detail: "The input contains more than one statement; pg-explain analyzes one at a time.",
314
+ cause: "A .sql file or --query string contained several semicolon-separated statements.",
315
+ remediation: {
316
+ summary: "Select one statement, or split them into separate invocations.",
317
+ commands: [
318
+ {
319
+ label: "Pick the Nth statement (1-based)",
320
+ shell: "pg-explain run --statement 2 --file queries.sql --dsn <dsn>"
321
+ }
322
+ ]
323
+ }
324
+ },
325
+ PGX_COST_ONLY_PLAN: {
326
+ severity: "info",
327
+ exit: 0 /* Success */,
328
+ title: "Cost-only plan \u2014 estimate-vs-actual checks unavailable",
329
+ detail: "This plan has cost estimates but no actual row/time data.",
330
+ cause: "It was produced by plain EXPLAIN (without ANALYZE), so runtime behavior is unknown.",
331
+ remediation: {
332
+ summary: "Re-run with ANALYZE to unlock estimate-vs-actual, timing, and spill findings.",
333
+ commands: [
334
+ { label: "Capture actuals", sql: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) <query>;" }
335
+ ]
336
+ },
337
+ docsUrl: `${DOCS}/using-explain.html#USING-EXPLAIN-ANALYZE`
338
+ },
339
+ PGX_NO_BUFFERS: {
340
+ severity: "info",
341
+ exit: 0 /* Success */,
342
+ title: "No BUFFERS data \u2014 cache/I/O analysis skipped",
343
+ detail: "Buffer counters are absent, so cache-hit ratio and I/O findings cannot be computed.",
344
+ cause: "The plan was captured without BUFFERS.",
345
+ remediation: {
346
+ summary: "Add BUFFERS to surface shared/temp block usage and the cache-hit ratio.",
347
+ commands: [
348
+ { label: "Capture buffers", sql: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) <query>;" }
349
+ ]
350
+ }
351
+ },
352
+ PGX_EMPTY_PLAN: {
353
+ severity: "info",
354
+ exit: 0 /* Success */,
355
+ title: "Nothing to analyze",
356
+ detail: "The plan has no scans or joins to evaluate (e.g. a bare Result node).",
357
+ cause: "The query is trivial and has no tuning surface.",
358
+ remediation: { summary: "Confirm this is the query you intended to profile." }
359
+ },
360
+ PGX_PG_DRIVER_MISSING: {
361
+ severity: "error",
362
+ exit: 2 /* Usage */,
363
+ title: "The 'pg' driver is not installed",
364
+ detail: "The run command needs the PostgreSQL driver, which is an optional dependency.",
365
+ cause: "pgexplain ships 'pg' as optional so plan-only use stays dependency-free; it isn't installed here.",
366
+ remediation: {
367
+ summary: "Install the pg driver, then re-run. (Plan-only analysis from a file/stdin needs no driver.)",
368
+ commands: [
369
+ { label: "with pnpm", shell: "pnpm add pg" },
370
+ { label: "with npm", shell: "npm install pg" }
371
+ ]
372
+ }
373
+ },
374
+ PGX_QUERY_FAILED: {
375
+ severity: "error",
376
+ exit: 5 /* Database */,
377
+ title: "The query could not be planned or executed",
378
+ detail: "PostgreSQL returned an error while running EXPLAIN.",
379
+ cause: "The statement has a syntax error, references something invalid, or hit a server-side limit.",
380
+ remediation: {
381
+ summary: "Read the server message below, fix the statement, and re-run. Test it in psql first if unsure.",
382
+ commands: [{ label: "Try it directly", shell: 'psql "<dsn>" -c "EXPLAIN <your statement>"' }]
383
+ },
384
+ docsUrl: `${DOCS}/sql-explain.html`
385
+ },
386
+ PGX_INTERNAL: {
387
+ severity: "error",
388
+ exit: 70 /* Internal */,
389
+ title: "pg-explain hit an unexpected error",
390
+ detail: "This is a bug in pg-explain, not in your query or plan.",
391
+ cause: "An unhandled condition was reached.",
392
+ remediation: {
393
+ summary: "Re-run with --debug for a credential-scrubbed stack trace, then file an issue.",
394
+ commands: [
395
+ { label: "Show the trace", shell: "pg-explain --debug ..." },
396
+ { label: "Report your version", shell: "pg-explain --version" }
397
+ ]
398
+ }
399
+ }
400
+ };
401
+ function opDiagnostic(code, overrides = {}) {
402
+ const spec = CATALOG[code];
403
+ const diag = {
404
+ code,
405
+ domain: "operational",
406
+ severity: spec.severity,
407
+ title: spec.title,
408
+ detail: overrides.detail ?? spec.detail,
409
+ cause: spec.cause,
410
+ remediation: spec.remediation
411
+ };
412
+ if (spec.docsUrl) diag.docsUrl = spec.docsUrl;
413
+ if (overrides.location) diag.location = overrides.location;
414
+ if (overrides.meta) diag.meta = overrides.meta;
415
+ return diag;
416
+ }
417
+ function opError(code, overrides = {}, cause) {
418
+ return new AppError(opDiagnostic(code, overrides), CATALOG[code].exit, cause);
419
+ }
420
+ var DEFAULT_THRESHOLDS = {
421
+ seqScanRows: 1e5,
422
+ nestedLoopOuterRows: 1e4,
423
+ filterDiscardRatio: 0.9,
424
+ filterRemovedAbs: 1e4,
425
+ misestimateFactor: 10,
426
+ heapFetchRatio: 0.1,
427
+ heapFetchAbs: 1e3,
428
+ correlatedLoops: 1e3,
429
+ jitPct: 25,
430
+ triggerPct: 10,
431
+ lowCacheHitRatio: 0.9
432
+ };
433
+ var DEFAULT_CONFIG = {
434
+ thresholds: { ...DEFAULT_THRESHOLDS },
435
+ rules: {}
436
+ };
437
+ var CONFIG_FILES = [".pgexplainrc.json", ".pgexplainrc"];
438
+ function merge(partial) {
439
+ return {
440
+ thresholds: { ...DEFAULT_THRESHOLDS, ...partial.thresholds ?? {} },
441
+ rules: { ...partial.rules ?? {} }
442
+ };
443
+ }
444
+ async function readJson(path) {
445
+ let text;
446
+ try {
447
+ text = await readFile(path, "utf8");
448
+ } catch (err) {
449
+ throw opError(
450
+ "PGX_EMPTY_INPUT",
451
+ {
452
+ detail: `Could not read config '${path}': ${err instanceof Error ? err.message : String(err)}`
453
+ },
454
+ err
455
+ );
456
+ }
457
+ try {
458
+ return JSON.parse(text);
459
+ } catch (err) {
460
+ throw opError(
461
+ "PGX_MALFORMED_JSON",
462
+ {
463
+ detail: `Config '${path}' is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
464
+ },
465
+ err
466
+ );
467
+ }
468
+ }
469
+ async function loadConfig(explicitPath, cwd = process.cwd()) {
470
+ if (explicitPath) return merge(await readJson(explicitPath));
471
+ for (const name of CONFIG_FILES) {
472
+ try {
473
+ const text = await readFile(join(cwd, name), "utf8");
474
+ return merge(JSON.parse(text));
475
+ } catch {
476
+ }
477
+ }
478
+ try {
479
+ const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
480
+ if (pkg.pgExplain) return merge(pkg.pgExplain);
481
+ } catch {
482
+ }
483
+ return { ...DEFAULT_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS }, rules: {} };
484
+ }
485
+ var PlanNodeSchema = z.looseObject({
486
+ "Node Type": z.string(),
487
+ get Plans() {
488
+ return z.array(PlanNodeSchema).optional();
489
+ }
490
+ });
491
+ var StatementSchema = z.looseObject({
492
+ Plan: PlanNodeSchema,
493
+ "Planning Time": z.number().optional(),
494
+ "Execution Time": z.number().optional(),
495
+ Triggers: z.array(z.looseObject({})).optional(),
496
+ JIT: z.looseObject({}).optional(),
497
+ Settings: z.record(z.string(), z.unknown()).optional()
498
+ });
499
+ var ExplainOutputSchema = z.array(StatementSchema).min(1);
500
+
501
+ // src/core/parse.ts
502
+ function num(raw, key) {
503
+ const v = raw[key];
504
+ return typeof v === "number" ? v : void 0;
505
+ }
506
+ function str(raw, key) {
507
+ const v = raw[key];
508
+ return typeof v === "string" ? v : void 0;
509
+ }
510
+ function strArray(raw, key) {
511
+ const v = raw[key];
512
+ if (Array.isArray(v) && v.every((x) => typeof x === "string")) return v;
513
+ if (typeof v === "string") return [v];
514
+ return void 0;
515
+ }
516
+ function parseJsonWithLocation(input) {
517
+ try {
518
+ return JSON.parse(input);
519
+ } catch (err) {
520
+ const message = err instanceof Error ? err.message : String(err);
521
+ let line;
522
+ let col;
523
+ const lc = message.match(/line (\d+) column (\d+)/i);
524
+ if (lc?.[1] && lc[2]) {
525
+ line = Number(lc[1]);
526
+ col = Number(lc[2]);
527
+ } else {
528
+ const pos = message.match(/position (\d+)/i);
529
+ if (pos?.[1]) {
530
+ const offset = Number(pos[1]);
531
+ const before = input.slice(0, offset);
532
+ line = before.split("\n").length;
533
+ col = offset - before.lastIndexOf("\n");
534
+ }
535
+ }
536
+ const where = line && col ? ` (line ${line}, col ${col})` : "";
537
+ throw opError(
538
+ "PGX_MALFORMED_JSON",
539
+ {
540
+ detail: `The plan input could not be parsed as JSON${where}: ${message}`,
541
+ location: line && col ? { kind: "input", line, col } : { kind: "input" }
542
+ },
543
+ err
544
+ );
545
+ }
546
+ }
547
+ function normalizeNode(raw, nextId) {
548
+ const node = {
549
+ id: nextId(),
550
+ nodeType: raw["Node Type"],
551
+ planRows: num(raw, "Plan Rows") ?? 0,
552
+ children: [],
553
+ metrics: {},
554
+ raw
555
+ };
556
+ assign(node, {
557
+ parentRelationship: str(raw, "Parent Relationship"),
558
+ subplanName: str(raw, "Subplan Name"),
559
+ relationName: str(raw, "Relation Name"),
560
+ schema: str(raw, "Schema"),
561
+ alias: str(raw, "Alias"),
562
+ indexName: str(raw, "Index Name"),
563
+ planWidth: num(raw, "Plan Width"),
564
+ startupCost: num(raw, "Startup Cost"),
565
+ totalCost: num(raw, "Total Cost"),
566
+ actualRows: num(raw, "Actual Rows"),
567
+ actualLoops: num(raw, "Actual Loops"),
568
+ actualStartupTime: num(raw, "Actual Startup Time"),
569
+ actualTotalTime: num(raw, "Actual Total Time"),
570
+ filter: str(raw, "Filter"),
571
+ rowsRemovedByFilter: num(raw, "Rows Removed by Filter"),
572
+ indexCond: str(raw, "Index Cond"),
573
+ recheckCond: str(raw, "Recheck Cond"),
574
+ rowsRemovedByIndexRecheck: num(raw, "Rows Removed by Index Recheck"),
575
+ heapFetches: num(raw, "Heap Fetches"),
576
+ hashCond: str(raw, "Hash Cond"),
577
+ joinType: str(raw, "Join Type"),
578
+ joinFilter: str(raw, "Join Filter"),
579
+ rowsRemovedByJoinFilter: num(raw, "Rows Removed by Join Filter"),
580
+ output: strArray(raw, "Output"),
581
+ sortMethod: str(raw, "Sort Method"),
582
+ sortSpaceType: str(raw, "Sort Space Type"),
583
+ sortSpaceUsed: num(raw, "Sort Space Used"),
584
+ sortKey: strArray(raw, "Sort Key"),
585
+ hashBuckets: num(raw, "Hash Buckets"),
586
+ originalHashBuckets: num(raw, "Original Hash Buckets"),
587
+ hashBatches: num(raw, "Hash Batches"),
588
+ originalHashBatches: num(raw, "Original Hash Batches"),
589
+ peakMemoryUsage: num(raw, "Peak Memory Usage"),
590
+ diskUsage: num(raw, "Disk Usage"),
591
+ exactHeapBlocks: num(raw, "Exact Heap Blocks"),
592
+ lossyHeapBlocks: num(raw, "Lossy Heap Blocks"),
593
+ sharedHitBlocks: num(raw, "Shared Hit Blocks"),
594
+ sharedReadBlocks: num(raw, "Shared Read Blocks"),
595
+ sharedDirtiedBlocks: num(raw, "Shared Dirtied Blocks"),
596
+ sharedWrittenBlocks: num(raw, "Shared Written Blocks"),
597
+ localHitBlocks: num(raw, "Local Hit Blocks"),
598
+ localReadBlocks: num(raw, "Local Read Blocks"),
599
+ tempReadBlocks: num(raw, "Temp Read Blocks"),
600
+ tempWrittenBlocks: num(raw, "Temp Written Blocks"),
601
+ ioReadTime: num(raw, "I/O Read Time"),
602
+ ioWriteTime: num(raw, "I/O Write Time"),
603
+ workersPlanned: num(raw, "Workers Planned"),
604
+ workersLaunched: num(raw, "Workers Launched")
605
+ });
606
+ const childPlans = raw.Plans;
607
+ if (Array.isArray(childPlans)) {
608
+ for (const child of childPlans) {
609
+ node.children.push(normalizeNode(child, nextId));
610
+ }
611
+ }
612
+ return node;
613
+ }
614
+ function assign(target, fields) {
615
+ for (const [k, v] of Object.entries(fields)) {
616
+ if (v !== void 0) target[k] = v;
617
+ }
618
+ }
619
+ function parseTriggers(raw) {
620
+ if (!Array.isArray(raw)) return [];
621
+ return raw.map((t) => {
622
+ const r = t;
623
+ const info = {};
624
+ assign(info, {
625
+ name: str(r, "Trigger Name"),
626
+ constraintName: str(r, "Constraint Name"),
627
+ relation: str(r, "Relation"),
628
+ calls: num(r, "Calls"),
629
+ time: num(r, "Time")
630
+ });
631
+ return info;
632
+ });
633
+ }
634
+ function parseJit(raw) {
635
+ if (!raw || typeof raw !== "object") return void 0;
636
+ const r = raw;
637
+ const timing = r.Timing;
638
+ const jit = {};
639
+ const functions = num(r, "Functions");
640
+ if (functions !== void 0) jit.functions = functions;
641
+ if (timing && typeof timing === "object") {
642
+ jit.timing = {
643
+ total: num(timing, "Total"),
644
+ generation: num(timing, "Generation"),
645
+ inlining: num(timing, "Inlining"),
646
+ optimization: num(timing, "Optimization"),
647
+ emission: num(timing, "Emission")
648
+ };
649
+ }
650
+ return jit;
651
+ }
652
+ function parseExplainJson(input) {
653
+ const json = parseJsonWithLocation(input);
654
+ let candidate = json;
655
+ if (json && typeof json === "object" && !Array.isArray(json)) {
656
+ const obj = json;
657
+ candidate = "Plan" in obj ? [obj] : "Node Type" in obj ? [{ Plan: obj }] : json;
658
+ }
659
+ const result = ExplainOutputSchema.safeParse(candidate);
660
+ if (!result.success) {
661
+ throw opError("PGX_UNEXPECTED_PLAN_SHAPE", {
662
+ detail: `The JSON is valid but is not an EXPLAIN plan: ${result.error.issues[0]?.message ?? "missing 'Plan' node"}.`,
663
+ location: { kind: "input" }
664
+ });
665
+ }
666
+ return result.data.map((stmt) => {
667
+ let id = 0;
668
+ const root = normalizeNode(stmt.Plan, () => id++);
669
+ const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
670
+ const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
671
+ const tree = {
672
+ root,
673
+ triggers: parseTriggers(stmt.Triggers),
674
+ hasAnalyze,
675
+ hasBuffers,
676
+ raw: stmt.Plan
677
+ };
678
+ if (stmt["Planning Time"] !== void 0) tree.planningTime = stmt["Planning Time"];
679
+ if (stmt["Execution Time"] !== void 0) tree.executionTime = stmt["Execution Time"];
680
+ const jit = parseJit(stmt.JIT);
681
+ if (jit) tree.jit = jit;
682
+ if (stmt.Settings) tree.settings = stmt.Settings;
683
+ return tree;
684
+ });
685
+ }
686
+ function walk(node, visit) {
687
+ visit(node);
688
+ for (const child of node.children) walk(child, visit);
689
+ }
690
+ function flatten(node) {
691
+ const out = [];
692
+ walk(node, (n) => out.push(n));
693
+ return out;
694
+ }
695
+
696
+ // src/core/metrics.ts
697
+ function computeMetrics(tree) {
698
+ walk(tree.root, (node) => {
699
+ const { actualRows, actualLoops, actualTotalTime } = node;
700
+ if (actualRows !== void 0 && actualLoops !== void 0) {
701
+ node.metrics.totalRows = actualRows * actualLoops;
702
+ }
703
+ if (actualTotalTime !== void 0 && actualLoops !== void 0) {
704
+ node.metrics.inclusiveMs = actualTotalTime * actualLoops;
705
+ }
706
+ if (node.metrics.totalRows !== void 0) {
707
+ const est = Math.max(node.planRows, 1);
708
+ const act = Math.max(node.metrics.totalRows, 1);
709
+ node.metrics.estimateFactor = est >= act ? est / act : act / est;
710
+ node.metrics.estimateDirection = node.planRows > node.metrics.totalRows ? "over" : node.metrics.totalRows > node.planRows ? "under" : "accurate";
711
+ }
712
+ const hit = node.sharedHitBlocks ?? 0;
713
+ const read = node.sharedReadBlocks ?? 0;
714
+ node.metrics.cacheHitRatio = hit + read > 0 ? hit / (hit + read) : null;
715
+ if (node.rowsRemovedByFilter !== void 0 && actualLoops !== void 0) {
716
+ const removed = node.rowsRemovedByFilter * actualLoops;
717
+ const kept = node.metrics.totalRows ?? 0;
718
+ const denom = removed + kept;
719
+ if (denom > 0) node.metrics.filterDiscardRatio = removed / denom;
720
+ }
721
+ if (node.lossyHeapBlocks !== void 0) {
722
+ const lossy = node.lossyHeapBlocks;
723
+ const exact = node.exactHeapBlocks ?? 0;
724
+ const denom = lossy + exact;
725
+ if (denom > 0) node.metrics.lossyRatio = lossy / denom;
726
+ }
727
+ });
728
+ walk(tree.root, (node) => {
729
+ if (node.metrics.inclusiveMs === void 0) return;
730
+ let childrenMs = 0;
731
+ for (const child of node.children) childrenMs += child.metrics.inclusiveMs ?? 0;
732
+ node.metrics.selfMs = Math.max(node.metrics.inclusiveMs - childrenMs, 0);
733
+ });
734
+ const totalMs = executionMs(tree);
735
+ if (totalMs && totalMs > 0) {
736
+ walk(tree.root, (node) => {
737
+ if (node.metrics.selfMs !== void 0) {
738
+ node.metrics.pctOfTotal = 100 * node.metrics.selfMs / totalMs;
739
+ }
740
+ });
741
+ }
742
+ }
743
+ function executionMs(tree) {
744
+ return tree.executionTime ?? tree.root.metrics.inclusiveMs;
745
+ }
746
+ function bottlenecks(tree, n = 5) {
747
+ return flatten(tree.root).filter((node) => node.metrics.selfMs !== void 0).sort((a, b) => (b.metrics.selfMs ?? 0) - (a.metrics.selfMs ?? 0)).slice(0, n);
748
+ }
749
+ function nodeLabel(node) {
750
+ let label = node.nodeType;
751
+ if (node.indexName && node.relationName)
752
+ label += ` using ${node.indexName} on ${node.relationName}`;
753
+ else if (node.relationName) label += ` on ${node.relationName}`;
754
+ if (node.alias && node.alias !== node.relationName) label += ` (${node.alias})`;
755
+ return label;
756
+ }
757
+
758
+ // src/util/format.ts
759
+ var BLOCK_BYTES = 8192;
760
+ function fmtInt(n) {
761
+ return Math.round(n).toLocaleString("en-US");
762
+ }
763
+ function fmtMs(ms) {
764
+ if (ms < 1) return `${ms.toFixed(3)} ms`;
765
+ if (ms < 1e3) return `${ms.toFixed(1)} ms`;
766
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(2)} s`;
767
+ const min = Math.floor(ms / 6e4);
768
+ const sec = (ms % 6e4 / 1e3).toFixed(0);
769
+ return `${min}m ${sec}s`;
770
+ }
771
+ function fmtPct(fraction0to100) {
772
+ return `${fraction0to100.toFixed(1)}%`;
773
+ }
774
+ function fmtKiB(kib) {
775
+ return fmtBytes(kib * 1024);
776
+ }
777
+ function fmtBlocks(blocks) {
778
+ return `${fmtInt(blocks)} blk (${fmtBytes(blocks * BLOCK_BYTES)})`;
779
+ }
780
+ function fmtBytes(bytes) {
781
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
782
+ let v = bytes;
783
+ let i = 0;
784
+ while (v >= 1024 && i < units.length - 1) {
785
+ v /= 1024;
786
+ i++;
787
+ }
788
+ const s = i === 0 ? String(Math.round(v)) : v.toFixed(1);
789
+ return `${s} ${units[i]}`;
790
+ }
791
+ function roundUpMiB(kib, stepMiB = 4) {
792
+ const mib = Math.ceil(kib / 1024 / stepMiB) * stepMiB;
793
+ return `${Math.max(mib, stepMiB)}MB`;
794
+ }
795
+ var UNICODE_TREE = { branch: "\u251C\u2500 ", last: "\u2514\u2500 ", vert: "\u2502 ", space: " " };
796
+ var ASCII_TREE = { branch: "+- ", last: "`- ", vert: "| ", space: " " };
797
+
798
+ // src/advisor/rules/util.ts
799
+ var DOCS2 = "https://www.postgresql.org/docs/current";
800
+ function locationOf(node) {
801
+ const loc = { kind: "node", nodeId: node.id, nodeType: node.nodeType };
802
+ if (node.relationName) loc.relation = node.relationName;
803
+ return loc;
804
+ }
805
+ function makeFinding(rule, ctx, node, parts) {
806
+ const d = {
807
+ code: rule.id,
808
+ domain: "plan",
809
+ severity: ctx.severityOf(rule.id, parts.severity ?? rule.defaultSeverity),
810
+ title: parts.title,
811
+ detail: parts.detail,
812
+ cause: parts.cause,
813
+ remediation: parts.remediation,
814
+ location: locationOf(node)
815
+ };
816
+ if (parts.docsUrl) d.docsUrl = parts.docsUrl;
817
+ if (parts.meta) d.meta = parts.meta;
818
+ return d;
819
+ }
820
+ function outerChild(node) {
821
+ return node.children[0];
822
+ }
823
+
824
+ // src/advisor/rules/bitmap-lossy.ts
825
+ var bitmapLossy = {
826
+ id: "PGX_BITMAP_LOSSY",
827
+ title: "Lossy bitmap heap scan",
828
+ defaultSeverity: "info",
829
+ requiresAnalyze: true,
830
+ check(node, ctx) {
831
+ if (node.nodeType !== "Bitmap Heap Scan") return [];
832
+ const lossy = node.lossyHeapBlocks ?? 0;
833
+ if (lossy <= 0) return [];
834
+ const exact = node.exactHeapBlocks ?? 0;
835
+ const rel = node.relationName ?? "the table";
836
+ const rechecked = node.rowsRemovedByIndexRecheck ?? 0;
837
+ const recheckNote = rechecked > 0 ? ` The recheck discarded ${fmtInt(rechecked)} extra rows that the lossy bitmap could not exclude.` : "";
838
+ return [
839
+ makeFinding(bitmapLossy, ctx, node, {
840
+ title: `Lossy bitmap heap scan on ${rel} (${fmtInt(lossy)} lossy blocks)`,
841
+ detail: `The bitmap for ${rel} held ${fmtInt(lossy)} lossy (page-granularity) blocks alongside ${fmtInt(
842
+ exact
843
+ )} exact blocks, so Postgres re-checked the index condition against every tuple on the lossy pages.${recheckNote}`,
844
+ cause: "The exact (per-tuple) bitmap did not fit in work_mem, so Postgres fell back to storing whole heap pages and recheck the index condition while reading them \u2014 more heap I/O and CPU than an exact bitmap.",
845
+ remediation: {
846
+ summary: `Raise work_mem for this session so the bitmap stays exact (no lossy blocks, no rechecks) on ${rel}; alternatively make the index condition more selective or add a composite index so the bitmap is smaller.`,
847
+ steps: [
848
+ "Increase work_mem for the session, then re-run EXPLAIN (ANALYZE) and confirm Lossy Heap Blocks drops to 0.",
849
+ "If raising work_mem is undesirable, make the index condition more selective (a more selective leading column or a composite index over the filtered columns) so fewer heap pages enter the bitmap."
850
+ ],
851
+ commands: [
852
+ {
853
+ label: "Give this session more work_mem",
854
+ sql: "SET work_mem = '<X>MB';"
855
+ },
856
+ {
857
+ label: "Or shrink the bitmap with a more selective index",
858
+ sql: `CREATE INDEX ON ${rel} (<more selective / composite columns>);`
859
+ }
860
+ ]
861
+ },
862
+ docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-WORK-MEM`,
863
+ meta: { lossyBlocks: lossy, exactBlocks: exact }
864
+ })
865
+ ];
866
+ }
867
+ };
868
+
869
+ // src/advisor/rules/cartesian-product.ts
870
+ function rowsOf(node) {
871
+ return node.metrics.totalRows ?? node.planRows;
872
+ }
873
+ var cartesianProduct = {
874
+ id: "PGX_CARTESIAN_PRODUCT",
875
+ title: "Cartesian product (missing join condition)",
876
+ defaultSeverity: "error",
877
+ check(node, ctx) {
878
+ if (node.nodeType !== "Nested Loop") return [];
879
+ if (node.joinFilter) return [];
880
+ const inner = node.children[1];
881
+ if (!inner) return [];
882
+ if (inner.indexCond || inner.recheckCond) return [];
883
+ const outer = node.children[0];
884
+ if (!outer) return [];
885
+ const outerRows = rowsOf(outer);
886
+ const innerRows = rowsOf(inner);
887
+ if (outerRows <= 1 || innerRows <= 1) return [];
888
+ const estimated = node.metrics.totalRows === void 0;
889
+ const product = fmtInt(outerRows * innerRows);
890
+ return [
891
+ makeFinding(cartesianProduct, ctx, node, {
892
+ title: `Cartesian product: Nested Loop with no join condition (~${product}${estimated ? " est." : ""} rows)`,
893
+ detail: `The Nested Loop has no Join Filter and the inner side has no Index Cond or Recheck Cond, so each of ${fmtInt(
894
+ outerRows
895
+ )} outer rows is paired with every one of ${fmtInt(innerRows)} inner rows${estimated ? " (estimated \u2014 run with ANALYZE for actuals)" : ""}.`,
896
+ cause: "No predicate links the two relations, so Postgres can only emit the full cross product. This usually means an ON or WHERE join condition was omitted (e.g. a comma join across tables).",
897
+ remediation: {
898
+ summary: "Add the missing join condition linking the two tables on their key columns (e.g. ON a.id = b.a_id). If a cross product is truly intended, make it explicit with CROSS JOIN and bound it with a LIMIT or aggregation.",
899
+ steps: [
900
+ "Find the two relations feeding this Nested Loop in your query.",
901
+ "Add an ON (or WHERE) clause matching their key columns so the loop becomes selective.",
902
+ "If you really want every combination, write CROSS JOIN explicitly and cap the size with LIMIT or an aggregate."
903
+ ],
904
+ commands: [
905
+ {
906
+ label: "Add the join predicate",
907
+ sql: "SELECT ...\nFROM <outer_table> a\nJOIN <inner_table> b ON a.<key> = b.<key>;"
908
+ },
909
+ {
910
+ label: "Or make the cross join explicit and bounded",
911
+ sql: "SELECT ...\nFROM <outer_table> a\nCROSS JOIN <inner_table> b\nLIMIT <n>;"
912
+ }
913
+ ]
914
+ },
915
+ docsUrl: `${DOCS2}/queries-table-expressions.html#QUERIES-JOIN`,
916
+ meta: { outerRows: Math.round(outerRows), innerRows: Math.round(innerRows) }
917
+ })
918
+ ];
919
+ }
920
+ };
921
+
922
+ // src/advisor/rules/correlated-subplan.ts
923
+ var correlatedSubplan = {
924
+ id: "PGX_CORRELATED_SUBPLAN",
925
+ title: "Correlated subplan re-executed per row",
926
+ defaultSeverity: "warn",
927
+ requiresAnalyze: true,
928
+ check(node, ctx) {
929
+ const isSubplan = node.parentRelationship === "SubPlan" || (node.subplanName?.startsWith("SubPlan") ?? false);
930
+ if (!isSubplan) return [];
931
+ const loops = node.actualLoops ?? 0;
932
+ if (loops <= ctx.thresholds.correlatedLoops) return [];
933
+ const name = node.subplanName ?? "the subplan";
934
+ return [
935
+ makeFinding(correlatedSubplan, ctx, node, {
936
+ title: `Correlated ${name} re-executed ${fmtInt(loops)} times`,
937
+ detail: `${name} ran ${fmtInt(loops)} times \u2014 once per outer row \u2014 instead of being evaluated a single time.`,
938
+ cause: "The subquery references a column from the enclosing query, so the planner cannot pull it out of the per-row loop and re-runs the whole subplan for every outer row.",
939
+ remediation: {
940
+ summary: `De-correlate the subquery: rewrite it as a JOIN or LATERAL join, or hoist it into a CTE/derived table evaluated once, then index the correlation key so the rewrite stays cheap.`,
941
+ steps: [
942
+ "Identify the outer column the subquery references (the correlation key).",
943
+ "For a scalar subquery in SELECT/WHERE, rewrite it as a LEFT JOIN to a grouped derived table, or a LATERAL join when it returns per-row results.",
944
+ "For EXISTS/IN, prefer the semi-join form (EXISTS / = ANY) the planner can de-correlate into a single hash/merge join.",
945
+ "Add an index on the correlation key so the resulting JOIN does not fall back to the same per-row cost.",
946
+ // Before (correlated, runs once per outer row):
947
+ // SELECT o.id, (SELECT count(*) FROM events e WHERE e.order_id = o.id) AS n FROM orders o;
948
+ // After (evaluated once, joined):
949
+ // SELECT o.id, COALESCE(e.n, 0) AS n
950
+ // FROM orders o
951
+ // LEFT JOIN (SELECT order_id, count(*) AS n FROM events GROUP BY order_id) e
952
+ // ON e.order_id = o.id;
953
+ "See the before/after sketch: SELECT (SELECT count(*) FROM events e WHERE e.order_id = o.id) becomes a LEFT JOIN to (SELECT order_id, count(*) FROM events GROUP BY order_id)."
954
+ ],
955
+ commands: [
956
+ {
957
+ label: "Index the correlation key so the de-correlated JOIN stays cheap",
958
+ sql: "CREATE INDEX ON <subquery table> (<correlation key column>);"
959
+ }
960
+ ]
961
+ },
962
+ docsUrl: `${DOCS2}/queries-table-expressions.html#QUERIES-LATERAL`,
963
+ meta: { loops }
964
+ })
965
+ ];
966
+ }
967
+ };
968
+
969
+ // src/advisor/rules/could-be-index-only.ts
970
+ var couldBeIndexOnly = {
971
+ id: "PGX_COULD_BE_INDEX_ONLY",
972
+ title: "Index scan may be eligible for index-only",
973
+ defaultSeverity: "info",
974
+ check(node, ctx) {
975
+ if (node.nodeType !== "Index Scan") return [];
976
+ if (!node.indexName) return [];
977
+ if (node.filter) return [];
978
+ if (!node.output || node.output.length === 0) return [];
979
+ if (node.output.length > 4) return [];
980
+ const rel = node.relationName ?? "the table";
981
+ const cols = node.output;
982
+ const colList = cols.join(", ");
983
+ const includeCols = cols.join(", ");
984
+ return [
985
+ makeFinding(couldBeIndexOnly, ctx, node, {
986
+ title: `Index Scan using ${node.indexName} on ${rel} may be eligible for index-only`,
987
+ detail: `This Index Scan projects only ${cols.length} column${cols.length === 1 ? "" : "s"} (${colList}) and applies no residual filter, so its predicate is fully resolved by ${node.indexName}. If that index also covers the selected columns, Postgres could use an Index Only Scan and skip the heap entirely.`,
988
+ cause: "An Index Scan still visits the table heap to fetch the projected columns. When every selected column is contained in the index (as a key or INCLUDE column) and the visibility map is current, Postgres can answer from the index alone (Index Only Scan).",
989
+ remediation: {
990
+ summary: `Add the selected columns (${includeCols}) to ${node.indexName} as INCLUDE columns so it covers the query, then keep the visibility map current with VACUUM so Postgres can switch ${rel} to an Index Only Scan.`,
991
+ steps: [
992
+ "Confirm which columns the index already covers (\\d <index> in psql) \u2014 this hint assumes VERBOSE Output and cannot read the index definition.",
993
+ "If any projected column is missing, recreate the index with those columns as INCLUDE (non-key) columns.",
994
+ "Run VACUUM so the visibility map is set; Index Only Scans fall back to heap fetches on pages not marked all-visible."
995
+ ],
996
+ commands: [
997
+ {
998
+ label: "Create a covering index",
999
+ sql: `CREATE INDEX ON ${rel} (<key columns from the Index Cond>) INCLUDE (${includeCols});`
1000
+ },
1001
+ {
1002
+ label: "Keep the visibility map current",
1003
+ sql: `VACUUM ${rel};`
1004
+ }
1005
+ ]
1006
+ },
1007
+ docsUrl: `${DOCS2}/indexes-index-only-scans.html`,
1008
+ meta: { outputColumns: cols.length }
1009
+ })
1010
+ ];
1011
+ }
1012
+ };
1013
+
1014
+ // src/advisor/rules/filter-could-be-index-cond.ts
1015
+ var filterCouldBeIndexCond = {
1016
+ id: "PGX_FILTER_COULD_BE_INDEX_COND",
1017
+ title: "Filter could be an index condition",
1018
+ defaultSeverity: "info",
1019
+ requiresAnalyze: true,
1020
+ check(node, ctx) {
1021
+ const indexed = node.nodeType === "Index Scan" || node.nodeType === "Index Only Scan" || node.nodeType === "Bitmap Heap Scan";
1022
+ if (!indexed) return [];
1023
+ if (!node.filter) return [];
1024
+ if (!node.indexCond && !node.recheckCond) return [];
1025
+ if ((node.rowsRemovedByFilter ?? 0) <= 0) return [];
1026
+ const rel = node.relationName ?? "the table";
1027
+ const cond = node.indexCond ?? node.recheckCond ?? "";
1028
+ const loops = node.actualLoops ?? 1;
1029
+ const removed = (node.rowsRemovedByFilter ?? 0) * loops;
1030
+ return [
1031
+ makeFinding(filterCouldBeIndexCond, ctx, node, {
1032
+ title: `Residual filter on ${rel} could be an index condition`,
1033
+ detail: `${node.nodeType} on ${rel} used the index for ${cond} but then applied Filter ${node.filter} to the fetched rows, discarding ${fmtInt(removed)} of them.`,
1034
+ cause: `The Filter columns are not part of the index, so Postgres must fetch each row the index matched and re-check the predicate in the executor instead of skipping non-matching entries during the index traversal.`,
1035
+ remediation: {
1036
+ summary: `Extend the index on ${rel} to include the Filter columns from ${node.filter} as trailing key columns, so the predicate is applied as an Index Cond during traversal instead of as a post-fetch Filter.`,
1037
+ steps: [
1038
+ `Confirm the Filter ${node.filter} is sargable \u2014 no functions or implicit casts wrapping the column.`,
1039
+ "Append the filter columns after the existing key columns so the index still serves the original lookup.",
1040
+ "Re-run EXPLAIN (ANALYZE) and check the Filter moved into the Index Cond with no Rows Removed by Filter."
1041
+ ],
1042
+ commands: [
1043
+ {
1044
+ label: "Extend the index with the filter columns",
1045
+ sql: `CREATE INDEX ON ${rel} (<existing key columns>, <filter columns>);`
1046
+ }
1047
+ ]
1048
+ },
1049
+ docsUrl: `${DOCS2}/indexes-multicolumn.html`,
1050
+ meta: { rowsRemovedByFilter: Math.round(removed) }
1051
+ })
1052
+ ];
1053
+ }
1054
+ };
1055
+
1056
+ // src/advisor/rules/hash-spill-disk.ts
1057
+ var hashSpillDisk = {
1058
+ id: "PGX_HASH_SPILL_DISK",
1059
+ title: "Hash join spilled to disk",
1060
+ defaultSeverity: "warn",
1061
+ requiresAnalyze: true,
1062
+ check(node, ctx) {
1063
+ if (node.nodeType !== "Hash") return [];
1064
+ const hashBatches = node.hashBatches ?? 1;
1065
+ if (hashBatches <= 1) return [];
1066
+ const originalHashBatches = node.originalHashBatches ?? hashBatches;
1067
+ const repartitioned = hashBatches > originalHashBatches;
1068
+ const peakKiB = node.peakMemoryUsage ?? 0;
1069
+ const diskKiB = node.diskUsage ?? 0;
1070
+ const workMemRecommended = roundUpMiB((peakKiB + diskKiB) * 1.2);
1071
+ return [
1072
+ makeFinding(hashSpillDisk, ctx, node, {
1073
+ title: `Hash build side spilled to disk (${fmtInt(hashBatches)} batches)`,
1074
+ detail: `The hash table was split into ${fmtInt(hashBatches)} batches${repartitioned ? ` (up from ${fmtInt(originalHashBatches)} planned)` : ""}, so the build side did not fit in work_mem and overflowed to temporary files${diskKiB > 0 ? ` (${fmtKiB(diskKiB)} written to disk)` : ""}.`,
1075
+ cause: repartitioned ? "Postgres had to add batches at runtime because the build side was larger than estimated \u2014 usually a row underestimate sized work_mem too small." : "The build (hash) side was larger than work_mem, forcing the hash join to partition it across disk-backed batches.",
1076
+ remediation: {
1077
+ summary: `Raise work_mem to about ${workMemRecommended} so the build side fits in a single batch, and make sure the SMALLER input is the hash/build side (a wrong build side usually comes from a row underestimate \u2014 fix the stats). Reducing build-side rows also avoids the spill.`,
1078
+ steps: [
1079
+ `Set work_mem high enough to hold the build side in one batch (~${workMemRecommended} here) at session or role scope, not globally \u2014 every sort/hash node can use work_mem, so a global bump can exhaust memory.`,
1080
+ "Confirm the smaller relation is on the build (Hash) side; if Postgres chose the larger side, a row underestimate is likely \u2014 re-run ANALYZE or raise statistics targets so the planner picks the smaller build side.",
1081
+ "Alternatively, filter or aggregate the build side earlier so fewer rows need to be hashed."
1082
+ ],
1083
+ commands: [
1084
+ {
1085
+ label: "Raise work_mem for this session",
1086
+ sql: `SET work_mem = '${workMemRecommended}';`
1087
+ },
1088
+ {
1089
+ label: "Or set it for a specific role",
1090
+ sql: `ALTER ROLE <role> SET work_mem = '${workMemRecommended}';`
1091
+ },
1092
+ {
1093
+ label: "Refresh planner statistics on the build-side table",
1094
+ sql: "ANALYZE <build_side_table>;"
1095
+ }
1096
+ ]
1097
+ },
1098
+ docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-WORK-MEM`,
1099
+ meta: { hashBatches, workMemRecommended }
1100
+ })
1101
+ ];
1102
+ }
1103
+ };
1104
+
1105
+ // src/advisor/rules/high-filter-discard.ts
1106
+ var highFilterDiscard = {
1107
+ id: "PGX_HIGH_FILTER_DISCARD",
1108
+ title: "Filter discards most rows read",
1109
+ defaultSeverity: "warn",
1110
+ requiresAnalyze: true,
1111
+ check(node, ctx) {
1112
+ const ratio = node.metrics.filterDiscardRatio;
1113
+ if (ratio === void 0 || ratio <= ctx.thresholds.filterDiscardRatio) return [];
1114
+ const removed = (node.rowsRemovedByFilter ?? 0) * (node.actualLoops ?? 1);
1115
+ if (removed <= ctx.thresholds.filterRemovedAbs) return [];
1116
+ const rel = node.relationName ?? "the table";
1117
+ const kept = node.metrics.totalRows ?? 0;
1118
+ const discardPct = ratio * 100;
1119
+ const filter = node.filter ?? "the filter predicate";
1120
+ return [
1121
+ makeFinding(highFilterDiscard, ctx, node, {
1122
+ title: `Filter on ${rel} discards ${fmtPct(discardPct)} of rows read`,
1123
+ detail: `Postgres read this node's rows then dropped ${fmtInt(removed)} of them (${fmtPct(
1124
+ discardPct
1125
+ )}), keeping only ${fmtInt(kept)}, via the post-read filter ${filter}.`,
1126
+ cause: `The predicate ${filter} is evaluated as a Filter after the rows are fetched, so every discarded row was still read and examined \u2014 work no index condition narrowed.`,
1127
+ remediation: {
1128
+ summary: `Move ${filter} into an index on ${rel} so the predicate becomes an Index Cond instead of a post-read Filter, letting Postgres skip the non-matching rows. For a low-cardinality predicate, a partial index keyed on the discarded condition is smaller and faster.`,
1129
+ steps: [
1130
+ "Identify the column(s) referenced by the filter above.",
1131
+ "Ensure the predicate is sargable (no function-wrapping or implicit casts on the indexed column).",
1132
+ "Use a plain index when the columns are selective across queries; use a partial index when the same constant predicate is always applied."
1133
+ ],
1134
+ commands: [
1135
+ {
1136
+ label: "Index the filter columns",
1137
+ sql: `CREATE INDEX ON ${rel} (<filter columns>);`
1138
+ },
1139
+ {
1140
+ label: "Or a partial index for a fixed low-cardinality predicate",
1141
+ sql: `CREATE INDEX ON ${rel} (<filter columns>) WHERE <predicate>;`
1142
+ }
1143
+ ]
1144
+ },
1145
+ docsUrl: `${DOCS2}/indexes-partial.html`,
1146
+ meta: { discardPct: Math.round(discardPct) }
1147
+ })
1148
+ ];
1149
+ }
1150
+ };
1151
+
1152
+ // src/advisor/rules/index-only-heap-fetches.ts
1153
+ var indexOnlyHeapFetches = {
1154
+ id: "PGX_INDEX_ONLY_HEAP_FETCHES",
1155
+ title: "Index-only scan with heap fetches",
1156
+ defaultSeverity: "info",
1157
+ requiresAnalyze: true,
1158
+ check(node, ctx) {
1159
+ if (node.nodeType !== "Index Only Scan") return [];
1160
+ const heapFetches = node.heapFetches ?? 0;
1161
+ if (heapFetches <= 0) return [];
1162
+ const rows = Math.max(node.metrics.totalRows ?? 1, 1);
1163
+ const ratio = heapFetches / rows;
1164
+ if (ratio <= ctx.thresholds.heapFetchRatio && heapFetches <= ctx.thresholds.heapFetchAbs) {
1165
+ return [];
1166
+ }
1167
+ const rel = node.relationName ?? "the table";
1168
+ return [
1169
+ makeFinding(indexOnlyHeapFetches, ctx, node, {
1170
+ title: `Index-only scan on ${rel} did ${fmtInt(heapFetches)} heap fetches`,
1171
+ detail: `The index-only scan on ${rel} fell back to the heap ${fmtInt(heapFetches)} times for ${fmtInt(
1172
+ rows
1173
+ )} rows produced. Each fetch is an extra table page read the index-only path was meant to avoid.`,
1174
+ cause: `Heap fetches happen when the visibility map does not mark the pages as all-visible, so Postgres must read the table row to confirm visibility. This usually means ${rel} has not been vacuumed recently enough for its write/update rate.`,
1175
+ remediation: {
1176
+ summary: `Run VACUUM (or VACUUM ANALYZE) on ${rel} to refresh the visibility map so the index-only scan can skip the heap. For a high-churn table, lower autovacuum_vacuum_scale_factor so autovacuum keeps the map current.`,
1177
+ steps: [
1178
+ `VACUUM ${rel} to update the visibility map; add ANALYZE if statistics are also stale.`,
1179
+ "If heap fetches keep returning, the table is updated faster than autovacuum runs \u2014 make autovacuum more aggressive on it.",
1180
+ "Confirm the scan stays index-only afterwards by re-running EXPLAIN (ANALYZE) and checking Heap Fetches drops toward 0."
1181
+ ],
1182
+ commands: [
1183
+ {
1184
+ label: "Refresh the visibility map",
1185
+ sql: `VACUUM (ANALYZE) ${rel};`
1186
+ },
1187
+ {
1188
+ label: "Keep the map current on a high-churn table",
1189
+ sql: `ALTER TABLE ${rel} SET (autovacuum_vacuum_scale_factor = 0.02);`
1190
+ }
1191
+ ]
1192
+ },
1193
+ docsUrl: `${DOCS2}/indexes-index-only-scans.html`,
1194
+ meta: { heapFetches }
1195
+ })
1196
+ ];
1197
+ }
1198
+ };
1199
+
1200
+ // src/advisor/rules/low-cache-hit.ts
1201
+ var MIN_READ_BLOCKS = 1e3;
1202
+ var lowCacheHit = {
1203
+ id: "PGX_LOW_CACHE_HIT",
1204
+ title: "Low cache hit ratio (heavy disk reads)",
1205
+ defaultSeverity: "info",
1206
+ requiresBuffers: true,
1207
+ check(node, ctx) {
1208
+ const ratio = node.metrics.cacheHitRatio;
1209
+ const readBlocks = node.sharedReadBlocks ?? 0;
1210
+ if (ratio == null) return [];
1211
+ if (ratio >= ctx.thresholds.lowCacheHitRatio) return [];
1212
+ if (readBlocks <= MIN_READ_BLOCKS) return [];
1213
+ const label = nodeLabel(node);
1214
+ const rel = node.relationName;
1215
+ const ratioPct = ratio * 100;
1216
+ return [
1217
+ makeFinding(lowCacheHit, ctx, node, {
1218
+ title: `Low cache hit ratio at ${label} (${fmtPct(ratioPct)})`,
1219
+ detail: `${label} served only ${fmtPct(ratioPct)} of its shared-buffer accesses from cache, reading ${fmtBlocks(readBlocks)} from disk.`,
1220
+ cause: "The pages this node needed were not resident in shared_buffers, so PostgreSQL had to read them from disk. On a first run this is an expected cold cache; if it persists, the working set is larger than the cache or the scan touches more pages than necessary.",
1221
+ remediation: {
1222
+ summary: `Re-run the query to check whether this is just a cold cache \u2014 the ratio should climb on a warm run. If it stays low, the working set exceeds shared_buffers: size shared_buffers/effective_cache_size to your RAM, or add a selective index on ${rel ?? "the scanned relation"} so far fewer pages are read.`,
1223
+ steps: [
1224
+ "Run the same EXPLAIN (ANALYZE, BUFFERS) a second time; a much higher hit ratio means the first run was a cold cache and no action is needed.",
1225
+ "If the ratio stays low, check whether shared_buffers (and effective_cache_size for planner costing) are sized to the machine's RAM.",
1226
+ "If the node reads far more pages than the rows it returns, add a selective index so only matching pages are fetched."
1227
+ ],
1228
+ commands: [
1229
+ {
1230
+ label: "Inspect current buffer-cache sizing",
1231
+ sql: "SHOW shared_buffers; SHOW effective_cache_size;"
1232
+ },
1233
+ {
1234
+ label: "Reduce pages read with a selective index",
1235
+ sql: `CREATE INDEX ON ${rel ?? "<table>"} (<predicate columns>);`
1236
+ }
1237
+ ]
1238
+ },
1239
+ docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-SHARED-BUFFERS`,
1240
+ meta: { cacheHitPct: Math.round(ratioPct * 10) / 10, readBlocks }
1241
+ })
1242
+ ];
1243
+ }
1244
+ };
1245
+
1246
+ // src/advisor/rules/nested-loop-large-outer.ts
1247
+ var nestedLoopLargeOuter = {
1248
+ id: "PGX_NESTED_LOOP_LARGE_OUTER",
1249
+ title: "Nested loop with a large outer side",
1250
+ defaultSeverity: "warn",
1251
+ requiresAnalyze: true,
1252
+ check(node, ctx) {
1253
+ if (node.nodeType !== "Nested Loop") return [];
1254
+ const outer = outerChild(node);
1255
+ const outerRows = outer?.metrics.totalRows;
1256
+ if (outerRows === void 0 || outerRows <= ctx.thresholds.nestedLoopOuterRows) return [];
1257
+ const outerLabel = outer ? nodeLabel(outer) : "the outer side";
1258
+ const inner = node.children[1];
1259
+ const innerLabel = inner ? nodeLabel(inner) : "the inner side";
1260
+ const innerCond = inner?.indexCond ?? inner?.joinFilter ?? inner?.filter ?? node.joinFilter;
1261
+ const misestimated = outer?.metrics.estimateDirection === "under";
1262
+ return [
1263
+ makeFinding(nestedLoopLargeOuter, ctx, node, {
1264
+ title: `Nested loop driven by ${fmtInt(outerRows)} outer rows (${outerLabel})`,
1265
+ detail: `The nested loop's outer side (${outerLabel}) produced ${fmtInt(
1266
+ outerRows
1267
+ )} rows, so its inner side (${innerLabel}) is re-executed roughly that many times.`,
1268
+ cause: misestimated ? `The planner expected far fewer outer rows than the ${fmtInt(
1269
+ outerRows
1270
+ )} that actually came back, so it chose a per-row nested loop where a single hash/merge join would have been cheaper.` : `A nested loop probes the inner side once per outer row; with ${fmtInt(
1271
+ outerRows
1272
+ )} outer rows that is ${fmtInt(outerRows)} inner executions.`,
1273
+ remediation: {
1274
+ summary: `Fix the outer-side row estimate first \u2014 re-ANALYZE ${outer?.relationName ?? "the driving table"}, raise its column statistics, or add extended statistics \u2014 so the planner switches to a hash or merge join. If the estimate is already accurate, add an index on the inner join key (${innerCond ?? "<inner join column>"}) so each of the ${fmtInt(outerRows)} probes is cheap.`,
1275
+ steps: [
1276
+ "Compare the outer node's estimated vs actual rows: a large gap means the estimate is the real problem.",
1277
+ "Refresh statistics so the planner sees the true cardinality and can prefer a hash/merge join.",
1278
+ "If columns are correlated, create extended (multivariate) statistics on them.",
1279
+ "If estimates are already correct, index the inner join key so each probe is an index lookup, not a scan.",
1280
+ "As a last resort to confirm the diagnosis, test with `SET enable_nestloop = off` for this query only."
1281
+ ],
1282
+ commands: [
1283
+ {
1284
+ label: "Refresh planner statistics on the driving table",
1285
+ sql: `ANALYZE ${outer?.relationName ?? "<outer table>"};`
1286
+ },
1287
+ {
1288
+ label: "Increase statistics target on the misestimated column, then re-ANALYZE",
1289
+ sql: `ALTER TABLE ${outer?.relationName ?? "<outer table>"} ALTER COLUMN <column> SET STATISTICS 1000;
1290
+ ANALYZE ${outer?.relationName ?? "<outer table>"};`
1291
+ },
1292
+ {
1293
+ label: "Add extended statistics for correlated columns",
1294
+ sql: `CREATE STATISTICS ${outer?.relationName ?? "<outer table>"}_stats (dependencies, ndistinct) ON <col_a>, <col_b> FROM ${outer?.relationName ?? "<outer table>"};
1295
+ ANALYZE ${outer?.relationName ?? "<outer table>"};`
1296
+ },
1297
+ {
1298
+ label: "If estimates are correct, index the inner join key",
1299
+ sql: `CREATE INDEX ON ${inner?.relationName ?? "<inner table>"} (<inner join column>);`
1300
+ },
1301
+ {
1302
+ label: "Confirm the diagnosis by disabling nested loops for this query only",
1303
+ sql: "SET enable_nestloop = off;"
1304
+ }
1305
+ ]
1306
+ },
1307
+ docsUrl: `${DOCS2}/runtime-config-query.html`,
1308
+ meta: { outerRows: Math.round(outerRows) }
1309
+ })
1310
+ ];
1311
+ }
1312
+ };
1313
+
1314
+ // src/advisor/rules/row-misestimate.ts
1315
+ var rowMisestimate = {
1316
+ id: "PGX_ROW_MISESTIMATE",
1317
+ title: "Row count misestimate",
1318
+ defaultSeverity: "info",
1319
+ requiresAnalyze: true,
1320
+ check(node, ctx) {
1321
+ const { estimateFactor, estimateDirection, totalRows } = node.metrics;
1322
+ if (estimateFactor === void 0) return [];
1323
+ if (estimateFactor < ctx.thresholds.misestimateFactor) return [];
1324
+ if (estimateDirection === void 0 || estimateDirection === "accurate") return [];
1325
+ const rows = Math.max(totalRows ?? 0, node.planRows);
1326
+ if (rows < 100) return [];
1327
+ const factor = Math.round(estimateFactor);
1328
+ const rel = node.relationName;
1329
+ const onRel = rel ? ` on ${rel}` : "";
1330
+ const target = rel ?? "the underlying table";
1331
+ const under = estimateDirection === "under";
1332
+ const direction = under ? "underestimate" : "overestimate";
1333
+ const actual = totalRows ?? 0;
1334
+ const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(actual)} were produced \u2014 a ${fmtInt(factor)}x ${direction}${onRel}.`;
1335
+ return [
1336
+ makeFinding(rowMisestimate, ctx, node, {
1337
+ // Severity: underestimates are the dangerous ones (under-sized joins/memory).
1338
+ severity: under ? "warn" : "info",
1339
+ title: `${fmtInt(factor)}x row ${direction}${onRel}`,
1340
+ detail,
1341
+ cause: "The planner's row estimate is based on statistics that are stale, missing, or too coarse for this predicate (e.g. correlated columns the planner treats as independent).",
1342
+ remediation: {
1343
+ summary: `Refresh and sharpen statistics for ${target}: run ANALYZE ${rel ?? "<relation>"}, raise per-column statistics targets on the predicate columns, and add extended statistics for correlated columns so the planner estimates rows correctly.${under ? " Underestimates feeding a nested loop or hash join are the highest priority \u2014 fix these first." : ""}`,
1344
+ steps: [
1345
+ `Refresh table statistics first; this alone often fixes the estimate.`,
1346
+ `If the column has a skewed/uneven distribution, raise its statistics target and re-ANALYZE.`,
1347
+ `If the predicate spans multiple correlated columns, create extended statistics so the planner stops assuming independence.`
1348
+ ],
1349
+ commands: [
1350
+ {
1351
+ label: "Refresh statistics",
1352
+ sql: `ANALYZE ${rel ?? "<relation>"};`
1353
+ },
1354
+ {
1355
+ label: "Raise per-column statistics target",
1356
+ sql: `ALTER TABLE ${rel ?? "<relation>"} ALTER COLUMN <column> SET STATISTICS 1000;
1357
+ ANALYZE ${rel ?? "<relation>"};`
1358
+ },
1359
+ {
1360
+ label: "Add extended statistics for correlated columns",
1361
+ sql: `CREATE STATISTICS <stats_name> (dependencies, ndistinct) ON <col_a>, <col_b> FROM ${rel ?? "<relation>"};
1362
+ ANALYZE ${rel ?? "<relation>"};`
1363
+ }
1364
+ ]
1365
+ },
1366
+ docsUrl: `${DOCS2}/planner-stats.html`,
1367
+ meta: {
1368
+ estimatedRows: Math.round(node.planRows),
1369
+ actualRows: Math.round(actual),
1370
+ factor,
1371
+ direction: estimateDirection
1372
+ }
1373
+ })
1374
+ ];
1375
+ }
1376
+ };
1377
+
1378
+ // src/advisor/rules/seq-scan-large.ts
1379
+ var seqScanLarge = {
1380
+ id: "PGX_SEQ_SCAN_LARGE",
1381
+ title: "Sequential scan on a large table",
1382
+ defaultSeverity: "warn",
1383
+ check(node, ctx) {
1384
+ if (node.nodeType !== "Seq Scan") return [];
1385
+ const rows = node.metrics.totalRows ?? node.planRows;
1386
+ if (rows < ctx.thresholds.seqScanRows) return [];
1387
+ const rel = node.relationName ?? "the table";
1388
+ const estimated = node.metrics.totalRows === void 0;
1389
+ const filterCols = node.filter ? ` matching ${node.filter}` : "";
1390
+ return [
1391
+ makeFinding(seqScanLarge, ctx, node, {
1392
+ title: `Sequential scan on ${rel} (${fmtInt(rows)}${estimated ? " est." : ""} rows)`,
1393
+ detail: `Postgres read ${rel} sequentially, scanning roughly ${fmtInt(rows)} rows${estimated ? " (estimated \u2014 run with ANALYZE for actuals)" : ""}.`,
1394
+ cause: node.filter ? `A row filter (${node.filter}) is applied after reading every row, so no index narrowed the scan.` : "No index condition narrowed the scan, so the whole relation was read.",
1395
+ remediation: {
1396
+ summary: `Add an index covering the WHERE/JOIN predicate on ${rel} so Postgres can skip non-matching rows. If the query genuinely needs most of the table, the seq scan is correct \u2014 reduce the rows touched instead.`,
1397
+ steps: [
1398
+ "Identify the selective columns in the WHERE/JOIN predicate.",
1399
+ "Ensure they are sargable (no function-wrapping or implicit casts on the column).",
1400
+ "If selectivity is low, a partial index (WHERE \u2026) may be better."
1401
+ ],
1402
+ commands: [
1403
+ {
1404
+ label: "Index the predicate columns",
1405
+ sql: `CREATE INDEX ON ${rel} (<predicate columns>)${filterCols ? " -- columns from the filter above" : ""};`
1406
+ }
1407
+ ]
1408
+ },
1409
+ docsUrl: `${DOCS2}/indexes-intro.html`,
1410
+ meta: { rows: Math.round(rows) }
1411
+ })
1412
+ ];
1413
+ }
1414
+ };
1415
+
1416
+ // src/advisor/rules/significant-jit.ts
1417
+ var significantJit = {
1418
+ id: "PGX_SIGNIFICANT_JIT",
1419
+ title: "JIT compilation dominates execution",
1420
+ defaultSeverity: "info",
1421
+ requiresAnalyze: true,
1422
+ check(node, ctx) {
1423
+ if (node !== ctx.tree.root) return [];
1424
+ const t = ctx.tree.jit?.timing;
1425
+ const jitTotal = t?.total ?? (t?.generation ?? 0) + (t?.inlining ?? 0) + (t?.optimization ?? 0) + (t?.emission ?? 0);
1426
+ const execMs = executionMs(ctx.tree);
1427
+ if (!execMs || jitTotal <= 0) return [];
1428
+ const jitPct = 100 * jitTotal / execMs;
1429
+ if (jitPct <= ctx.thresholds.jitPct) return [];
1430
+ return [
1431
+ makeFinding(significantJit, ctx, node, {
1432
+ title: `JIT compilation took ${fmtMs(jitTotal)} (${fmtPct(jitPct)} of execution)`,
1433
+ detail: `JIT spent ${fmtMs(jitTotal)} generating, optimizing, and emitting machine code, out of ${fmtMs(
1434
+ execMs
1435
+ )} total execution time. The compilation overhead outweighs the runtime it saved.`,
1436
+ cause: "The plan's estimated cost crossed jit_above_cost, so Postgres JIT-compiled the query \u2014 but the query is too short for compilation to pay off, often because a row overestimate inflated the cost.",
1437
+ remediation: {
1438
+ summary: "Raise jit_above_cost (and jit_inline_above_cost / jit_optimize_above_cost) so short queries skip JIT, or disable JIT for this session with SET jit = off. Then investigate why the cost estimate is high enough to trigger JIT \u2014 frequently a row overestimate fixable with ANALYZE.",
1439
+ steps: [
1440
+ "Confirm the query is consistently short-running before tuning \u2014 JIT pays off on long, CPU-bound queries.",
1441
+ "Raise jit_above_cost above this plan's total cost so similar queries skip JIT entirely.",
1442
+ "If only inlining/optimization are expensive, raise jit_inline_above_cost / jit_optimize_above_cost instead of disabling JIT.",
1443
+ "Check the planner's row estimates against actuals \u2014 an overestimate that inflates cost is the usual reason a cheap query triggers JIT; run ANALYZE on the relations involved."
1444
+ ],
1445
+ commands: [
1446
+ { label: "Disable JIT for this session", sql: "SET jit = off;" },
1447
+ {
1448
+ label: "Raise the JIT cost thresholds",
1449
+ sql: "SET jit_above_cost = <above this plan's total cost>;\nSET jit_inline_above_cost = <higher>;\nSET jit_optimize_above_cost = <higher>;"
1450
+ },
1451
+ {
1452
+ label: "Refresh statistics if the cost is driven by a row overestimate",
1453
+ sql: "ANALYZE <table>;"
1454
+ }
1455
+ ]
1456
+ },
1457
+ docsUrl: `${DOCS2}/runtime-config-query.html#GUC-JIT-ABOVE-COST`,
1458
+ meta: { jitMs: Math.round(jitTotal), jitPct: Math.round(jitPct) }
1459
+ })
1460
+ ];
1461
+ }
1462
+ };
1463
+
1464
+ // src/advisor/rules/sort-spill-disk.ts
1465
+ var sortSpillDisk = {
1466
+ id: "PGX_SORT_SPILL_DISK",
1467
+ title: "Sort spilled to disk",
1468
+ defaultSeverity: "warn",
1469
+ requiresAnalyze: true,
1470
+ check(node, ctx) {
1471
+ if (node.nodeType !== "Sort") return [];
1472
+ const onDisk = node.sortSpaceType === "Disk" || node.sortMethod !== void 0 && /external/i.test(node.sortMethod);
1473
+ if (!onDisk) return [];
1474
+ const usedKiB = node.sortSpaceUsed ?? 0;
1475
+ const workMemRecommended = roundUpMiB(usedKiB > 0 ? usedKiB * 2.2 : 0);
1476
+ const usedText = usedKiB > 0 ? ` using ${fmtKiB(usedKiB)} of temp space` : "";
1477
+ const method = node.sortMethod ? ` (${node.sortMethod})` : "";
1478
+ const orderBy = node.sortKey && node.sortKey.length > 0 ? node.sortKey.join(", ") : "<ORDER BY columns>";
1479
+ const summary = usedKiB > 0 ? `Raise work_mem for this query so the sort stays in memory, e.g. SET work_mem = '${workMemRecommended}' at session or role scope (do NOT raise it globally without accounting for max_connections \u2014 each connection can allocate work_mem several times over). Alternatively, add an index on (${orderBy}) so rows arrive pre-sorted and the Sort node disappears.` : `Raise work_mem for this query so the sort stays in memory, e.g. SET work_mem = '64MB' at session or role scope (do NOT raise it globally without accounting for max_connections \u2014 each connection can allocate work_mem several times over). Alternatively, add an index on (${orderBy}) so rows arrive pre-sorted and the Sort node disappears.`;
1480
+ return [
1481
+ makeFinding(sortSpillDisk, ctx, node, {
1482
+ title: `Sort spilled to disk${usedText}`,
1483
+ detail: `The Sort node ran an external merge sort on disk${method}${usedText}, because the data exceeded work_mem.`,
1484
+ cause: "work_mem was too small to hold the sort set, so Postgres wrote sorted runs to temporary files and merged them \u2014 adding temp-file I/O that an in-memory sort avoids.",
1485
+ remediation: {
1486
+ summary,
1487
+ steps: [
1488
+ "Set work_mem at session or role scope for this workload, not cluster-wide.",
1489
+ `Size it above the spilled footprint${usedKiB > 0 ? ` (~${fmtKiB(usedKiB)}); ${workMemRecommended} leaves headroom` : ""}.`,
1490
+ `Or add an index on (${orderBy}) so the sort is satisfied by an ordered scan and removed entirely.`
1491
+ ],
1492
+ commands: [
1493
+ {
1494
+ label: "Raise work_mem for this session",
1495
+ sql: `SET work_mem = '${usedKiB > 0 ? workMemRecommended : "64MB"}';`
1496
+ },
1497
+ {
1498
+ label: "Or set it per role",
1499
+ sql: `ALTER ROLE <role> SET work_mem = '${usedKiB > 0 ? workMemRecommended : "64MB"}';`
1500
+ },
1501
+ {
1502
+ label: "Or add an index matching the sort key",
1503
+ sql: `CREATE INDEX ON <table> (${orderBy});`
1504
+ }
1505
+ ]
1506
+ },
1507
+ docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-WORK-MEM`,
1508
+ meta: { sortSpaceUsedKiB: Math.round(usedKiB), workMemRecommended }
1509
+ })
1510
+ ];
1511
+ }
1512
+ };
1513
+
1514
+ // src/advisor/rules/trigger-time.ts
1515
+ var triggerTime = {
1516
+ id: "PGX_TRIGGER_TIME",
1517
+ title: "Triggers consume significant time",
1518
+ defaultSeverity: "info",
1519
+ requiresAnalyze: true,
1520
+ check(node, ctx) {
1521
+ if (node !== ctx.tree.root) return [];
1522
+ const triggers = ctx.tree.triggers;
1523
+ const execMs = executionMs(ctx.tree);
1524
+ const triggerTotal = triggers.reduce((s, t) => s + (t.time ?? 0), 0);
1525
+ if (!triggers.length || !execMs || triggerTotal <= 0) return [];
1526
+ const pct = 100 * triggerTotal / execMs;
1527
+ if (pct <= ctx.thresholds.triggerPct) return [];
1528
+ const worst = triggers.reduce((a, b) => (b.time ?? 0) > (a.time ?? 0) ? b : a);
1529
+ const worstLabel = worst.name ?? worst.constraintName ?? "a trigger";
1530
+ const onRel = worst.relation ? ` on ${worst.relation}` : "";
1531
+ return [
1532
+ makeFinding(triggerTime, ctx, node, {
1533
+ title: `Triggers consumed ${fmtMs(triggerTotal)} (${fmtPct(pct)} of execution)`,
1534
+ detail: `Trigger execution took ${fmtMs(triggerTotal)} of the ${fmtMs(
1535
+ execMs
1536
+ )} total \u2014 ${fmtPct(pct)} of the time is spent outside the plan tree (heaviest: "${worstLabel}"${onRel}).`,
1537
+ cause: "Time spent firing triggers (often foreign-key constraint checks or AFTER triggers) is not attributed to any plan node, so it is invisible in the node timings even though it dominates the statement.",
1538
+ remediation: {
1539
+ summary: `Index the foreign-key columns involved in the constraint checks so each row's lookup is cheap, and review the trigger function bodies for per-row inefficiency. For bulk loads, defer constraints (SET CONSTRAINTS ALL DEFERRED) or disable and replay triggers around the batch.`,
1540
+ steps: [
1541
+ "Confirm both the referencing and referenced FK columns are indexed \u2014 an FK check does a lookup on the referenced key for every row, and a missing index makes it a full scan per row.",
1542
+ "Inspect each trigger function body for per-row work that could be batched or removed.",
1543
+ "For bulk INSERT/UPDATE/COPY, defer FK constraints until commit, or temporarily disable user triggers and replay the work once after the batch."
1544
+ ],
1545
+ commands: [
1546
+ {
1547
+ label: "Index the referencing FK column(s) so constraint checks are cheap",
1548
+ sql: `CREATE INDEX ON <referencing_table> (<fk_columns>);`
1549
+ },
1550
+ {
1551
+ label: "Defer FK constraint checks to commit for a bulk load",
1552
+ sql: `BEGIN;
1553
+ SET CONSTRAINTS ALL DEFERRED;
1554
+ -- bulk INSERT/UPDATE/COPY here
1555
+ COMMIT;`
1556
+ },
1557
+ {
1558
+ label: "Disable user triggers around a batch, then re-enable",
1559
+ sql: `ALTER TABLE <table> DISABLE TRIGGER USER;
1560
+ -- bulk work here
1561
+ ALTER TABLE <table> ENABLE TRIGGER USER;`
1562
+ }
1563
+ ]
1564
+ },
1565
+ docsUrl: `${DOCS2}/sql-createtrigger.html`,
1566
+ meta: { triggerMs: Math.round(triggerTotal), triggerPct: Math.round(pct) }
1567
+ })
1568
+ ];
1569
+ }
1570
+ };
1571
+
1572
+ // src/advisor/rules/workers-not-launched.ts
1573
+ var workersNotLaunched = {
1574
+ id: "PGX_WORKERS_NOT_LAUNCHED",
1575
+ title: "Parallel workers planned but not launched",
1576
+ defaultSeverity: "info",
1577
+ requiresAnalyze: true,
1578
+ check(node, ctx) {
1579
+ if (node.nodeType !== "Gather" && node.nodeType !== "Gather Merge") return [];
1580
+ if (node.workersPlanned === void 0 || node.workersLaunched === void 0) return [];
1581
+ if (node.workersLaunched >= node.workersPlanned) return [];
1582
+ const planned = node.workersPlanned;
1583
+ const launched = node.workersLaunched;
1584
+ const shortfall = planned - launched;
1585
+ return [
1586
+ makeFinding(workersNotLaunched, ctx, node, {
1587
+ title: `${node.nodeType} got ${fmtInt(launched)} of ${fmtInt(planned)} planned workers`,
1588
+ detail: `This ${node.nodeType} planned for ${fmtInt(planned)} parallel worker${planned === 1 ? "" : "s"} but only ${fmtInt(launched)} were launched (${fmtInt(shortfall)} short), so part of the work ran serially.`,
1589
+ cause: "The global background-worker pool was exhausted: max_worker_processes or max_parallel_workers was already saturated (often by other concurrent parallel queries) when this node tried to start its workers.",
1590
+ remediation: {
1591
+ summary: `Raise max_parallel_workers and max_worker_processes so the pool can supply the ${fmtInt(
1592
+ planned
1593
+ )} workers this query plans for, and confirm max_parallel_workers_per_gather permits them. If parallelism is not actually speeding this query up, the shortfall is harmless.`,
1594
+ steps: [
1595
+ "Check current limits: max_worker_processes, max_parallel_workers, max_parallel_workers_per_gather.",
1596
+ "max_parallel_workers must be <= max_worker_processes; raise both together (max_worker_processes change needs a restart).",
1597
+ "Look for other concurrent parallel queries saturating the shared pool during peak load.",
1598
+ "If a serial plan is no slower here, leave the settings alone \u2014 this is informational."
1599
+ ],
1600
+ commands: [
1601
+ {
1602
+ label: "Enlarge the global parallel-worker pool",
1603
+ sql: "ALTER SYSTEM SET max_parallel_workers = '<N>';\nALTER SYSTEM SET max_worker_processes = '<N+>';\nSELECT pg_reload_conf();"
1604
+ },
1605
+ {
1606
+ label: "Allow more workers per Gather",
1607
+ sql: "ALTER SYSTEM SET max_parallel_workers_per_gather = '<N>';\nSELECT pg_reload_conf();"
1608
+ },
1609
+ {
1610
+ label: "Inspect the current settings",
1611
+ sql: "SELECT name, setting FROM pg_settings WHERE name IN ('max_worker_processes', 'max_parallel_workers', 'max_parallel_workers_per_gather');"
1612
+ }
1613
+ ]
1614
+ },
1615
+ docsUrl: `${DOCS2}/runtime-config-resource.html#GUC-MAX-PARALLEL-WORKERS`,
1616
+ meta: { planned, launched }
1617
+ })
1618
+ ];
1619
+ }
1620
+ };
1621
+
1622
+ // src/advisor/rules/index.ts
1623
+ var ALL_RULES = [
1624
+ cartesianProduct,
1625
+ seqScanLarge,
1626
+ nestedLoopLargeOuter,
1627
+ highFilterDiscard,
1628
+ sortSpillDisk,
1629
+ hashSpillDisk,
1630
+ correlatedSubplan,
1631
+ rowMisestimate,
1632
+ filterCouldBeIndexCond,
1633
+ couldBeIndexOnly,
1634
+ indexOnlyHeapFetches,
1635
+ bitmapLossy,
1636
+ workersNotLaunched,
1637
+ lowCacheHit,
1638
+ significantJit,
1639
+ triggerTime
1640
+ ];
1641
+
1642
+ // src/advisor/index.ts
1643
+ function runAdvisor(tree, config = DEFAULT_CONFIG) {
1644
+ const ctx = {
1645
+ tree,
1646
+ thresholds: config.thresholds,
1647
+ severityOf: (id, fallback) => config.rules[id]?.severity ?? fallback,
1648
+ isEnabled: (id) => config.rules[id]?.enabled !== false
1649
+ };
1650
+ const nodes = flatten(tree.root);
1651
+ const diagnostics = [];
1652
+ for (const rule of ALL_RULES) {
1653
+ if (!ctx.isEnabled(rule.id)) continue;
1654
+ if (rule.requiresAnalyze && !tree.hasAnalyze) continue;
1655
+ if (rule.requiresBuffers && !tree.hasBuffers) continue;
1656
+ for (const node of nodes) {
1657
+ for (const finding of rule.check(node, ctx)) diagnostics.push(finding);
1658
+ }
1659
+ }
1660
+ diagnostics.sort(bySeverity);
1661
+ let worst = null;
1662
+ for (const d of diagnostics) worst = worst === null ? d.severity : maxSeverity(worst, d.severity);
1663
+ const bn = bottlenecks(tree, 5);
1664
+ return {
1665
+ tree,
1666
+ diagnostics,
1667
+ bottlenecks: bn,
1668
+ verdict: buildVerdict(tree, diagnostics, bn),
1669
+ worstSeverity: worst
1670
+ };
1671
+ }
1672
+ function buildVerdict(tree, diagnostics, bn) {
1673
+ const counts = { error: 0, warn: 0, info: 0 };
1674
+ for (const d of diagnostics) counts[d.severity]++;
1675
+ const parts = [];
1676
+ if (counts.error) parts.push(`${counts.error} critical`);
1677
+ if (counts.warn) parts.push(`${counts.warn} warning${counts.warn > 1 ? "s" : ""}`);
1678
+ if (counts.info) parts.push(`${counts.info} note${counts.info > 1 ? "s" : ""}`);
1679
+ const findings = parts.length ? parts.join(", ") : "no issues found";
1680
+ const top = bn[0];
1681
+ let bottleneck = "";
1682
+ if (top?.metrics.selfMs !== void 0) {
1683
+ const pct = top.metrics.pctOfTotal !== void 0 ? ` (${top.metrics.pctOfTotal.toFixed(0)}% of time)` : "";
1684
+ bottleneck = ` \u2014 top cost: ${nodeLabel(top)}${pct}`;
1685
+ }
1686
+ const ms = executionMs(tree);
1687
+ const timing = ms !== void 0 ? ` Total ${fmtMs(ms)}.` : " Cost-only plan (no timing).";
1688
+ return `${findings}${bottleneck}.${timing}`;
1689
+ }
1690
+
1691
+ // src/input/redact.ts
1692
+ function redactExpression(expr) {
1693
+ return expr.replace(/'(?:[^']|'')*'/g, "'?'").replace(/\b\d+(?:\.\d+)?\b/g, "N");
1694
+ }
1695
+ var EXPR_FIELDS = [
1696
+ "filter",
1697
+ "indexCond",
1698
+ "recheckCond",
1699
+ "hashCond",
1700
+ "joinFilter"
1701
+ ];
1702
+ function redactNode(node) {
1703
+ for (const field of EXPR_FIELDS) {
1704
+ const value = node[field];
1705
+ if (typeof value === "string") node[field] = redactExpression(value);
1706
+ }
1707
+ if (node.output) node.output = node.output.map(redactExpression);
1708
+ if (node.sortKey) node.sortKey = node.sortKey.map(redactExpression);
1709
+ }
1710
+ function redactPlanTree(tree) {
1711
+ walk(tree.root, redactNode);
1712
+ }
1713
+
1714
+ // src/report/tree.ts
1715
+ function treeLines(tree, glyphs) {
1716
+ const lines = [];
1717
+ const recurse = (node, indent, isLast, isRoot) => {
1718
+ const connector = isRoot ? "" : isLast ? glyphs.last : glyphs.branch;
1719
+ lines.push({ node, prefix: indent + connector });
1720
+ const childIndent = isRoot ? "" : indent + (isLast ? glyphs.space : glyphs.vert);
1721
+ node.children.forEach((child, i) => {
1722
+ recurse(child, childIndent, i === node.children.length - 1, false);
1723
+ });
1724
+ };
1725
+ recurse(tree.root, "", true, true);
1726
+ return lines;
1727
+ }
1728
+ function nodeSummary(node) {
1729
+ const m = node.metrics;
1730
+ const parts = [];
1731
+ if (m.totalRows !== void 0) {
1732
+ let rows = `rows=${fmtInt(m.totalRows)}`;
1733
+ if (m.estimateFactor !== void 0 && m.estimateFactor >= 2 && m.estimateDirection !== "accurate") {
1734
+ rows += ` (est ${fmtInt(node.planRows)}, ${m.estimateFactor.toFixed(0)}\xD7 ${m.estimateDirection})`;
1735
+ }
1736
+ parts.push(rows);
1737
+ } else {
1738
+ parts.push(`rows\u2248${fmtInt(node.planRows)} est`);
1739
+ }
1740
+ if (m.selfMs !== void 0) {
1741
+ let t = `self ${fmtMs(m.selfMs)}`;
1742
+ if (m.pctOfTotal !== void 0 && m.pctOfTotal >= 1) t += ` (${m.pctOfTotal.toFixed(0)}%)`;
1743
+ parts.push(t);
1744
+ }
1745
+ if (node.metrics.cacheHitRatio != null) {
1746
+ parts.push(`cache ${(node.metrics.cacheHitRatio * 100).toFixed(0)}%`);
1747
+ }
1748
+ return parts.join(" \xB7 ");
1749
+ }
1750
+
1751
+ // src/report/json.ts
1752
+ var JSON_SCHEMA_VERSION = 1;
1753
+ function renderJson(result, pretty = true) {
1754
+ const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
1755
+ const counts = { error: 0, warn: 0, info: 0 };
1756
+ for (const d of diagnostics) counts[d.severity]++;
1757
+ const report = {
1758
+ schemaVersion: JSON_SCHEMA_VERSION,
1759
+ verdict: result.verdict,
1760
+ worstSeverity: result.worstSeverity,
1761
+ summary: {
1762
+ planningTimeMs: tree.planningTime ?? null,
1763
+ executionTimeMs: executionMs(tree) ?? null,
1764
+ hasAnalyze: tree.hasAnalyze,
1765
+ hasBuffers: tree.hasBuffers,
1766
+ nodeCount: flatten(tree.root).length,
1767
+ findings: counts
1768
+ },
1769
+ diagnostics,
1770
+ bottlenecks: bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0).map((n) => ({
1771
+ id: n.id,
1772
+ label: nodeLabel(n),
1773
+ nodeType: n.nodeType,
1774
+ relation: n.relationName ?? null,
1775
+ selfMs: n.metrics.selfMs ?? null,
1776
+ pctOfTotal: n.metrics.pctOfTotal ?? null,
1777
+ totalRows: n.metrics.totalRows ?? null
1778
+ })),
1779
+ plan: serializeNode(tree.root)
1780
+ };
1781
+ return JSON.stringify(report, null, pretty ? 2 : 0);
1782
+ }
1783
+ function serializeNode(node) {
1784
+ const { children, metrics, raw, ...fields } = node;
1785
+ return { ...fields, metrics, children: children.map(serializeNode) };
1786
+ }
1787
+
1788
+ // src/report/html.ts
1789
+ var SEV = {
1790
+ error: { label: "Critical", cls: "sev-error" },
1791
+ warn: { label: "Warning", cls: "sev-warn" },
1792
+ info: { label: "Note", cls: "sev-info" }
1793
+ };
1794
+ function esc(s) {
1795
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1796
+ }
1797
+ function renderHtml(result) {
1798
+ const { tree, diagnostics } = result;
1799
+ const ms = executionMs(tree);
1800
+ const treeHtml = treeLines(tree, UNICODE_TREE).map(({ node, prefix }) => {
1801
+ const pct = node.metrics.pctOfTotal ?? 0;
1802
+ const heat2 = pct >= 50 ? "hot" : pct >= 20 ? "warm" : pct >= 5 ? "" : "cold";
1803
+ return `<div class="node ${heat2}"><span class="glyph">${esc(prefix)}</span><span class="label">${esc(nodeLabel(node))}</span> <span class="meta">${esc(nodeSummary(node))}</span></div>`;
1804
+ }).join("\n");
1805
+ const findingsHtml = diagnostics.length ? diagnostics.map(findingHtml).join("\n") : '<p class="ok">No anti-patterns detected. \u{1F389}</p>';
1806
+ return `<!doctype html>
1807
+ <html lang="en">
1808
+ <head>
1809
+ <meta charset="utf-8">
1810
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1811
+ <title>pg-explain report</title>
1812
+ <style>
1813
+ :root { color-scheme: light dark; }
1814
+ body { font: 15px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 2rem; max-width: 980px; margin-inline: auto; }
1815
+ h1 { font-size: 1.4rem; } h2 { font-size: 1.1rem; margin-top: 2rem; border-bottom: 1px solid #8884; padding-bottom: .3rem; }
1816
+ .verdict { padding: .75rem 1rem; border-left: 4px solid #888; background: #8881; border-radius: 4px; }
1817
+ .verdict.sev-error { border-color: #e5484d; } .verdict.sev-warn { border-color: #f5a623; } .verdict.sev-info { border-color: #4493f8; }
1818
+ table { border-collapse: collapse; width: 100%; } td, th { text-align: left; padding: .3rem .6rem; border-bottom: 1px solid #8883; }
1819
+ .tree { overflow-x: auto; } .node { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; font-size: 13px; }
1820
+ .glyph { color: #8888; } .meta { color: #888; }
1821
+ .node.hot .label { color: #e5484d; font-weight: 700; } .node.warm .label { color: #f5a623; } .node.cold .label { opacity: .6; }
1822
+ .finding { border: 1px solid #8883; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
1823
+ .finding .tag { font-size: .75rem; font-weight: 700; padding: .1rem .5rem; border-radius: 3px; color: #fff; }
1824
+ .sev-error .tag { background: #e5484d; } .sev-warn .tag { background: #f5a623; } .sev-info .tag { background: #4493f8; }
1825
+ .finding code, pre { font-family: ui-monospace, monospace; font-size: 13px; }
1826
+ pre { background: #8881; padding: .6rem .8rem; border-radius: 4px; overflow-x: auto; }
1827
+ .label-cmd { color: #888; font-size: .85rem; margin-top: .5rem; }
1828
+ .ok { color: #2e7d32; }
1829
+ </style>
1830
+ </head>
1831
+ <body>
1832
+ <h1>pg-explain report</h1>
1833
+ <div class="verdict ${result.worstSeverity ? SEV[result.worstSeverity].cls : ""}">${esc(result.verdict)}</div>
1834
+
1835
+ <h2>Summary</h2>
1836
+ <table>
1837
+ ${tree.planningTime !== void 0 ? `<tr><th>Planning time</th><td>${esc(fmtMs(tree.planningTime))}</td></tr>` : ""}
1838
+ ${ms !== void 0 ? `<tr><th>Execution time</th><td>${esc(fmtMs(ms))}</td></tr>` : ""}
1839
+ ${!tree.hasAnalyze ? "<tr><th>Mode</th><td>cost-only (no ANALYZE)</td></tr>" : ""}
1840
+ <tr><th>Findings</th><td>${diagnostics.length}</td></tr>
1841
+ </table>
1842
+
1843
+ <h2>Plan tree</h2>
1844
+ <div class="tree">
1845
+ ${treeHtml}
1846
+ </div>
1847
+
1848
+ <h2>Findings</h2>
1849
+ ${findingsHtml}
1850
+ </body>
1851
+ </html>
1852
+ `;
1853
+ }
1854
+ function findingHtml(d) {
1855
+ const sev = SEV[d.severity];
1856
+ const steps = d.remediation.steps?.length ? `<ul>${d.remediation.steps.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>` : "";
1857
+ const cmds = (d.remediation.commands ?? []).map((c) => {
1858
+ const body = c.sql ?? c.shell ?? "";
1859
+ const label = c.label ? `<div class="label-cmd">${esc(c.label)}</div>` : "";
1860
+ return `${label}<pre><code>${esc(body)}</code></pre>`;
1861
+ }).join("");
1862
+ const docs = d.docsUrl ? `<p>\u{1F4D6} <a href="${esc(d.docsUrl)}">PostgreSQL docs</a></p>` : "";
1863
+ const meta = d.location?.relation ? ` <span class="meta">on ${esc(d.location.relation)}</span>` : "";
1864
+ return `<div class="finding ${sev.cls}">
1865
+ <p><span class="tag">${sev.label}</span> <strong>${esc(d.title)}</strong> <code>${esc(d.code)}</code>${meta}</p>
1866
+ <p><strong>What:</strong> ${esc(d.detail)}</p>
1867
+ <p><strong>Why:</strong> ${esc(d.cause)}</p>
1868
+ <p><strong>Fix:</strong> ${esc(d.remediation.summary)}</p>
1869
+ ${steps}
1870
+ ${cmds}
1871
+ ${docs}
1872
+ </div>`;
1873
+ }
1874
+
1875
+ // src/report/markdown.ts
1876
+ var SEV_LABEL = {
1877
+ error: "\u{1F534} Critical",
1878
+ warn: "\u{1F7E0} Warning",
1879
+ info: "\u{1F535} Note"
1880
+ };
1881
+ function renderMarkdown(result, opts = {}) {
1882
+ const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
1883
+ const out = [];
1884
+ out.push("# pg-explain report", "");
1885
+ out.push(`> **Verdict:** ${result.verdict}`, "");
1886
+ out.push("## Summary", "");
1887
+ const ms = executionMs(tree);
1888
+ out.push("| Metric | Value |", "| --- | --- |");
1889
+ if (tree.planningTime !== void 0) out.push(`| Planning time | ${fmtMs(tree.planningTime)} |`);
1890
+ if (ms !== void 0) out.push(`| Execution time | ${fmtMs(ms)} |`);
1891
+ if (!tree.hasAnalyze) out.push("| Mode | cost-only (no ANALYZE) |");
1892
+ out.push(`| Findings | ${summarizeCounts(diagnostics)} |`, "");
1893
+ if (opts.tldr) {
1894
+ out.push(...renderFindings(diagnostics, true));
1895
+ return `${out.join("\n").trimEnd()}
1896
+ `;
1897
+ }
1898
+ out.push("## Plan tree", "", "```");
1899
+ for (const { node, prefix } of treeLines(tree, UNICODE_TREE)) {
1900
+ out.push(`${prefix}${nodeLabel(node)} \u2014 ${nodeSummary(node)}`);
1901
+ }
1902
+ out.push("```", "");
1903
+ const ranked = bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0);
1904
+ if (ranked.length) {
1905
+ out.push("## Bottlenecks (by self time)", "");
1906
+ out.push("| # | Node | Self time | % of total | Rows |", "| --- | --- | --- | --- | --- |");
1907
+ ranked.forEach((node, i) => {
1908
+ const pct = node.metrics.pctOfTotal !== void 0 ? `${node.metrics.pctOfTotal.toFixed(1)}%` : "\u2014";
1909
+ const rows = node.metrics.totalRows !== void 0 ? fmtInt(node.metrics.totalRows) : "\u2014";
1910
+ out.push(
1911
+ `| ${i + 1} | ${nodeLabel(node)} | ${fmtMs(node.metrics.selfMs ?? 0)} | ${pct} | ${rows} |`
1912
+ );
1913
+ });
1914
+ out.push("");
1915
+ }
1916
+ out.push(...renderFindings(diagnostics, false));
1917
+ return `${out.join("\n").trimEnd()}
1918
+ `;
1919
+ }
1920
+ function renderFindings(diagnostics, tldr) {
1921
+ const out = ["## Findings", ""];
1922
+ if (diagnostics.length === 0) {
1923
+ out.push("No anti-patterns detected. \u{1F389}", "");
1924
+ return out;
1925
+ }
1926
+ for (const d of diagnostics) {
1927
+ out.push(`### ${SEV_LABEL[d.severity]} \u2014 ${d.title}`, "");
1928
+ out.push(`\`${d.code}\``, "");
1929
+ out.push(`**What:** ${d.detail}`, "");
1930
+ out.push(`**Why:** ${d.cause}`, "");
1931
+ out.push(`**Fix:** ${d.remediation.summary}`, "");
1932
+ if (!tldr) {
1933
+ if (d.remediation.steps?.length) {
1934
+ for (const step of d.remediation.steps) out.push(`- ${step}`);
1935
+ out.push("");
1936
+ }
1937
+ for (const cmd of d.remediation.commands ?? []) {
1938
+ const body = cmd.sql ?? cmd.shell ?? "";
1939
+ const lang = cmd.sql ? "sql" : "sh";
1940
+ if (cmd.label) out.push(`_${cmd.label}:_`);
1941
+ out.push("```" + lang, body, "```", "");
1942
+ }
1943
+ if (d.docsUrl) out.push(`\u{1F4D6} [PostgreSQL docs](${d.docsUrl})`, "");
1944
+ }
1945
+ }
1946
+ return out;
1947
+ }
1948
+ function summarizeCounts(diagnostics) {
1949
+ const counts = { error: 0, warn: 0, info: 0 };
1950
+ for (const d of diagnostics) counts[d.severity]++;
1951
+ if (diagnostics.length === 0) return "none";
1952
+ return `${counts.error} critical, ${counts.warn} warning(s), ${counts.info} note(s)`;
1953
+ }
1954
+ var { createColors, isColorSupported } = pc;
1955
+ var active = createColors(isColorSupported);
1956
+ function configureColor(mode) {
1957
+ active = createColors(mode === "always" || mode === "auto" && isColorSupported);
1958
+ }
1959
+ function colors() {
1960
+ return active;
1961
+ }
1962
+
1963
+ // src/report/terminal.ts
1964
+ var SEV_TAG = { error: "CRITICAL", warn: "WARNING", info: "NOTE" };
1965
+ function sevColor(sev, text) {
1966
+ const c = colors();
1967
+ if (sev === "error") return c.red(c.bold(text));
1968
+ if (sev === "warn") return c.yellow(text);
1969
+ return c.cyan(text);
1970
+ }
1971
+ function heat(node, text) {
1972
+ const c = colors();
1973
+ const pct = node.metrics.pctOfTotal;
1974
+ if (pct === void 0) return text;
1975
+ if (pct >= 50) return c.red(c.bold(text));
1976
+ if (pct >= 20) return c.yellow(text);
1977
+ if (pct >= 5) return text;
1978
+ return c.dim(text);
1979
+ }
1980
+ function bar(pct, width = 8) {
1981
+ const filled = Math.round(pct / 100 * width);
1982
+ return "\u2587".repeat(Math.min(filled, width)) + "\u2581".repeat(Math.max(width - filled, 0));
1983
+ }
1984
+ function renderTerminal(result, opts = {}) {
1985
+ const c = colors();
1986
+ const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
1987
+ const glyphs = opts.ascii ? ASCII_TREE : UNICODE_TREE;
1988
+ const out = [];
1989
+ out.push(c.bold("pg-explain report"));
1990
+ out.push(`${c.bold("Verdict:")} ${verdictColored(result)}`);
1991
+ out.push("");
1992
+ if (opts.tldr) {
1993
+ out.push(...findingsBlock(diagnostics, opts));
1994
+ return `${out.join("\n").trimEnd()}
1995
+ `;
1996
+ }
1997
+ out.push(c.bold("Plan tree"));
1998
+ for (const { node, prefix } of treeLines(tree, glyphs)) {
1999
+ const heatBar = opts.bars !== false && node.metrics.pctOfTotal !== void 0 ? ` ${c.dim(bar(node.metrics.pctOfTotal))}` : "";
2000
+ out.push(
2001
+ `${c.dim(prefix)}${heat(node, nodeLabel(node))}${heatBar} ${c.dim(nodeSummary(node))}`
2002
+ );
2003
+ }
2004
+ out.push("");
2005
+ const ranked = bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0);
2006
+ if (ranked.length) {
2007
+ out.push(c.bold("Bottlenecks (by self time)"));
2008
+ ranked.forEach((node, i) => {
2009
+ const pct = node.metrics.pctOfTotal !== void 0 ? `${node.metrics.pctOfTotal.toFixed(0)}%` : "\u2014";
2010
+ out.push(
2011
+ ` ${i + 1}. ${heat(node, nodeLabel(node))} \u2014 ${fmtMs(node.metrics.selfMs ?? 0)} (${pct})`
2012
+ );
2013
+ });
2014
+ out.push("");
2015
+ }
2016
+ out.push(...findingsBlock(diagnostics, opts));
2017
+ const ms = executionMs(tree);
2018
+ if (ms !== void 0) out.push(c.dim(`Total execution time: ${fmtMs(ms)}`));
2019
+ return `${out.join("\n").trimEnd()}
2020
+ `;
2021
+ }
2022
+ function verdictColored(result) {
2023
+ if (result.worstSeverity === null) return colors().green(result.verdict);
2024
+ return sevColor(result.worstSeverity, result.verdict);
2025
+ }
2026
+ function findingsBlock(diagnostics, opts) {
2027
+ const c = colors();
2028
+ const out = [c.bold("Findings")];
2029
+ if (diagnostics.length === 0) {
2030
+ out.push(` ${c.green("No anti-patterns detected.")}`, "");
2031
+ return out;
2032
+ }
2033
+ for (const d of diagnostics) {
2034
+ out.push("");
2035
+ out.push(
2036
+ `${sevColor(d.severity, `[${SEV_TAG[d.severity]}]`)} ${c.bold(d.title)} ${c.dim(d.code)}`
2037
+ );
2038
+ out.push(` ${c.dim("What:")} ${d.detail}`);
2039
+ out.push(` ${c.dim("Why: ")} ${d.cause}`);
2040
+ out.push(` ${c.dim("Fix: ")} ${d.remediation.summary}`);
2041
+ if (!opts.tldr) {
2042
+ for (const step of d.remediation.steps ?? []) out.push(` - ${step}`);
2043
+ for (const cmd of d.remediation.commands ?? []) {
2044
+ const body = cmd.sql ?? cmd.shell ?? "";
2045
+ const label = cmd.label ? `${c.dim(`${cmd.label}:`)} ` : "";
2046
+ out.push(` ${label}${c.green(body)}`);
2047
+ }
2048
+ if (d.docsUrl) out.push(` ${c.dim(`docs: ${d.docsUrl}`)}`);
2049
+ }
2050
+ }
2051
+ out.push("");
2052
+ return out;
2053
+ }
2054
+
2055
+ // src/report/render.ts
2056
+ var FORMATS = ["terminal", "markdown", "json", "html", "text"];
2057
+ function isFormat(s) {
2058
+ return FORMATS.includes(s);
2059
+ }
2060
+ function render(result, opts) {
2061
+ switch (opts.format) {
2062
+ case "markdown":
2063
+ return renderMarkdown(result, { tldr: opts.tldr });
2064
+ case "json":
2065
+ return renderJson(result, opts.pretty ?? true);
2066
+ case "html":
2067
+ return renderHtml(result);
2068
+ case "text":
2069
+ return renderTerminal(result, { ascii: true, bars: false, tldr: opts.tldr });
2070
+ default:
2071
+ return renderTerminal(result, { ascii: opts.ascii, tldr: opts.tldr });
2072
+ }
2073
+ }
2074
+
2075
+ // src/index.ts
2076
+ function analyze(input, options = {}) {
2077
+ const trees = parseExplainJson(input);
2078
+ const tree = selectStatement(trees, options.statement);
2079
+ if (options.redact) redactPlanTree(tree);
2080
+ computeMetrics(tree);
2081
+ const result = runAdvisor(tree, options.config ?? DEFAULT_CONFIG);
2082
+ const notices = planNotices(tree);
2083
+ if (notices.length) {
2084
+ result.diagnostics = [...result.diagnostics, ...notices].sort(bySeverity);
2085
+ result.worstSeverity = result.diagnostics.reduce(
2086
+ (worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
2087
+ null
2088
+ );
2089
+ }
2090
+ return result;
2091
+ }
2092
+ function selectStatement(trees, statement) {
2093
+ if (statement !== void 0) {
2094
+ const tree = trees[statement - 1];
2095
+ if (!tree) {
2096
+ throw opError("PGX_MULTIPLE_STATEMENTS", {
2097
+ detail: `--statement ${statement} is out of range; the input has ${trees.length} statement(s).`
2098
+ });
2099
+ }
2100
+ return tree;
2101
+ }
2102
+ const first = trees[0];
2103
+ if (!first) throw opError("PGX_UNEXPECTED_PLAN_SHAPE");
2104
+ return first;
2105
+ }
2106
+ function planNotices(tree) {
2107
+ const notices = [];
2108
+ if (!tree.hasAnalyze) notices.push(opDiagnostic("PGX_COST_ONLY_PLAN"));
2109
+ else if (!tree.hasBuffers) notices.push(opDiagnostic("PGX_NO_BUFFERS"));
2110
+ const nodes = flatten(tree.root);
2111
+ const trivial = nodes.length === 1 && /^(Result|Values? Scan)$/.test(tree.root.nodeType);
2112
+ if (trivial) notices.push(opDiagnostic("PGX_EMPTY_PLAN"));
2113
+ return notices;
2114
+ }
2115
+
2116
+ // src/input/stdin.ts
2117
+ function readStdin(timeoutMs = 3e4) {
2118
+ if (process.stdin.isTTY) return Promise.resolve("");
2119
+ return new Promise((resolve, reject) => {
2120
+ const chunks = [];
2121
+ const timer = setTimeout(() => {
2122
+ process.stdin.pause();
2123
+ reject(new Error(`Timed out after ${timeoutMs}ms waiting for stdin`));
2124
+ }, timeoutMs);
2125
+ process.stdin.on("data", (c) => chunks.push(c)).on("end", () => {
2126
+ clearTimeout(timer);
2127
+ resolve(Buffer.concat(chunks).toString("utf8"));
2128
+ }).on("error", (err) => {
2129
+ clearTimeout(timer);
2130
+ reject(err);
2131
+ });
2132
+ });
2133
+ }
2134
+
2135
+ // src/input/source.ts
2136
+ async function resolvePlanInput(file) {
2137
+ if (file) {
2138
+ let text2;
2139
+ try {
2140
+ text2 = await readFile(file, "utf8");
2141
+ } catch (err) {
2142
+ const msg = err instanceof Error ? err.message : String(err);
2143
+ throw opError(
2144
+ "PGX_EMPTY_INPUT",
2145
+ { detail: `Could not read file '${file}': ${msg}`, location: { kind: "input" } },
2146
+ err
2147
+ );
2148
+ }
2149
+ if (!text2.trim()) {
2150
+ throw opError("PGX_EMPTY_INPUT", {
2151
+ detail: `File '${file}' is empty.`,
2152
+ location: { kind: "input" }
2153
+ });
2154
+ }
2155
+ return text2;
2156
+ }
2157
+ if (process.stdin.isTTY) {
2158
+ throw opError("PGX_EMPTY_INPUT", {
2159
+ detail: "No --file given and stdin is a terminal (nothing was piped in)."
2160
+ });
2161
+ }
2162
+ const text = await readStdin();
2163
+ if (!text.trim()) throw opError("PGX_EMPTY_INPUT");
2164
+ return text;
2165
+ }
2166
+ async function emit(result, opts) {
2167
+ configureColor(opts.format === "terminal" ? opts.color : "never");
2168
+ const text = render(result, {
2169
+ format: opts.format,
2170
+ tldr: opts.tldr,
2171
+ ascii: opts.ascii,
2172
+ pretty: opts.pretty
2173
+ });
2174
+ if (opts.output) {
2175
+ await writeFile(opts.output, text);
2176
+ } else {
2177
+ process.stdout.write(text.endsWith("\n") ? text : `${text}
2178
+ `);
2179
+ }
2180
+ return gateExit(result, opts.failOn);
2181
+ }
2182
+ function gateExit(result, failOn) {
2183
+ if (!failOn || result.worstSeverity === null) return 0 /* Success */;
2184
+ return severityAtLeast(result.worstSeverity, failOn) ? 1 /* CiGate */ : 0 /* Success */;
2185
+ }
2186
+
2187
+ // src/commands/analyze.ts
2188
+ async function runAnalyze(args) {
2189
+ if (args.file && await isDirectory(args.file)) return runBatch(args, args.file);
2190
+ const text = await resolvePlanInput(args.file);
2191
+ const result = analyze(text, {
2192
+ config: args.config,
2193
+ statement: args.statement,
2194
+ redact: args.redact
2195
+ });
2196
+ return emit(result, args);
2197
+ }
2198
+ async function isDirectory(path) {
2199
+ try {
2200
+ return (await stat(path)).isDirectory();
2201
+ } catch {
2202
+ return false;
2203
+ }
2204
+ }
2205
+ async function runBatch(args, dir) {
2206
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".json")).sort();
2207
+ if (!files.length) {
2208
+ throw opError("PGX_EMPTY_INPUT", {
2209
+ detail: `No .json plan files found in directory '${dir}'.`
2210
+ });
2211
+ }
2212
+ configureColor(args.format === "terminal" ? args.color : "never");
2213
+ const jsonReports = [];
2214
+ const textReports = [];
2215
+ let worst = 0 /* Success */;
2216
+ for (const name of files) {
2217
+ const text = await readFile(join(dir, name), "utf8");
2218
+ let result;
2219
+ try {
2220
+ result = analyze(text, { config: args.config, redact: args.redact });
2221
+ } catch (err) {
2222
+ process.stderr.write(
2223
+ `skipping ${name}: ${err instanceof Error ? err.message : String(err)}
2224
+ `
2225
+ );
2226
+ worst = 4 /* Parse */;
2227
+ continue;
2228
+ }
2229
+ if (gateTrips(result, args.failOn)) worst = 1 /* CiGate */;
2230
+ const body = render(result, {
2231
+ format: args.format,
2232
+ tldr: args.tldr,
2233
+ ascii: args.ascii,
2234
+ pretty: args.pretty
2235
+ });
2236
+ if (args.format === "json") jsonReports.push({ file: name, report: JSON.parse(body) });
2237
+ else textReports.push(`
2238
+ ${"=".repeat(60)}
2239
+ ${name}
2240
+ ${"=".repeat(60)}
2241
+ ${body}`);
2242
+ }
2243
+ const out = args.format === "json" ? `${JSON.stringify(jsonReports, null, args.pretty ? 2 : 0)}
2244
+ ` : `${textReports.join("\n")}
2245
+ `;
2246
+ if (args.output) await writeFile(args.output, out);
2247
+ else process.stdout.write(out);
2248
+ return worst;
2249
+ }
2250
+ function gateTrips(result, failOn) {
2251
+ if (!failOn || result.worstSeverity === null) return false;
2252
+ return severityAtLeast(result.worstSeverity, failOn);
2253
+ }
2254
+
2255
+ // src/commands/completion.ts
2256
+ var SUBCOMMANDS = "run diff completion";
2257
+ var FLAGS = "--format --output --tldr --redact --ascii --color --no-color --fail-on --strict --config --statement --quiet --verbose --debug --help --version";
2258
+ var FORMATS2 = "terminal markdown json html text";
2259
+ var BASH = `# pg-explain bash completion
2260
+ _pg_explain() {
2261
+ local cur prev
2262
+ cur="\${COMP_WORDS[COMP_CWORD]}"
2263
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
2264
+ if [ "$prev" = "--format" ] || [ "$prev" = "-f" ]; then
2265
+ COMPREPLY=( $(compgen -W "${FORMATS2}" -- "$cur") ); return
2266
+ fi
2267
+ if [ "$COMP_CWORD" -eq 1 ]; then
2268
+ COMPREPLY=( $(compgen -W "${SUBCOMMANDS} ${FLAGS}" -f -- "$cur") ); return
2269
+ fi
2270
+ COMPREPLY=( $(compgen -W "${FLAGS}" -f -- "$cur") )
2271
+ }
2272
+ complete -F _pg_explain pg-explain
2273
+ `;
2274
+ var ZSH = `#compdef pg-explain
2275
+ # pg-explain zsh completion
2276
+ _pg_explain() {
2277
+ local -a subcmds flags
2278
+ subcmds=(${SUBCOMMANDS.split(" ").map((s) => `'${s}'`).join(" ")})
2279
+ flags=(${FLAGS.split(" ").map((f) => `'${f}'`).join(" ")})
2280
+ if (( CURRENT == 2 )); then
2281
+ _alternative "subcmds:subcommand:(\${subcmds})" 'files:file:_files' "flags:flag:(\${flags})"
2282
+ else
2283
+ _alternative 'files:file:_files' "flags:flag:(\${flags})"
2284
+ fi
2285
+ }
2286
+ compdef _pg_explain pg-explain
2287
+ `;
2288
+ var FISH = `# pg-explain fish completion
2289
+ complete -c pg-explain -f
2290
+ ${SUBCOMMANDS.split(" ").map((s) => `complete -c pg-explain -n '__fish_use_subcommand' -a '${s}'`).join("\n")}
2291
+ complete -c pg-explain -l format -x -a '${FORMATS2}'
2292
+ ${FLAGS.split(" ").filter((f) => f.startsWith("--")).map((f) => `complete -c pg-explain -l '${f.replace(/^--/, "")}'`).join("\n")}
2293
+ `;
2294
+ function runCompletion(shell) {
2295
+ const scripts = { bash: BASH, zsh: ZSH, fish: FISH };
2296
+ const script = shell ? scripts[shell] : void 0;
2297
+ if (!script) {
2298
+ process.stderr.write(
2299
+ `Usage: pg-explain completion <bash|zsh|fish>
2300
+ bash: pg-explain completion bash > /etc/bash_completion.d/pg-explain
2301
+ zsh: pg-explain completion zsh > "\${fpath[1]}/_pg-explain"
2302
+ fish: pg-explain completion fish > ~/.config/fish/completions/pg-explain.fish
2303
+ `
2304
+ );
2305
+ return 2 /* Usage */;
2306
+ }
2307
+ process.stdout.write(script);
2308
+ return 0 /* Success */;
2309
+ }
2310
+
2311
+ // src/core/diff.ts
2312
+ function weight(node) {
2313
+ if (node.metrics.selfMs !== void 0) return node.metrics.selfMs;
2314
+ const own = node.totalCost ?? 0;
2315
+ let children = 0;
2316
+ for (const c of node.children) children += c.totalCost ?? 0;
2317
+ return Math.max(own - children, 0);
2318
+ }
2319
+ function signature(node) {
2320
+ return nodeLabel(node);
2321
+ }
2322
+ function weightBySignature(result) {
2323
+ const map = /* @__PURE__ */ new Map();
2324
+ walk(result.tree.root, (node) => {
2325
+ const key = signature(node);
2326
+ map.set(key, (map.get(key) ?? 0) + weight(node));
2327
+ });
2328
+ return map;
2329
+ }
2330
+ function findingKey(d) {
2331
+ return `${d.code}|${d.location?.relation ?? ""}`;
2332
+ }
2333
+ function diffAnalyses(before, after) {
2334
+ const beforeMs = executionMs(before.tree);
2335
+ const afterMs = executionMs(after.tree);
2336
+ const timed = before.tree.hasAnalyze && after.tree.hasAnalyze;
2337
+ let execDeltaMs;
2338
+ let execDeltaPct;
2339
+ if (beforeMs !== void 0 && afterMs !== void 0) {
2340
+ execDeltaMs = afterMs - beforeMs;
2341
+ execDeltaPct = beforeMs > 0 ? 100 * execDeltaMs / beforeMs : void 0;
2342
+ }
2343
+ const beforeMap = weightBySignature(before);
2344
+ const afterMap = weightBySignature(after);
2345
+ const keys = /* @__PURE__ */ new Set([...beforeMap.keys(), ...afterMap.keys()]);
2346
+ const regressed = [];
2347
+ const improved = [];
2348
+ const added = [];
2349
+ const removed = [];
2350
+ for (const key of keys) {
2351
+ const b = beforeMap.get(key);
2352
+ const a = afterMap.get(key);
2353
+ const beforeVal = b ?? 0;
2354
+ const afterVal = a ?? 0;
2355
+ const deltaMs = afterVal - beforeVal;
2356
+ const deltaPct = beforeVal > 0 ? 100 * deltaMs / beforeVal : null;
2357
+ const entry = {
2358
+ signature: key,
2359
+ beforeMs: beforeVal,
2360
+ afterMs: afterVal,
2361
+ deltaMs,
2362
+ deltaPct
2363
+ };
2364
+ if (b === void 0) added.push(entry);
2365
+ else if (a === void 0) removed.push(entry);
2366
+ else if (deltaMs > 1e-4) regressed.push(entry);
2367
+ else if (deltaMs < -1e-4) improved.push(entry);
2368
+ }
2369
+ regressed.sort((x, y) => y.deltaMs - x.deltaMs);
2370
+ improved.sort((x, y) => x.deltaMs - y.deltaMs);
2371
+ added.sort((x, y) => y.afterMs - x.afterMs);
2372
+ removed.sort((x, y) => y.beforeMs - x.beforeMs);
2373
+ const beforeKeys = new Set(before.diagnostics.map(findingKey));
2374
+ const afterKeys = new Set(after.diagnostics.map(findingKey));
2375
+ const newFindings = after.diagnostics.filter((d) => !beforeKeys.has(findingKey(d)));
2376
+ const resolvedFindings = before.diagnostics.filter((d) => !afterKeys.has(findingKey(d)));
2377
+ return {
2378
+ beforeMs,
2379
+ afterMs,
2380
+ execDeltaMs,
2381
+ execDeltaPct,
2382
+ timed,
2383
+ regressed,
2384
+ improved,
2385
+ added,
2386
+ removed,
2387
+ newFindings,
2388
+ resolvedFindings
2389
+ };
2390
+ }
2391
+
2392
+ // src/report/diff.ts
2393
+ function renderDiff(diff, format) {
2394
+ if (format === "json") return JSON.stringify(diff, null, 2);
2395
+ if (format === "markdown") return renderDiffMarkdown(diff);
2396
+ return renderDiffTerminal(diff);
2397
+ }
2398
+ function headline(diff) {
2399
+ if (diff.execDeltaMs === void 0)
2400
+ return "Compared plans (no timing available \u2014 using cost as a proxy).";
2401
+ const dir = diff.execDeltaMs > 0 ? "slower" : diff.execDeltaMs < 0 ? "faster" : "unchanged";
2402
+ const pct = diff.execDeltaPct !== void 0 ? ` (${diff.execDeltaPct >= 0 ? "+" : ""}${diff.execDeltaPct.toFixed(1)}%)` : "";
2403
+ return `${fmtMs(Math.abs(diff.execDeltaMs))} ${dir}${pct}: ${fmtMs(diff.beforeMs ?? 0)} \u2192 ${fmtMs(diff.afterMs ?? 0)}`;
2404
+ }
2405
+ function renderDiffTerminal(diff) {
2406
+ const c = colors();
2407
+ const out = [];
2408
+ out.push(c.bold("pg-explain diff (before \u2192 after)"));
2409
+ const slower = (diff.execDeltaMs ?? 0) > 0;
2410
+ out.push(`${c.bold("Verdict:")} ${slower ? c.red(headline(diff)) : c.green(headline(diff))}`);
2411
+ out.push("");
2412
+ section(out, "Regressed nodes (slower)", diff.regressed, (d) => c.red(deltaLine(d)));
2413
+ section(out, "Improved nodes (faster)", diff.improved, (d) => c.green(deltaLine(d)));
2414
+ section(out, "Added nodes", diff.added, (d) => `${d.signature} +${fmtMs(d.afterMs)}`);
2415
+ section(out, "Removed nodes", diff.removed, (d) => `${d.signature} -${fmtMs(d.beforeMs)}`);
2416
+ if (diff.newFindings.length) {
2417
+ out.push(c.bold(c.red("New findings")));
2418
+ for (const f of diff.newFindings) out.push(` + [${f.severity}] ${f.title} ${c.dim(f.code)}`);
2419
+ out.push("");
2420
+ }
2421
+ if (diff.resolvedFindings.length) {
2422
+ out.push(c.bold(c.green("Resolved findings")));
2423
+ for (const f of diff.resolvedFindings) out.push(` - ${f.title} ${c.dim(f.code)}`);
2424
+ out.push("");
2425
+ }
2426
+ return `${out.join("\n").trimEnd()}
2427
+ `;
2428
+ }
2429
+ function section(out, title, items, line) {
2430
+ if (!items.length) return;
2431
+ out.push(colors().bold(title));
2432
+ for (const d of items.slice(0, 10)) out.push(` ${line(d)}`);
2433
+ out.push("");
2434
+ }
2435
+ function deltaLine(d) {
2436
+ const pct = d.deltaPct !== null ? ` (${d.deltaPct >= 0 ? "+" : ""}${d.deltaPct.toFixed(0)}%)` : "";
2437
+ const sign = d.deltaMs >= 0 ? "+" : "-";
2438
+ return `${d.signature} ${sign}${fmtMs(Math.abs(d.deltaMs))}${pct} [${fmtMs(d.beforeMs)} \u2192 ${fmtMs(d.afterMs)}]`;
2439
+ }
2440
+ function renderDiffMarkdown(diff) {
2441
+ const out = ["# pg-explain diff", "", `> **${headline(diff)}**`, ""];
2442
+ const table = (title, items) => {
2443
+ if (!items.length) return;
2444
+ out.push(`## ${title}`, "", "| Node | Before | After | \u0394 |", "| --- | --- | --- | --- |");
2445
+ for (const d of items.slice(0, 20)) {
2446
+ const pct = d.deltaPct !== null ? ` (${d.deltaPct >= 0 ? "+" : ""}${d.deltaPct.toFixed(0)}%)` : "";
2447
+ out.push(
2448
+ `| ${d.signature} | ${fmtMs(d.beforeMs)} | ${fmtMs(d.afterMs)} | ${d.deltaMs >= 0 ? "+" : ""}${fmtMs(d.deltaMs)}${pct} |`
2449
+ );
2450
+ }
2451
+ out.push("");
2452
+ };
2453
+ table("Regressed (slower)", diff.regressed);
2454
+ table("Improved (faster)", diff.improved);
2455
+ table("Added", diff.added);
2456
+ table("Removed", diff.removed);
2457
+ if (diff.newFindings.length) {
2458
+ out.push("## New findings", "");
2459
+ for (const f of diff.newFindings) out.push(`- **${f.severity}** ${f.title} \`${f.code}\``);
2460
+ out.push("");
2461
+ }
2462
+ if (diff.resolvedFindings.length) {
2463
+ out.push("## Resolved findings", "");
2464
+ for (const f of diff.resolvedFindings) out.push(`- ${f.title} \`${f.code}\``);
2465
+ out.push("");
2466
+ }
2467
+ return `${out.join("\n").trimEnd()}
2468
+ `;
2469
+ }
2470
+
2471
+ // src/commands/diff.ts
2472
+ async function runDiff(args) {
2473
+ const [beforeText, afterText] = await Promise.all([readPlan(args.before), readPlan(args.after)]);
2474
+ const before = analyze(beforeText, { config: args.config, redact: args.redact });
2475
+ const after = analyze(afterText, { config: args.config, redact: args.redact });
2476
+ const diff = diffAnalyses(before, after);
2477
+ configureColor(args.format === "terminal" ? args.color : "never");
2478
+ const text = renderDiff(diff, args.format);
2479
+ if (args.output) await writeFile(args.output, text);
2480
+ else process.stdout.write(text.endsWith("\n") ? text : `${text}
2481
+ `);
2482
+ if (args.failOnNewFindings && diff.newFindings.length > 0) return 1 /* CiGate */;
2483
+ if (args.failOnSlowerPct !== void 0 && diff.execDeltaPct !== void 0 && diff.execDeltaPct >= args.failOnSlowerPct) {
2484
+ return 1 /* CiGate */;
2485
+ }
2486
+ return 0 /* Success */;
2487
+ }
2488
+ async function readPlan(path) {
2489
+ try {
2490
+ return await readFile(path, "utf8");
2491
+ } catch (err) {
2492
+ throw opError(
2493
+ "PGX_EMPTY_INPUT",
2494
+ {
2495
+ detail: `Could not read plan file '${path}': ${err instanceof Error ? err.message : String(err)}`
2496
+ },
2497
+ err
2498
+ );
2499
+ }
2500
+ }
2501
+
2502
+ // src/util/log.ts
2503
+ var level = "normal";
2504
+ function setLogLevel(l) {
2505
+ level = l;
2506
+ }
2507
+ function isDebug() {
2508
+ return level === "debug";
2509
+ }
2510
+ function write(msg) {
2511
+ process.stderr.write(`${msg}
2512
+ `);
2513
+ }
2514
+ function logInfo(msg) {
2515
+ if (level !== "quiet") write(msg);
2516
+ }
2517
+ function logVerbose(msg) {
2518
+ if (level === "verbose" || level === "debug") write(msg);
2519
+ }
2520
+ function logError(msg) {
2521
+ write(msg);
2522
+ }
2523
+
2524
+ // src/db/version.ts
2525
+ function capabilities(versionNum) {
2526
+ const major = Math.floor(versionNum / 1e4);
2527
+ return {
2528
+ versionNum,
2529
+ major,
2530
+ summary: major >= 10,
2531
+ settings: major >= 12,
2532
+ wal: major >= 13,
2533
+ genericPlan: major >= 16,
2534
+ serialize: major >= 17,
2535
+ memory: major >= 17
2536
+ };
2537
+ }
2538
+ function versionLabel(versionNum) {
2539
+ const major = Math.floor(versionNum / 1e4);
2540
+ const minor = versionNum % 100;
2541
+ return `${major}.${minor}`;
2542
+ }
2543
+
2544
+ // src/db/explain.ts
2545
+ function buildExplain(flags, caps) {
2546
+ if (flags.genericPlan && flags.analyze) {
2547
+ throw opError("PGX_INVALID_EXPLAIN_OPTION", {
2548
+ detail: "GENERIC_PLAN cannot be combined with ANALYZE (GENERIC_PLAN does not execute the query)."
2549
+ });
2550
+ }
2551
+ if (flags.wal && !flags.analyze) {
2552
+ throw opError("PGX_INVALID_EXPLAIN_OPTION", { detail: "WAL requires ANALYZE." });
2553
+ }
2554
+ const opts = ["FORMAT JSON"];
2555
+ const omitted = [];
2556
+ const gate = (label, supported, requiredMajor) => {
2557
+ if (supported) return true;
2558
+ if (flags.compat) {
2559
+ omitted.push(label);
2560
+ return false;
2561
+ }
2562
+ throw opError("PGX_UNSUPPORTED_PG_VERSION", {
2563
+ detail: `EXPLAIN (${label}) requires PostgreSQL ${requiredMajor}; server is ${versionLabel(caps.versionNum)}.`,
2564
+ meta: { option: label, requiredMajor, serverVersion: caps.versionNum }
2565
+ });
2566
+ };
2567
+ if (flags.genericPlan && gate("GENERIC_PLAN", caps.genericPlan, 16)) opts.push("GENERIC_PLAN");
2568
+ if (flags.analyze) opts.push("ANALYZE");
2569
+ if (flags.buffers) opts.push("BUFFERS");
2570
+ if (flags.verbose) opts.push("VERBOSE");
2571
+ if (flags.settings && gate("SETTINGS", caps.settings, 12)) opts.push("SETTINGS");
2572
+ if (flags.wal && gate("WAL", caps.wal, 13)) opts.push("WAL");
2573
+ if (!flags.costs) opts.push("COSTS OFF");
2574
+ if (flags.analyze && !flags.timing) opts.push("TIMING OFF");
2575
+ if (!flags.summary && caps.summary) opts.push("SUMMARY OFF");
2576
+ return { prefix: `EXPLAIN (${opts.join(", ")})`, omitted };
2577
+ }
2578
+ function leadingKeyword(sql) {
2579
+ const cleaned = sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ").trim();
2580
+ return (cleaned.split(/\s+/)[0] ?? "").toUpperCase();
2581
+ }
2582
+ function isReadOnlyStatement(sql) {
2583
+ const kw = leadingKeyword(sql);
2584
+ if (["SELECT", "TABLE", "VALUES", "SHOW", "EXPLAIN"].includes(kw)) return true;
2585
+ if (kw === "WITH") return !/\b(INSERT|UPDATE|DELETE|MERGE)\b/i.test(sql);
2586
+ return false;
2587
+ }
2588
+ function splitStatements(sql) {
2589
+ const out = [];
2590
+ let buf = "";
2591
+ let i = 0;
2592
+ const n = sql.length;
2593
+ while (i < n) {
2594
+ const ch = sql[i];
2595
+ const two = sql.slice(i, i + 2);
2596
+ if (two === "--") {
2597
+ const nl = sql.indexOf("\n", i);
2598
+ const end = nl === -1 ? n : nl;
2599
+ buf += sql.slice(i, end);
2600
+ i = end;
2601
+ } else if (two === "/*") {
2602
+ const close = sql.indexOf("*/", i + 2);
2603
+ const end = close === -1 ? n : close + 2;
2604
+ buf += sql.slice(i, end);
2605
+ i = end;
2606
+ } else if (ch === "'" || ch === '"') {
2607
+ const end = scanQuoted(sql, i, ch);
2608
+ buf += sql.slice(i, end);
2609
+ i = end;
2610
+ } else if (ch === "$") {
2611
+ const tag = matchDollarTag(sql, i);
2612
+ if (tag) {
2613
+ const close = sql.indexOf(tag, i + tag.length);
2614
+ const end = close === -1 ? n : close + tag.length;
2615
+ buf += sql.slice(i, end);
2616
+ i = end;
2617
+ } else {
2618
+ buf += ch;
2619
+ i++;
2620
+ }
2621
+ } else if (ch === ";") {
2622
+ if (buf.trim()) out.push(buf.trim());
2623
+ buf = "";
2624
+ i++;
2625
+ } else {
2626
+ buf += ch;
2627
+ i++;
2628
+ }
2629
+ }
2630
+ if (buf.trim()) out.push(buf.trim());
2631
+ return out;
2632
+ }
2633
+ function scanQuoted(sql, start, quote) {
2634
+ let i = start + 1;
2635
+ while (i < sql.length) {
2636
+ if (sql[i] === quote) {
2637
+ if (sql[i + 1] === quote)
2638
+ i += 2;
2639
+ else return i + 1;
2640
+ } else {
2641
+ i++;
2642
+ }
2643
+ }
2644
+ return sql.length;
2645
+ }
2646
+ function matchDollarTag(sql, start) {
2647
+ const m = /^\$[A-Za-z_]*\$/.exec(sql.slice(start));
2648
+ return m ? m[0] : null;
2649
+ }
2650
+ function parseDurationMs(value) {
2651
+ const m = /^(\d+(?:\.\d+)?)\s*(ms|s|min|m|h)?$/.exec(value.trim());
2652
+ if (!m?.[1])
2653
+ throw opError("PGX_INVALID_EXPLAIN_OPTION", {
2654
+ detail: `Invalid duration '${value}'. Use e.g. 60s, 500ms, 2min.`
2655
+ });
2656
+ const n = Number(m[1]);
2657
+ switch (m[2]) {
2658
+ case "s":
2659
+ return Math.round(n * 1e3);
2660
+ case "min":
2661
+ case "m":
2662
+ return Math.round(n * 6e4);
2663
+ case "h":
2664
+ return Math.round(n * 36e5);
2665
+ default:
2666
+ return Math.round(n);
2667
+ }
2668
+ }
2669
+
2670
+ // src/db/client.ts
2671
+ async function newClient(config) {
2672
+ let mod;
2673
+ try {
2674
+ mod = await import('pg');
2675
+ } catch (err) {
2676
+ throw opError("PGX_PG_DRIVER_MISSING", {}, err);
2677
+ }
2678
+ const lib = mod.default ?? mod;
2679
+ return new lib.Client(config);
2680
+ }
2681
+ function buildClientConfig(c, ca) {
2682
+ const config = { connectionTimeoutMillis: c.connectTimeoutMs };
2683
+ if (c.dsn) {
2684
+ config.connectionString = c.dsn;
2685
+ } else {
2686
+ if (c.host) config.host = c.host;
2687
+ if (c.port) config.port = c.port;
2688
+ if (c.database) config.database = c.database;
2689
+ if (c.user) config.user = c.user;
2690
+ if (c.password) config.password = c.password;
2691
+ }
2692
+ if (c.sslmode && c.sslmode !== "disable" && c.sslmode !== "prefer") {
2693
+ const verify = c.sslmode === "verify-ca" || c.sslmode === "verify-full";
2694
+ config.ssl = ca ? { rejectUnauthorized: verify, ca } : { rejectUnauthorized: verify };
2695
+ } else if (c.sslmode === "disable") {
2696
+ config.ssl = false;
2697
+ }
2698
+ return config;
2699
+ }
2700
+ async function runExplain(opts) {
2701
+ const ca = opts.connection.sslrootcert ? await readFile(opts.connection.sslrootcert, "utf8").catch((err) => {
2702
+ throw opError("PGX_SSL_VERIFY_FAILED", {
2703
+ detail: `Could not read --sslrootcert '${opts.connection.sslrootcert}': ${err instanceof Error ? err.message : String(err)}`
2704
+ });
2705
+ }) : void 0;
2706
+ const client = await newClient(buildClientConfig(opts.connection, ca));
2707
+ try {
2708
+ await client.connect();
2709
+ } catch (err) {
2710
+ throw mapConnectError(err);
2711
+ }
2712
+ try {
2713
+ const verNum = await fetchVersionNum(client);
2714
+ const caps = capabilities(verNum);
2715
+ const built = buildExplain(opts.flags, caps);
2716
+ const explainSql = `${built.prefix} ${opts.statement}`;
2717
+ logVerbose(`server_version_num=${verNum}; ${built.prefix}`);
2718
+ const useTxn = opts.rollback;
2719
+ if (useTxn) await client.query("BEGIN");
2720
+ try {
2721
+ if (useTxn) {
2722
+ await client.query(`SET LOCAL statement_timeout = ${msInt(opts.statementTimeoutMs)}`);
2723
+ await client.query(`SET LOCAL lock_timeout = ${msInt(opts.lockTimeoutMs)}`);
2724
+ if (!opts.forceWrite) await client.query("SET LOCAL transaction_read_only = on");
2725
+ }
2726
+ const res = await client.query({
2727
+ text: explainSql,
2728
+ values: opts.params ?? []
2729
+ });
2730
+ return { json: extractPlanJson(res.rows), caps, omitted: built.omitted };
2731
+ } catch (err) {
2732
+ throw mapQueryError(err);
2733
+ } finally {
2734
+ if (useTxn) await client.query("ROLLBACK").catch(() => {
2735
+ });
2736
+ }
2737
+ } finally {
2738
+ await client.end().catch(() => {
2739
+ });
2740
+ }
2741
+ }
2742
+ function msInt(ms) {
2743
+ return Math.max(0, Math.floor(ms));
2744
+ }
2745
+ async function fetchVersionNum(client) {
2746
+ try {
2747
+ const res = await client.query("SHOW server_version_num");
2748
+ return Number(res.rows[0]?.server_version_num ?? 0);
2749
+ } catch (err) {
2750
+ throw mapQueryError(err);
2751
+ }
2752
+ }
2753
+ function extractPlanJson(rows) {
2754
+ const value = rows[0]?.["QUERY PLAN"];
2755
+ if (value === void 0) {
2756
+ throw opError("PGX_UNEXPECTED_PLAN_SHAPE", {
2757
+ detail: "The server returned no plan rows for EXPLAIN."
2758
+ });
2759
+ }
2760
+ return typeof value === "string" ? value : JSON.stringify(value);
2761
+ }
2762
+ function asPgError(err) {
2763
+ if (err && typeof err === "object") return err;
2764
+ return { message: String(err) };
2765
+ }
2766
+ function mapConnectError(err) {
2767
+ if (err instanceof AppError) return err;
2768
+ const e = asPgError(err);
2769
+ const msg = e.message ?? "";
2770
+ switch (e.code) {
2771
+ case "28P01":
2772
+ case "28000":
2773
+ return opError("PGX_AUTH_FAILED", { detail: msg }, err);
2774
+ case "3D000":
2775
+ return opError("PGX_DB_NOT_FOUND", { detail: msg }, err);
2776
+ case "ECONNREFUSED":
2777
+ case "ENOTFOUND":
2778
+ case "EAI_AGAIN":
2779
+ case "EHOSTUNREACH":
2780
+ return opError("PGX_HOST_UNREACHABLE", { detail: msg }, err);
2781
+ case "ETIMEDOUT":
2782
+ return opError("PGX_CONN_TIMEOUT", { detail: msg }, err);
2783
+ }
2784
+ if (/timeout/i.test(msg)) return opError("PGX_CONN_TIMEOUT", { detail: msg }, err);
2785
+ if (/self.signed|certificate|verify|CERT_/i.test(msg))
2786
+ return opError("PGX_SSL_VERIFY_FAILED", { detail: msg }, err);
2787
+ if (/SSL|encryption/i.test(msg)) return opError("PGX_SSL_REQUIRED", { detail: msg }, err);
2788
+ return opError("PGX_HOST_UNREACHABLE", { detail: msg }, err);
2789
+ }
2790
+ function mapQueryError(err) {
2791
+ if (err instanceof AppError) return err;
2792
+ const e = asPgError(err);
2793
+ const msg = e.message ?? "";
2794
+ const meta = e.code ? { sqlState: e.code } : void 0;
2795
+ switch (e.code) {
2796
+ case "57014":
2797
+ return /statement timeout/i.test(msg) ? opError("PGX_STATEMENT_TIMEOUT", { detail: msg, meta }, err) : opError("PGX_QUERY_CANCELED", { detail: msg, meta }, err);
2798
+ case "55P03":
2799
+ return opError("PGX_LOCK_TIMEOUT", { detail: msg, meta }, err);
2800
+ case "42501":
2801
+ return opError("PGX_PERMISSION_DENIED", { detail: msg, meta }, err);
2802
+ case "42P01":
2803
+ return opError("PGX_RELATION_NOT_FOUND", { detail: msg, meta }, err);
2804
+ case "28P01":
2805
+ case "28000":
2806
+ return opError("PGX_AUTH_FAILED", { detail: msg, meta }, err);
2807
+ case "3D000":
2808
+ return opError("PGX_DB_NOT_FOUND", { detail: msg, meta }, err);
2809
+ default:
2810
+ return opError("PGX_QUERY_FAILED", { detail: msg, meta }, err);
2811
+ }
2812
+ }
2813
+
2814
+ // src/commands/run.ts
2815
+ async function runRun(args) {
2816
+ const sql = await resolveSql(args);
2817
+ const statements = splitStatements(sql);
2818
+ const statement = selectStatement2(statements, args.statementIndex);
2819
+ if (args.flags.analyze && !args.flags.genericPlan && !isReadOnlyStatement(statement) && !args.forceWrite) {
2820
+ const verb = statement.trim().split(/\s+/)[0]?.toUpperCase() ?? "statement";
2821
+ throw opError("PGX_NON_SELECT_REFUSED", {
2822
+ detail: `Refusing to ANALYZE a non-SELECT (${verb}) \u2014 it would modify data.`
2823
+ });
2824
+ }
2825
+ const result = await runExplain({
2826
+ connection: args.connection,
2827
+ statement,
2828
+ params: args.params,
2829
+ flags: args.flags,
2830
+ statementTimeoutMs: args.statementTimeoutMs,
2831
+ lockTimeoutMs: args.lockTimeoutMs,
2832
+ forceWrite: args.forceWrite,
2833
+ rollback: args.rollback
2834
+ });
2835
+ if (result.omitted.length) {
2836
+ logInfo(
2837
+ `Note: server is PostgreSQL ${result.caps.major}; skipped unsupported option(s): ${result.omitted.join(", ")}.`
2838
+ );
2839
+ }
2840
+ const analysis = analyze(result.json, { config: args.config, redact: args.redact });
2841
+ return emit(analysis, args);
2842
+ }
2843
+ async function resolveSql(args) {
2844
+ if (args.query) return args.query;
2845
+ if (args.file) {
2846
+ try {
2847
+ return await readFile(args.file, "utf8");
2848
+ } catch (err) {
2849
+ throw opError(
2850
+ "PGX_EMPTY_INPUT",
2851
+ {
2852
+ detail: `Could not read SQL file '${args.file}': ${err instanceof Error ? err.message : String(err)}`
2853
+ },
2854
+ err
2855
+ );
2856
+ }
2857
+ }
2858
+ throw opError("PGX_EMPTY_INPUT", {
2859
+ detail: "The run command needs SQL: pass --query '<sql>' or --file <path.sql>."
2860
+ });
2861
+ }
2862
+ function selectStatement2(statements, index) {
2863
+ if (statements.length === 0) {
2864
+ throw opError("PGX_EMPTY_INPUT", { detail: "No SQL statement found after parsing." });
2865
+ }
2866
+ if (index !== void 0) {
2867
+ const stmt = statements[index - 1];
2868
+ if (!stmt) {
2869
+ throw opError("PGX_MULTIPLE_STATEMENTS", {
2870
+ detail: `--statement ${index} is out of range; found ${statements.length} statement(s).`
2871
+ });
2872
+ }
2873
+ return stmt;
2874
+ }
2875
+ if (statements.length > 1) {
2876
+ throw opError("PGX_MULTIPLE_STATEMENTS", {
2877
+ detail: `Found ${statements.length} statements. Pick one with --statement <n> (1-based).`
2878
+ });
2879
+ }
2880
+ return statements[0];
2881
+ }
2882
+
2883
+ // src/diagnostics/print.ts
2884
+ function formatDiagnostic(d) {
2885
+ const c = colors();
2886
+ const tag = d.severity === "error" ? c.red(c.bold("error")) : d.severity === "warn" ? c.yellow("warning") : c.cyan("info");
2887
+ const lines = [];
2888
+ lines.push(`${tag} ${c.bold(d.title)} ${c.dim(`[${d.code}]`)}`);
2889
+ lines.push(` ${c.dim("what:")} ${d.detail}`);
2890
+ lines.push(` ${c.dim("why: ")} ${d.cause}`);
2891
+ lines.push(` ${c.dim("fix: ")} ${d.remediation.summary}`);
2892
+ for (const step of d.remediation.steps ?? []) lines.push(` \u2022 ${step}`);
2893
+ for (const cmd of d.remediation.commands ?? []) {
2894
+ const body = cmd.sql ?? cmd.shell ?? "";
2895
+ const label = cmd.label ? `${c.dim(`${cmd.label}:`)} ` : "";
2896
+ lines.push(` ${label}${c.green(body)}`);
2897
+ }
2898
+ if (d.docsUrl) lines.push(` ${c.dim(`docs: ${d.docsUrl}`)}`);
2899
+ return scrubCredentials(lines.join("\n"));
2900
+ }
2901
+
2902
+ // src/cli.ts
2903
+ var SEVERITIES = ["error", "warn", "info"];
2904
+ var outputArgs = {
2905
+ format: {
2906
+ type: "string",
2907
+ default: "terminal",
2908
+ alias: "f",
2909
+ description: "terminal | markdown | json | html | text"
2910
+ },
2911
+ output: {
2912
+ type: "string",
2913
+ alias: "o",
2914
+ description: "Write the report to a file instead of stdout"
2915
+ },
2916
+ tldr: { type: "boolean", description: "Summary + findings only (no plan tree)" },
2917
+ redact: { type: "boolean", description: "Strip literal values from expressions (safe to share)" },
2918
+ ascii: { type: "boolean", description: "Use ASCII tree glyphs instead of Unicode" },
2919
+ color: { type: "string", default: "auto", description: "auto | always | never" },
2920
+ "no-color": { type: "boolean", description: "Disable color (same as --color never)" },
2921
+ "fail-on": {
2922
+ type: "string",
2923
+ description: "CI gate: exit 1 if a finding at/above info|warn|error exists"
2924
+ },
2925
+ strict: { type: "boolean", description: "Shorthand for --fail-on warn" },
2926
+ config: {
2927
+ type: "string",
2928
+ description: "Path to a config file (default: .pgexplainrc[.json] / package.json#pgExplain)"
2929
+ },
2930
+ compact: { type: "boolean", description: "Compact JSON output" },
2931
+ quiet: { type: "boolean", alias: "q", description: "Suppress non-error logs" },
2932
+ verbose: { type: "boolean", description: "Extra diagnostic logging" },
2933
+ debug: { type: "boolean", description: "Print stack traces on internal errors" }
2934
+ };
2935
+ function applyGlobalFlags(args) {
2936
+ setLogLevel(args.quiet ? "quiet" : args.debug ? "debug" : args.verbose ? "verbose" : "normal");
2937
+ const mode = args["no-color"] ? "never" : args.color;
2938
+ configureColor(mode === "always" || mode === "never" ? mode : "auto");
2939
+ }
2940
+ function emitOptionsFrom(args) {
2941
+ const opts = {
2942
+ format: resolveFormat(args.format),
2943
+ color: args["no-color"] ? "never" : args.color,
2944
+ ascii: args.ascii,
2945
+ tldr: args.tldr,
2946
+ pretty: !args.compact
2947
+ };
2948
+ if (args.output) opts.output = args.output;
2949
+ const failOn = resolveFailOn(args);
2950
+ if (failOn) opts.failOn = failOn;
2951
+ return opts;
2952
+ }
2953
+ function resolveFormat(value) {
2954
+ if (isFormat(value)) return value;
2955
+ throw usageError(
2956
+ `Unknown --format '${value}'`,
2957
+ "Pick one of: terminal, markdown, json, html, text."
2958
+ );
2959
+ }
2960
+ function resolveFailOn(args) {
2961
+ if (args.strict) return "warn";
2962
+ const value = args["fail-on"];
2963
+ if (value === void 0) return void 0;
2964
+ if (SEVERITIES.includes(value)) return value;
2965
+ throw usageError(`Unknown --fail-on '${value}'`, "Use one of: info, warn, error.");
2966
+ }
2967
+ function resolveStatement(args) {
2968
+ const raw = args.statement ?? args.stmt;
2969
+ if (raw === void 0) return void 0;
2970
+ const n = Number(raw);
2971
+ if (!Number.isInteger(n) || n < 1)
2972
+ throw usageError(`Invalid --statement '${raw}'`, "Use a 1-based integer.");
2973
+ return n;
2974
+ }
2975
+ function usageError(title, fix) {
2976
+ const diagnostic = {
2977
+ code: "PGX_USAGE",
2978
+ domain: "operational",
2979
+ severity: "error",
2980
+ title,
2981
+ detail: "The command could not be run as given.",
2982
+ cause: "Invalid command-line usage.",
2983
+ remediation: {
2984
+ summary: fix,
2985
+ commands: [{ label: "See all options", shell: "pg-explain --help" }]
2986
+ }
2987
+ };
2988
+ return new AppError(diagnostic, 2 /* Usage */);
2989
+ }
2990
+ function handleFatal(err) {
2991
+ if (err instanceof AppError) {
2992
+ logError(formatDiagnostic(err.diagnostic));
2993
+ return err.exitCode;
2994
+ }
2995
+ logError(formatDiagnostic(opDiagnostic("PGX_INTERNAL")));
2996
+ if (isDebug() && err instanceof Error && err.stack) logError(scrubCredentials(err.stack));
2997
+ return 70 /* Internal */;
2998
+ }
2999
+ var runCmd = defineCommand({
3000
+ meta: {
3001
+ name: "run",
3002
+ description: "Connect to PostgreSQL, run EXPLAIN safely, and analyze the result."
3003
+ },
3004
+ args: {
3005
+ dsn: {
3006
+ type: "string",
3007
+ description: "Connection string (or use --host/--port/\u2026 or PG* env vars)"
3008
+ },
3009
+ host: { type: "string", description: "Server host" },
3010
+ port: { type: "string", description: "Server port" },
3011
+ dbname: { type: "string", alias: "d", description: "Database name" },
3012
+ user: { type: "string", alias: "U", description: "Role name" },
3013
+ sslmode: { type: "string", description: "disable | require | verify-ca | verify-full" },
3014
+ sslrootcert: { type: "string", description: "Path to a CA certificate (PEM)" },
3015
+ "connect-timeout": {
3016
+ type: "string",
3017
+ default: "10s",
3018
+ description: "Connection timeout (e.g. 30s)"
3019
+ },
3020
+ query: { type: "string", description: "SQL to explain" },
3021
+ file: { type: "string", description: "Path to a .sql file to explain" },
3022
+ statement: { type: "string", description: "1-based statement index when the file has several" },
3023
+ param: { type: "string", description: "Value for $1, $2, \u2026 (repeatable)" },
3024
+ "statement-timeout": {
3025
+ type: "string",
3026
+ default: "30s",
3027
+ description: "statement_timeout for the run"
3028
+ },
3029
+ "lock-timeout": { type: "string", default: "5s", description: "lock_timeout for the run" },
3030
+ force: {
3031
+ type: "boolean",
3032
+ description: "Allow a non-SELECT to execute (still auto-rolled-back)"
3033
+ },
3034
+ "no-rollback": {
3035
+ type: "boolean",
3036
+ description: "Do not wrap the run in a rolled-back transaction (dangerous)"
3037
+ },
3038
+ "no-analyze": { type: "boolean", description: "Do not execute the query; plan estimates only" },
3039
+ "no-buffers": { type: "boolean", description: "Omit BUFFERS" },
3040
+ "explain-verbose": {
3041
+ type: "boolean",
3042
+ description: "Add EXPLAIN VERBOSE (output columns, schemas)"
3043
+ },
3044
+ settings: { type: "boolean", description: "Add EXPLAIN SETTINGS (PG12+)" },
3045
+ wal: { type: "boolean", description: "Add EXPLAIN WAL (PG13+, needs ANALYZE)" },
3046
+ "generic-plan": {
3047
+ type: "boolean",
3048
+ description: "EXPLAIN GENERIC_PLAN (PG16+, does not execute)"
3049
+ },
3050
+ "no-timing": { type: "boolean", description: "Add TIMING OFF (reduces ANALYZE overhead)" },
3051
+ "no-costs": { type: "boolean", description: "Add COSTS OFF" },
3052
+ compat: { type: "boolean", description: "Auto-omit EXPLAIN options the server is too old for" },
3053
+ ...outputArgs
3054
+ },
3055
+ async run({ args }) {
3056
+ try {
3057
+ applyGlobalFlags(args);
3058
+ const connection = {
3059
+ connectTimeoutMs: parseDurationMs(args["connect-timeout"])
3060
+ };
3061
+ if (args.dsn) connection.dsn = args.dsn;
3062
+ if (args.host) connection.host = args.host;
3063
+ if (args.port) connection.port = Number(args.port);
3064
+ if (args.dbname) connection.database = args.dbname;
3065
+ if (args.user) connection.user = args.user;
3066
+ if (args.sslmode) connection.sslmode = args.sslmode;
3067
+ if (args.sslrootcert) connection.sslrootcert = args.sslrootcert;
3068
+ const flags = {
3069
+ analyze: !args["no-analyze"],
3070
+ buffers: !args["no-buffers"],
3071
+ verbose: !!args["explain-verbose"],
3072
+ settings: !!args.settings,
3073
+ wal: !!args.wal,
3074
+ timing: !args["no-timing"],
3075
+ costs: !args["no-costs"],
3076
+ summary: true,
3077
+ genericPlan: !!args["generic-plan"],
3078
+ compat: !!args.compat
3079
+ };
3080
+ const params = Array.isArray(args.param) ? args.param : args.param ? [args.param] : void 0;
3081
+ process.exitCode = await runRun({
3082
+ ...emitOptionsFrom(args),
3083
+ config: await loadConfig(args.config),
3084
+ connection,
3085
+ query: args.query,
3086
+ file: args.file,
3087
+ statementIndex: resolveStatement(args),
3088
+ params,
3089
+ flags,
3090
+ statementTimeoutMs: parseDurationMs(args["statement-timeout"]),
3091
+ lockTimeoutMs: parseDurationMs(args["lock-timeout"]),
3092
+ forceWrite: !!args.force,
3093
+ rollback: !args["no-rollback"],
3094
+ redact: args.redact
3095
+ });
3096
+ } catch (err) {
3097
+ process.exitCode = handleFatal(err);
3098
+ }
3099
+ }
3100
+ });
3101
+ var diffCmd = defineCommand({
3102
+ meta: {
3103
+ name: "diff",
3104
+ description: "Compare two EXPLAIN plans (before \u2192 after) and report regressions."
3105
+ },
3106
+ args: {
3107
+ before: { type: "positional", required: true, description: "Baseline plan JSON file" },
3108
+ after: { type: "positional", required: true, description: "New plan JSON file" },
3109
+ format: {
3110
+ type: "string",
3111
+ default: "terminal",
3112
+ alias: "f",
3113
+ description: "terminal | markdown | json"
3114
+ },
3115
+ output: { type: "string", alias: "o", description: "Write to a file instead of stdout" },
3116
+ color: { type: "string", default: "auto", description: "auto | always | never" },
3117
+ "no-color": { type: "boolean", description: "Disable color" },
3118
+ redact: { type: "boolean", description: "Strip literal values before comparing" },
3119
+ config: { type: "string", description: "Path to a config file" },
3120
+ "fail-on-slower": {
3121
+ type: "string",
3122
+ description: "Exit 1 if execution time regresses by \u2265 this percent"
3123
+ },
3124
+ "fail-on-new-findings": { type: "boolean", description: "Exit 1 if any new finding appears" },
3125
+ quiet: { type: "boolean", alias: "q", description: "Suppress non-error logs" },
3126
+ debug: { type: "boolean", description: "Print stack traces on internal errors" }
3127
+ },
3128
+ async run({ args }) {
3129
+ try {
3130
+ applyGlobalFlags(args);
3131
+ const format = args.format;
3132
+ if (!["terminal", "markdown", "json"].includes(format)) {
3133
+ throw usageError(`Unknown diff --format '${format}'`, "Use terminal, markdown, or json.");
3134
+ }
3135
+ const diffArgs = {
3136
+ before: args.before,
3137
+ after: args.after,
3138
+ format,
3139
+ color: args["no-color"] ? "never" : args.color,
3140
+ redact: args.redact,
3141
+ config: await loadConfig(args.config),
3142
+ failOnNewFindings: !!args["fail-on-new-findings"]
3143
+ };
3144
+ if (args.output) diffArgs.output = args.output;
3145
+ if (args["fail-on-slower"] !== void 0) {
3146
+ const pct = Number(args["fail-on-slower"]);
3147
+ if (!Number.isFinite(pct))
3148
+ throw usageError(
3149
+ `Invalid --fail-on-slower '${args["fail-on-slower"]}'`,
3150
+ "Use a number, e.g. 20."
3151
+ );
3152
+ diffArgs.failOnSlowerPct = pct;
3153
+ }
3154
+ process.exitCode = await runDiff(diffArgs);
3155
+ } catch (err) {
3156
+ process.exitCode = handleFatal(err);
3157
+ }
3158
+ }
3159
+ });
3160
+ var main = defineCommand({
3161
+ meta: {
3162
+ name: "pg-explain",
3163
+ version: package_default.version,
3164
+ description: "Analyze a PostgreSQL EXPLAIN plan and report fixable findings. Pipe a plan or pass a file; use `pg-explain run --help` to execute a query and explain it."
3165
+ },
3166
+ args: {
3167
+ file: {
3168
+ type: "positional",
3169
+ required: false,
3170
+ description: "Plan JSON file (default: read stdin)"
3171
+ },
3172
+ statement: {
3173
+ type: "string",
3174
+ description: "1-based statement index when the input holds several"
3175
+ },
3176
+ ...outputArgs
3177
+ },
3178
+ async run({ args }) {
3179
+ try {
3180
+ applyGlobalFlags(args);
3181
+ process.exitCode = await runAnalyze({
3182
+ ...emitOptionsFrom(args),
3183
+ config: await loadConfig(args.config),
3184
+ file: args.file,
3185
+ statement: resolveStatement(args),
3186
+ redact: args.redact
3187
+ });
3188
+ } catch (err) {
3189
+ process.exitCode = handleFatal(err);
3190
+ }
3191
+ }
3192
+ });
3193
+ process.on("SIGINT", () => process.exit(130 /* Sigint */));
3194
+ process.on("SIGTERM", () => process.exit(130 /* Sigint */));
3195
+ var argv = process.argv.slice(2);
3196
+ if (argv[0] === "completion") {
3197
+ process.exitCode = runCompletion(argv[1]);
3198
+ } else {
3199
+ const started = argv[0] === "run" ? runMain(runCmd, { rawArgs: argv.slice(1) }) : argv[0] === "diff" ? runMain(diffCmd, { rawArgs: argv.slice(1) }) : runMain(main, { rawArgs: argv });
3200
+ started.catch((err) => {
3201
+ process.exitCode = handleFatal(err);
3202
+ });
3203
+ }
3204
+ //# sourceMappingURL=cli.js.map
3205
+ //# sourceMappingURL=cli.js.map