pgexplain 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,10 +4,12 @@ import { readFile, writeFile, stat, readdir } from 'fs/promises';
4
4
  import { join } from 'path';
5
5
  import { z } from 'zod';
6
6
  import pc from 'picocolors';
7
+ import { tmpdir } from 'os';
8
+ import { spawn } from 'child_process';
7
9
 
8
10
  // package.json
9
11
  var package_default = {
10
- version: "0.1.0"};
12
+ version: "0.3.0"};
11
13
 
12
14
  // src/diagnostics/diagnostic.ts
13
15
  var AppError = class extends Error {
@@ -21,6 +23,9 @@ var AppError = class extends Error {
21
23
  if (cause !== void 0) this.cause = cause;
22
24
  }
23
25
  };
26
+ function finding(code, severity, parts) {
27
+ return { code, domain: "plan", severity, ...parts };
28
+ }
24
29
  var SEVERITY_RANK = { error: 0, warn: 1, info: 2 };
25
30
  function bySeverity(a, b) {
26
31
  return SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
@@ -428,7 +433,9 @@ var DEFAULT_THRESHOLDS = {
428
433
  correlatedLoops: 1e3,
429
434
  jitPct: 25,
430
435
  triggerPct: 10,
431
- lowCacheHitRatio: 0.9
436
+ lowCacheHitRatio: 0.9,
437
+ limitDiscardRows: 1e4,
438
+ staleStatsModRatio: 0.2
432
439
  };
433
440
  var DEFAULT_CONFIG = {
434
441
  thresholds: { ...DEFAULT_THRESHOLDS },
@@ -482,6 +489,308 @@ async function loadConfig(explicitPath, cwd = process.cwd()) {
482
489
  }
483
490
  return { ...DEFAULT_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS }, rules: {} };
484
491
  }
492
+
493
+ // src/core/parse-text.ts
494
+ var cap = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
495
+ var numeric = (v) => {
496
+ const t = v.trim();
497
+ return /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : t;
498
+ };
499
+ function splitList(s) {
500
+ const out = [];
501
+ let depth = 0;
502
+ let cur = "";
503
+ for (const ch of s) {
504
+ if (ch === "(") depth++;
505
+ else if (ch === ")") depth--;
506
+ if (ch === "," && depth === 0) {
507
+ if (cur.trim()) out.push(cur.trim());
508
+ cur = "";
509
+ } else {
510
+ cur += ch;
511
+ }
512
+ }
513
+ if (cur.trim()) out.push(cur.trim());
514
+ return out;
515
+ }
516
+ function splitIntoLines(text) {
517
+ const out = [];
518
+ const lines = text.split(/\r?\n/);
519
+ const count = (s, re) => (s.match(re) || []).length;
520
+ const closingFirst = (s) => {
521
+ const c = s.indexOf(")");
522
+ const o = s.indexOf("(");
523
+ return c !== -1 && c < o;
524
+ };
525
+ const sameIndent = (a, b) => a.search(/\S/) === b.search(/\S/);
526
+ for (const line of lines) {
527
+ const prev = out[out.length - 1];
528
+ if (prev && count(prev, /\)/g) !== count(prev, /\(/g)) {
529
+ out[out.length - 1] += line;
530
+ } else if (/^(?:Total\s+runtime|Planning(\s+time)?|Execution\s+time|Time|Filter|Output|JIT|Trigger|Settings|Serialization)/i.test(
531
+ line
532
+ )) {
533
+ out.push(line);
534
+ } else if (/^\S/.test(line) || /^\s*\(/.test(line) || closingFirst(line)) {
535
+ if (prev) out[out.length - 1] += line;
536
+ else out.push(line);
537
+ } else if (prev && /,\s*$/.test(prev) && !sameIndent(prev, line) && !/^\s*->/i.test(line)) {
538
+ out[out.length - 1] += line;
539
+ } else {
540
+ out.push(line);
541
+ }
542
+ }
543
+ return out;
544
+ }
545
+ var estimation = String.raw`\(cost=(\d+\.\d+)\.\.(\d+\.\d+)\s+rows=(\d+)\s+width=(\d+)\)`;
546
+ var actual = String.raw`(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|(never\s+executed))`;
547
+ var nodeRe = new RegExp(
548
+ String.raw`^(\s*->\s*|\s*)(Finalize|Simple|Partial)*\s*([^\r\n\t\f\v(]*?)\s*` + String.raw`(?:(?:${estimation}\s+\(${actual}\))|(?:${estimation})|(?:\(${actual}\)))\s*$`
549
+ );
550
+ var subRe = /^((?:Sub|Init)Plan)\s*(?:\d+\s*)?(?:\(returns.*\))?\s*$/;
551
+ var cteRe = /^CTE\s+(\S+)\s*$/;
552
+ var workerRe = /^Worker\s+(\d+):\s+(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|never\s+executed)(.*)$/;
553
+ var triggerRe = /^Trigger\s+(.*):\s+time=(\d+\.\d+)\s+calls=(\d+)\s*$/;
554
+ var headerRe = /^(QUERY PLAN|-{2,}|#|\(\d+ rows?\))/;
555
+ function splitNodeType(text) {
556
+ let s = text.trim();
557
+ let indexName;
558
+ let relationName;
559
+ let schema;
560
+ let alias;
561
+ const using = s.match(/\susing (\S+)/);
562
+ if (using?.[1]) {
563
+ indexName = using[1];
564
+ s = s.replace(using[0] ?? "", "");
565
+ }
566
+ const on = s.match(/\son (\S+?)(?:\s+(\S+))?\s*$/);
567
+ if (on?.[1]) {
568
+ let rel = on[1];
569
+ alias = on[2];
570
+ const dot = rel.lastIndexOf(".");
571
+ if (dot !== -1) {
572
+ schema = rel.slice(0, dot);
573
+ rel = rel.slice(dot + 1);
574
+ }
575
+ relationName = rel;
576
+ s = s.slice(0, on.index).trim();
577
+ }
578
+ const nodeType = s.replace(/^Parallel\s+/, "").trim();
579
+ if (nodeType === "Bitmap Index Scan" && relationName && !indexName) {
580
+ indexName = relationName;
581
+ relationName = void 0;
582
+ schema = void 0;
583
+ alias = void 0;
584
+ }
585
+ const out = { "Node Type": nodeType };
586
+ if (relationName) out["Relation Name"] = relationName;
587
+ if (indexName) out["Index Name"] = indexName;
588
+ if (schema) out.Schema = schema;
589
+ if (alias && alias !== relationName) out.Alias = alias;
590
+ return out;
591
+ }
592
+ function parseSort(text, node) {
593
+ const m = text.match(/^Sort Method:\s+(.*?)\s+(Memory|Disk):\s+(\S+)kB\s*$/);
594
+ if (!m?.[1] || !m[2] || m[3] === void 0) return false;
595
+ node["Sort Method"] = m[1].trim();
596
+ node["Sort Space Type"] = m[2];
597
+ node["Sort Space Used"] = Number(m[3]);
598
+ return true;
599
+ }
600
+ function parseBuffers(text, node) {
601
+ const m = text.match(/^Buffers:\s+(.*)$/);
602
+ if (!m?.[1]) return false;
603
+ for (const group of m[1].split(/,\s+/)) {
604
+ const g = group.match(/^(shared|temp|local)\s+(.*)$/);
605
+ if (!g?.[1] || g[2] === void 0) continue;
606
+ const type = cap(g[1]);
607
+ for (const kv of g[2].trim().split(/\s+/)) {
608
+ const [method, value] = kv.split("=");
609
+ if (method && value !== void 0) node[`${type} ${cap(method)} Blocks`] = Number(value);
610
+ }
611
+ }
612
+ return true;
613
+ }
614
+ function parseWal(text, node) {
615
+ const m = text.match(/^WAL:\s+(.*)$/);
616
+ if (!m?.[1]) return false;
617
+ for (const kv of m[1].trim().split(/\s+/)) {
618
+ const [k, value] = kv.split("=");
619
+ if (!k || value === void 0) continue;
620
+ node[`WAL ${k === "fpi" ? "FPI" : cap(k)}`] = Number(value);
621
+ }
622
+ return true;
623
+ }
624
+ function parseIoTimings(text, node) {
625
+ const m = text.match(/^I\/O Timings:\s+(.*)$/);
626
+ if (!m?.[1]) return false;
627
+ const read = m[1].match(/(?:^|\s)read=(\d+(?:\.\d+)?)/);
628
+ const write2 = m[1].match(/(?:^|\s)write=(\d+(?:\.\d+)?)/);
629
+ if (read?.[1]) node["I/O Read Time"] = Number(read[1]);
630
+ if (write2?.[1]) node["I/O Write Time"] = Number(write2[1]);
631
+ return true;
632
+ }
633
+ function parseSettings(text) {
634
+ const out = {};
635
+ for (const pair of splitList(text)) {
636
+ const m = pair.match(/^(\S+)\s*=\s*(.*)$/);
637
+ if (m?.[1] && m[2] !== void 0) out[m[1]] = m[2].replace(/^'|'$/g, "");
638
+ }
639
+ return out;
640
+ }
641
+ var LIST_KEYS = /* @__PURE__ */ new Set(["Output", "Sort Key", "Presorted Key", "Group Key"]);
642
+ function parseTextToStatements(input) {
643
+ const statements = [];
644
+ let stmt = null;
645
+ let stack = [];
646
+ let current = null;
647
+ let jit = null;
648
+ const finish = () => {
649
+ if (stmt?.Plan) statements.push(stmt);
650
+ stmt = null;
651
+ stack = [];
652
+ current = null;
653
+ jit = null;
654
+ };
655
+ for (let raw of splitIntoLines(input)) {
656
+ raw = raw.replace(/"\s*$/, "").replace(/^\s*"/, "").replace(/\t/g, " ");
657
+ const depth = raw.match(/^\s*/)?.[0].length ?? 0;
658
+ const line = raw.slice(depth);
659
+ if (line === "" || headerRe.test(line)) {
660
+ if (line === "" && stmt?.Plan) finish();
661
+ continue;
662
+ }
663
+ const nodeM = nodeRe.exec(line);
664
+ const subM = subRe.exec(line);
665
+ const cteM = cteRe.exec(line);
666
+ if (nodeM && !subM && !cteM) {
667
+ if (!stmt) stmt = {};
668
+ jit = null;
669
+ const node = { ...splitNodeType(nodeM[3] ?? "") };
670
+ if (nodeM[2]) node["Partial Mode"] = nodeM[2];
671
+ const startup = nodeM[4] ?? nodeM[13];
672
+ const total = nodeM[5] ?? nodeM[14];
673
+ if (startup && total) {
674
+ node["Startup Cost"] = Number(startup);
675
+ node["Total Cost"] = Number(total);
676
+ node["Plan Rows"] = Number(nodeM[6] ?? nodeM[15]);
677
+ node["Plan Width"] = Number(nodeM[7] ?? nodeM[16]);
678
+ }
679
+ const st = nodeM[8] ?? nodeM[17];
680
+ const tt = nodeM[9] ?? nodeM[18];
681
+ if (st && tt) {
682
+ node["Actual Startup Time"] = Number(st);
683
+ node["Actual Total Time"] = Number(tt);
684
+ }
685
+ const rows = nodeM[10] ?? nodeM[19];
686
+ const loops = nodeM[11] ?? nodeM[20];
687
+ if (rows && loops) {
688
+ node["Actual Rows"] = Number(rows);
689
+ node["Actual Loops"] = Number(loops);
690
+ }
691
+ if (nodeM[12] ?? nodeM[21]) {
692
+ node["Actual Loops"] = 0;
693
+ node["Actual Rows"] = 0;
694
+ }
695
+ stack = stack.filter((f) => f.depth < depth);
696
+ const parent = stack[stack.length - 1];
697
+ if (!parent) {
698
+ stmt.Plan = node;
699
+ } else {
700
+ if (parent.rel) {
701
+ node["Parent Relationship"] = parent.rel;
702
+ if (parent.name) node["Subplan Name"] = parent.name;
703
+ }
704
+ const parentNode = parent.node;
705
+ if (!parentNode.Plans) parentNode.Plans = [];
706
+ parentNode.Plans.push(node);
707
+ }
708
+ stack.push({ depth, node });
709
+ current = node;
710
+ continue;
711
+ }
712
+ if (subM || cteM) {
713
+ stack = stack.filter((f) => f.depth < depth);
714
+ const parent = stack[stack.length - 1];
715
+ if (!parent) continue;
716
+ if (cteM?.[1])
717
+ stack.push({ depth, node: parent.node, rel: "InitPlan", name: `CTE ${cteM[1]}` });
718
+ else if (subM?.[1])
719
+ stack.push({
720
+ depth,
721
+ node: parent.node,
722
+ rel: subM[1],
723
+ name: (subM[0] ?? "").trim()
724
+ });
725
+ continue;
726
+ }
727
+ const workerM = workerRe.exec(line);
728
+ if (workerM && current) {
729
+ const worker = { "Worker Number": Number(workerM[1]) };
730
+ if (workerM[2] && workerM[3]) {
731
+ worker["Actual Startup Time"] = Number(workerM[2]);
732
+ worker["Actual Total Time"] = Number(workerM[3]);
733
+ }
734
+ if (workerM[4] && workerM[5]) {
735
+ worker["Actual Rows"] = Number(workerM[4]);
736
+ worker["Actual Loops"] = Number(workerM[5]);
737
+ }
738
+ if (!Array.isArray(current.Workers)) current.Workers = [];
739
+ current.Workers.push(worker);
740
+ continue;
741
+ }
742
+ const trigM = triggerRe.exec(line);
743
+ if (trigM && stmt) {
744
+ if (!Array.isArray(stmt.Triggers)) stmt.Triggers = [];
745
+ stmt.Triggers.push({
746
+ "Trigger Name": trigM[1],
747
+ Time: Number(trigM[2]),
748
+ Calls: Number(trigM[3])
749
+ });
750
+ continue;
751
+ }
752
+ const kv = line.match(/^([^:]+):\s*(.*)$/);
753
+ if (!kv?.[1]) continue;
754
+ const key = kv[1].trim();
755
+ const value = (kv[2] ?? "").trim();
756
+ if (key === "JIT") {
757
+ jit = {};
758
+ if (stmt) stmt.JIT = jit;
759
+ continue;
760
+ }
761
+ if (jit) {
762
+ if (key === "Functions") jit.Functions = Number(value);
763
+ else if (key === "Timing") {
764
+ const timing = {};
765
+ for (const part of value.split(/,\s*/)) {
766
+ const t = part.match(/^(\S+)\s+(\d+\.\d+)\s*ms/);
767
+ if (t?.[1]) timing[t[1]] = Number(t[2]);
768
+ }
769
+ jit.Timing = timing;
770
+ }
771
+ continue;
772
+ }
773
+ if (key === "Planning Time") {
774
+ if (stmt) stmt["Planning Time"] = parseFloat(value);
775
+ continue;
776
+ }
777
+ if (key === "Execution Time" || key === "Total runtime") {
778
+ if (stmt) stmt["Execution Time"] = parseFloat(value);
779
+ continue;
780
+ }
781
+ if (key === "Settings") {
782
+ if (stmt) stmt.Settings = parseSettings(value);
783
+ continue;
784
+ }
785
+ if (!current) continue;
786
+ if (parseSort(line, current) || parseBuffers(line, current) || parseWal(line, current) || parseIoTimings(line, current)) {
787
+ continue;
788
+ }
789
+ current[key] = LIST_KEYS.has(key) ? splitList(value) : numeric(value);
790
+ }
791
+ finish();
792
+ return statements;
793
+ }
485
794
  var PlanNodeSchema = z.looseObject({
486
795
  "Node Type": z.string(),
487
796
  get Plans() {
@@ -590,6 +899,10 @@ function normalizeNode(raw, nextId) {
590
899
  diskUsage: num(raw, "Disk Usage"),
591
900
  exactHeapBlocks: num(raw, "Exact Heap Blocks"),
592
901
  lossyHeapBlocks: num(raw, "Lossy Heap Blocks"),
902
+ cacheHits: num(raw, "Cache Hits"),
903
+ cacheMisses: num(raw, "Cache Misses"),
904
+ cacheEvictions: num(raw, "Cache Evictions"),
905
+ cacheOverflows: num(raw, "Cache Overflows"),
593
906
  sharedHitBlocks: num(raw, "Shared Hit Blocks"),
594
907
  sharedReadBlocks: num(raw, "Shared Read Blocks"),
595
908
  sharedDirtiedBlocks: num(raw, "Shared Dirtied Blocks"),
@@ -601,8 +914,13 @@ function normalizeNode(raw, nextId) {
601
914
  ioReadTime: num(raw, "I/O Read Time"),
602
915
  ioWriteTime: num(raw, "I/O Write Time"),
603
916
  workersPlanned: num(raw, "Workers Planned"),
604
- workersLaunched: num(raw, "Workers Launched")
917
+ workersLaunched: num(raw, "Workers Launched"),
918
+ walRecords: num(raw, "WAL Records"),
919
+ walBytes: num(raw, "WAL Bytes"),
920
+ walFpi: num(raw, "WAL FPI")
605
921
  });
922
+ const workers = parseWorkers(raw.Workers);
923
+ if (workers.length) node.workers = workers;
606
924
  const childPlans = raw.Plans;
607
925
  if (Array.isArray(childPlans)) {
608
926
  for (const child of childPlans) {
@@ -616,6 +934,24 @@ function assign(target, fields) {
616
934
  if (v !== void 0) target[k] = v;
617
935
  }
618
936
  }
937
+ function parseWorkers(raw) {
938
+ if (!Array.isArray(raw)) return [];
939
+ const out = [];
940
+ for (const w of raw) {
941
+ const r = w;
942
+ const number = num(r, "Worker Number");
943
+ if (number === void 0) continue;
944
+ const stat2 = { number };
945
+ assign(stat2, {
946
+ actualRows: num(r, "Actual Rows"),
947
+ actualLoops: num(r, "Actual Loops"),
948
+ actualStartupTime: num(r, "Actual Startup Time"),
949
+ actualTotalTime: num(r, "Actual Total Time")
950
+ });
951
+ out.push(stat2);
952
+ }
953
+ return out;
954
+ }
619
955
  function parseTriggers(raw) {
620
956
  if (!Array.isArray(raw)) return [];
621
957
  return raw.map((t) => {
@@ -649,6 +985,36 @@ function parseJit(raw) {
649
985
  }
650
986
  return jit;
651
987
  }
988
+ function statementToTree(stmt) {
989
+ let id = 0;
990
+ const root = normalizeNode(stmt.Plan, () => id++);
991
+ const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
992
+ const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
993
+ const tree = {
994
+ root,
995
+ triggers: parseTriggers(stmt.Triggers),
996
+ hasAnalyze,
997
+ hasBuffers,
998
+ raw: stmt.Plan
999
+ };
1000
+ if (typeof stmt["Planning Time"] === "number") tree.planningTime = stmt["Planning Time"];
1001
+ if (typeof stmt["Execution Time"] === "number") tree.executionTime = stmt["Execution Time"];
1002
+ const serialization = stmt.Serialization;
1003
+ if (serialization && typeof serialization === "object") {
1004
+ const t = num(serialization, "Time");
1005
+ if (t !== void 0) tree.serializationTime = t;
1006
+ }
1007
+ const jit = parseJit(stmt.JIT);
1008
+ if (jit) tree.jit = jit;
1009
+ if (stmt.Settings) tree.settings = stmt.Settings;
1010
+ return tree;
1011
+ }
1012
+ function parseExplain(input) {
1013
+ return /^\s*[[{]/.test(input) ? parseExplainJson(input) : parseExplainText(input);
1014
+ }
1015
+ function parseExplainText(input) {
1016
+ return parseTextToStatements(input).map(statementToTree);
1017
+ }
652
1018
  function parseExplainJson(input) {
653
1019
  const json = parseJsonWithLocation(input);
654
1020
  let candidate = json;
@@ -663,25 +1029,7 @@ function parseExplainJson(input) {
663
1029
  location: { kind: "input" }
664
1030
  });
665
1031
  }
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
- });
1032
+ return result.data.map((stmt) => statementToTree(stmt));
685
1033
  }
686
1034
  function walk(node, visit) {
687
1035
  visit(node);
@@ -746,6 +1094,31 @@ function executionMs(tree) {
746
1094
  function bottlenecks(tree, n = 5) {
747
1095
  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
1096
  }
1097
+ function aggregateStats(tree) {
1098
+ const total = executionMs(tree) ?? 0;
1099
+ const groupBy = (keyOf) => {
1100
+ const acc = /* @__PURE__ */ new Map();
1101
+ for (const n of flatten(tree.root)) {
1102
+ const key = keyOf(n);
1103
+ if (!key) continue;
1104
+ const e = acc.get(key) ?? { count: 0, selfMs: 0 };
1105
+ e.count++;
1106
+ e.selfMs += n.metrics.selfMs ?? 0;
1107
+ acc.set(key, e);
1108
+ }
1109
+ return [...acc.entries()].map(([key, e]) => ({
1110
+ key,
1111
+ count: e.count,
1112
+ selfMs: e.selfMs,
1113
+ pctOfTotal: total > 0 ? 100 * e.selfMs / total : 0
1114
+ })).sort((a, b) => b.selfMs - a.selfMs || b.count - a.count);
1115
+ };
1116
+ return {
1117
+ byNodeType: groupBy((n) => n.nodeType),
1118
+ byRelation: groupBy((n) => n.relationName),
1119
+ byIndex: groupBy((n) => n.indexName)
1120
+ };
1121
+ }
749
1122
  function nodeLabel(node) {
750
1123
  let label = node.nodeType;
751
1124
  if (node.indexName && node.relationName)
@@ -877,8 +1250,11 @@ var cartesianProduct = {
877
1250
  check(node, ctx) {
878
1251
  if (node.nodeType !== "Nested Loop") return [];
879
1252
  if (node.joinFilter) return [];
880
- const inner = node.children[1];
1253
+ let inner = node.children[1];
881
1254
  if (!inner) return [];
1255
+ while ((inner.nodeType === "Memoize" || inner.nodeType === "Materialize") && inner.children[0]) {
1256
+ inner = inner.children[0];
1257
+ }
882
1258
  if (inner.indexCond || inner.recheckCond) return [];
883
1259
  const outer = node.children[0];
884
1260
  if (!outer) return [];
@@ -1197,6 +1573,47 @@ var indexOnlyHeapFetches = {
1197
1573
  }
1198
1574
  };
1199
1575
 
1576
+ // src/advisor/rules/limit-large-offset.ts
1577
+ var limitLargeOffset = {
1578
+ id: "PGX_LIMIT_LARGE_OFFSET",
1579
+ title: "LIMIT discards a large prefix (OFFSET pagination)",
1580
+ defaultSeverity: "warn",
1581
+ requiresAnalyze: true,
1582
+ check(node, ctx) {
1583
+ if (node.nodeType !== "Limit") return [];
1584
+ const child = outerChild(node);
1585
+ const emitted = node.metrics.totalRows;
1586
+ const produced = child?.metrics.totalRows;
1587
+ if (emitted === void 0 || produced === void 0) return [];
1588
+ const discarded = produced - emitted;
1589
+ if (discarded < ctx.thresholds.limitDiscardRows) return [];
1590
+ const rel = child?.relationName ?? "the input";
1591
+ return [
1592
+ makeFinding(limitLargeOffset, ctx, node, {
1593
+ title: `LIMIT discarded ${fmtInt(discarded)} rows before returning ${fmtInt(emitted)}`,
1594
+ detail: `The plan produced ${fmtInt(produced)} rows from ${rel} but the Limit node returned only ${fmtInt(emitted)} \u2014 ${fmtInt(discarded)} rows were generated just to be skipped.`,
1595
+ cause: "OFFSET-style pagination makes Postgres compute and discard every row before the requested page, so deep pages get progressively slower (page N costs O(N)).",
1596
+ remediation: {
1597
+ summary: "Switch to keyset (seek) pagination: filter on the last-seen sort key instead of skipping rows, and keep an index on the sort key so each page is a direct index seek.",
1598
+ steps: [
1599
+ "Order by a unique (or tie-broken) key, e.g. ORDER BY created_at, id.",
1600
+ "Pass the last row's key from the previous page instead of an OFFSET.",
1601
+ "Index the sort key so the WHERE clause seeks directly to the page start."
1602
+ ],
1603
+ commands: [
1604
+ {
1605
+ label: "Keyset pagination instead of OFFSET",
1606
+ sql: "SELECT \u2026 FROM t WHERE (created_at, id) > ($last_created_at, $last_id) ORDER BY created_at, id LIMIT 50;"
1607
+ }
1608
+ ]
1609
+ },
1610
+ docsUrl: `${DOCS2}/queries-limit.html`,
1611
+ meta: { discarded: Math.round(discarded), emitted: Math.round(emitted) }
1612
+ })
1613
+ ];
1614
+ }
1615
+ };
1616
+
1200
1617
  // src/advisor/rules/low-cache-hit.ts
1201
1618
  var MIN_READ_BLOCKS = 1e3;
1202
1619
  var lowCacheHit = {
@@ -1243,6 +1660,46 @@ var lowCacheHit = {
1243
1660
  }
1244
1661
  };
1245
1662
 
1663
+ // src/advisor/rules/memoize-evictions.ts
1664
+ var memoizeEvictions = {
1665
+ id: "PGX_MEMOIZE_EVICTIONS",
1666
+ title: "Memoize cache is thrashing",
1667
+ defaultSeverity: "warn",
1668
+ requiresAnalyze: true,
1669
+ check(node, ctx) {
1670
+ if (node.nodeType !== "Memoize") return [];
1671
+ const hits = node.cacheHits ?? 0;
1672
+ const evictions = node.cacheEvictions ?? 0;
1673
+ const overflows = node.cacheOverflows ?? 0;
1674
+ const thrashing = evictions > hits;
1675
+ if (!thrashing && overflows === 0) return [];
1676
+ return [
1677
+ makeFinding(memoizeEvictions, ctx, node, {
1678
+ title: overflows > 0 ? `Memoize cache overflowed ${fmtInt(overflows)} time(s)` : `Memoize evicted ${fmtInt(evictions)} entries against ${fmtInt(hits)} hits`,
1679
+ detail: `The Memoize cache recorded ${fmtInt(hits)} hits, ${fmtInt(node.cacheMisses ?? 0)} misses, ${fmtInt(evictions)} evictions, and ${fmtInt(overflows)} overflows \u2014 entries are being thrown away before they can be reused.`,
1680
+ cause: "The distinct key values do not fit in the memory Memoize is allowed (derived from work_mem \xD7 hash_mem_multiplier), so the cache churns and the node degenerates into a slower re-executing inner side.",
1681
+ remediation: {
1682
+ summary: "Give the session more cache memory (work_mem / hash_mem_multiplier) so the key space fits, or reduce the number of distinct keys flowing into the Memoize.",
1683
+ steps: [
1684
+ "Estimate the distinct keys: the planner sizes the cache from ndistinct of the join key.",
1685
+ "Raise work_mem (or hash_mem_multiplier on PG 15+) for this workload and re-run.",
1686
+ "If the key space is genuinely huge, an index on the inner side may beat Memoize \u2014 compare with enable_memoize = off."
1687
+ ],
1688
+ commands: [
1689
+ { label: "More cache memory for this session", sql: "SET work_mem = '64MB';" },
1690
+ {
1691
+ label: "Compare the plan without Memoize",
1692
+ sql: "SET enable_memoize = off; EXPLAIN ANALYZE <query>;"
1693
+ }
1694
+ ]
1695
+ },
1696
+ docsUrl: `${DOCS2}/runtime-config-resource.html`,
1697
+ meta: { hits, evictions, overflows }
1698
+ })
1699
+ ];
1700
+ }
1701
+ };
1702
+
1246
1703
  // src/advisor/rules/nested-loop-large-outer.ts
1247
1704
  var nestedLoopLargeOuter = {
1248
1705
  id: "PGX_NESTED_LOOP_LARGE_OUTER",
@@ -1330,8 +1787,8 @@ var rowMisestimate = {
1330
1787
  const target = rel ?? "the underlying table";
1331
1788
  const under = estimateDirection === "under";
1332
1789
  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}.`;
1790
+ const actual2 = totalRows ?? 0;
1791
+ const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(actual2)} were produced \u2014 a ${fmtInt(factor)}x ${direction}${onRel}.`;
1335
1792
  return [
1336
1793
  makeFinding(rowMisestimate, ctx, node, {
1337
1794
  // Severity: underestimates are the dangerous ones (under-sized joins/memory).
@@ -1366,7 +1823,7 @@ ANALYZE ${rel ?? "<relation>"};`
1366
1823
  docsUrl: `${DOCS2}/planner-stats.html`,
1367
1824
  meta: {
1368
1825
  estimatedRows: Math.round(node.planRows),
1369
- actualRows: Math.round(actual),
1826
+ actualRows: Math.round(actual2),
1370
1827
  factor,
1371
1828
  direction: estimateDirection
1372
1829
  }
@@ -1625,8 +2082,10 @@ var ALL_RULES = [
1625
2082
  seqScanLarge,
1626
2083
  nestedLoopLargeOuter,
1627
2084
  highFilterDiscard,
2085
+ limitLargeOffset,
1628
2086
  sortSpillDisk,
1629
2087
  hashSpillDisk,
2088
+ memoizeEvictions,
1630
2089
  correlatedSubplan,
1631
2090
  rowMisestimate,
1632
2091
  filterCouldBeIndexCond,
@@ -1654,7 +2113,7 @@ function runAdvisor(tree, config = DEFAULT_CONFIG) {
1654
2113
  if (rule.requiresAnalyze && !tree.hasAnalyze) continue;
1655
2114
  if (rule.requiresBuffers && !tree.hasBuffers) continue;
1656
2115
  for (const node of nodes) {
1657
- for (const finding of rule.check(node, ctx)) diagnostics.push(finding);
2116
+ for (const finding2 of rule.check(node, ctx)) diagnostics.push(finding2);
1658
2117
  }
1659
2118
  }
1660
2119
  diagnostics.sort(bySeverity);
@@ -1711,6 +2170,138 @@ function redactPlanTree(tree) {
1711
2170
  walk(tree.root, redactNode);
1712
2171
  }
1713
2172
 
2173
+ // src/locks/advisor.ts
2174
+ var DOCS3 = "https://www.postgresql.org/docs/current";
2175
+ function analyzeLocks(sql, tree) {
2176
+ const code = stripSql(sql);
2177
+ const upper = code.toUpperCase();
2178
+ const kw = (code.trim().split(/\s+/)[0] ?? "").toUpperCase();
2179
+ const out = [];
2180
+ const add = (id, severity, parts) => {
2181
+ out.push({
2182
+ code: id,
2183
+ domain: "plan",
2184
+ severity,
2185
+ title: parts.title,
2186
+ detail: parts.detail,
2187
+ cause: parts.cause,
2188
+ remediation: { summary: parts.fix, commands: parts.commands },
2189
+ docsUrl: `${DOCS3}/explicit-locking.html`
2190
+ });
2191
+ };
2192
+ if (/\bVACUUM\s+FULL\b/.test(upper) || /\bCLUSTER\b/.test(upper) || /\bALTER\s+TABLE\b[\s\S]*\b(TYPE|SET\s+DATA\s+TYPE)\b/.test(upper)) {
2193
+ add("PGX_LOCK_TABLE_REWRITE", "error", {
2194
+ title: "Operation rewrites the table under an ACCESS EXCLUSIVE lock",
2195
+ detail: "VACUUM FULL / CLUSTER / a column-type change rewrites the whole table and holds ACCESS EXCLUSIVE for the duration.",
2196
+ cause: "ACCESS EXCLUSIVE blocks every reader and writer until the rewrite finishes \u2014 an outage on a busy table.",
2197
+ fix: "Avoid the full rewrite: use pg_repack for bloat instead of VACUUM FULL/CLUSTER; for type changes, add a new column, backfill in batches, and swap. Always do rewrites off-peak with a lock_timeout.",
2198
+ commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
2199
+ });
2200
+ }
2201
+ if (/\bCREATE\s+(UNIQUE\s+)?INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
2202
+ add("PGX_DDL_NO_CONCURRENTLY", "warn", {
2203
+ title: "CREATE INDEX without CONCURRENTLY blocks writes",
2204
+ detail: "A plain CREATE INDEX takes a SHARE lock, blocking all writes to the table until the build completes.",
2205
+ cause: "On a large or busy table the build can take minutes, during which inserts/updates/deletes are blocked.",
2206
+ fix: "Build the index online with CONCURRENTLY (note: it cannot run inside a transaction and may leave an INVALID index on failure, which you then drop and recreate).",
2207
+ commands: [{ label: "Build online", sql: "CREATE INDEX CONCURRENTLY ON <table> (<cols>);" }]
2208
+ });
2209
+ }
2210
+ if (/\bDROP\s+INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
2211
+ add("PGX_DROP_INDEX_NO_CONCURRENTLY", "warn", {
2212
+ title: "DROP INDEX without CONCURRENTLY takes ACCESS EXCLUSIVE",
2213
+ detail: "A plain DROP INDEX locks the table with ACCESS EXCLUSIVE.",
2214
+ cause: "Readers and writers block until the drop completes.",
2215
+ fix: "Use DROP INDEX CONCURRENTLY to avoid blocking.",
2216
+ commands: [{ label: "Drop online", sql: "DROP INDEX CONCURRENTLY <index>;" }]
2217
+ });
2218
+ }
2219
+ if (/\bTRUNCATE\b/.test(upper)) {
2220
+ add("PGX_LOCK_TRUNCATE", "info", {
2221
+ title: "TRUNCATE takes an ACCESS EXCLUSIVE lock",
2222
+ detail: "TRUNCATE briefly locks the table with ACCESS EXCLUSIVE.",
2223
+ cause: "It is fast (no row scan) but still blocks all access while it runs and is transactional.",
2224
+ fix: "Fine for maintenance windows; on a hot table, set a lock_timeout so it fails fast instead of queueing behind/ahead of other transactions.",
2225
+ commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
2226
+ });
2227
+ }
2228
+ if (/\bLOCK\s+TABLE\b/.test(upper)) {
2229
+ add("PGX_LOCK_TABLE_EXPLICIT", "info", {
2230
+ title: "Explicit LOCK TABLE",
2231
+ detail: "An explicit LOCK TABLE acquires the named lock mode for the rest of the transaction.",
2232
+ cause: "Holding a strong lock longer than necessary blocks other sessions.",
2233
+ fix: "Use the lowest lock mode that suffices and keep the transaction short."
2234
+ });
2235
+ }
2236
+ if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/.test(upper) && !/\bLIMIT\b/.test(upper)) {
2237
+ add("PGX_SELECT_FOR_UPDATE_UNBOUNDED", "warn", {
2238
+ title: "Row-locking SELECT without a LIMIT",
2239
+ detail: "SELECT \u2026 FOR UPDATE/SHARE locks every row it matches, held until the transaction ends.",
2240
+ cause: "Locking an unbounded set increases contention and deadlock risk with concurrent updaters.",
2241
+ fix: "Bound the set with a deterministic ORDER BY + LIMIT (and process in batches); a consistent lock order also avoids deadlocks.",
2242
+ commands: [{ label: "Bound + order", sql: "SELECT \u2026 ORDER BY id FOR UPDATE LIMIT 100;" }]
2243
+ });
2244
+ }
2245
+ if (kw === "UPDATE" || kw === "DELETE") {
2246
+ if (!/\bWHERE\b/.test(upper)) {
2247
+ add("PGX_WRITE_NO_WHERE", "warn", {
2248
+ title: `${kw} without a WHERE clause locks every row`,
2249
+ detail: `This ${kw} touches the whole table, taking a row lock on every row until commit.`,
2250
+ cause: "All rows are locked for the transaction's duration, blocking concurrent writers and bloating the table.",
2251
+ fix: "Add a WHERE clause; for large rewrites, update in batches (e.g. by primary-key ranges) and commit between batches."
2252
+ });
2253
+ } else if (tree && hasSeqScanOnTarget(tree, targetTable(code, kw))) {
2254
+ const rel = targetTable(code, kw);
2255
+ add("PGX_UPDATE_UNINDEXED_PREDICATE", "warn", {
2256
+ title: `${kw} scans ${rel ?? "the table"} sequentially to find rows`,
2257
+ detail: `The plan uses a Seq Scan on ${rel ?? "the target table"}, so the ${kw} reads (and locks the touched rows of) the whole table.`,
2258
+ cause: "An unindexed predicate means more rows scanned and locked, and the locks are held until commit.",
2259
+ fix: `Index the ${kw}'s WHERE columns so it finds rows via an index and locks only what it changes.`,
2260
+ commands: [
2261
+ {
2262
+ label: "Index the predicate",
2263
+ sql: `CREATE INDEX ON ${rel ?? "<table>"} (<where columns>);`
2264
+ }
2265
+ ]
2266
+ });
2267
+ }
2268
+ }
2269
+ if (/^(ALTER|CREATE|DROP)\b/.test(kw) && !/\bCONCURRENTLY\b/.test(upper) && !/\bSET\s+LOCK_TIMEOUT\b/.test(upper)) {
2270
+ add("PGX_DDL_NO_LOCK_TIMEOUT", "warn", {
2271
+ title: "DDL without a lock_timeout can stall the whole table",
2272
+ detail: "This DDL needs a strong lock; if it waits behind a long transaction, every query that arrives after it also queues behind the DDL.",
2273
+ cause: "A blocked ACCESS EXCLUSIVE request sits at the head of the lock queue and blocks new readers/writers too.",
2274
+ fix: "Set a short lock_timeout before the DDL and retry, so it fails fast instead of forming a queue.",
2275
+ commands: [
2276
+ {
2277
+ label: "Fail fast, then retry",
2278
+ sql: "SET lock_timeout = '3s';\n-- run the DDL; on timeout, retry later"
2279
+ }
2280
+ ]
2281
+ });
2282
+ }
2283
+ return out;
2284
+ }
2285
+ function stripSql(sql) {
2286
+ return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '"x"');
2287
+ }
2288
+ function targetTable(code, kw) {
2289
+ const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i;
2290
+ const m = re.exec(code);
2291
+ return m?.[1];
2292
+ }
2293
+ function hasSeqScanOnTarget(tree, table) {
2294
+ let found = false;
2295
+ walk(tree.root, (n) => {
2296
+ if (n.nodeType === "Seq Scan" && (!table || n.relationName === bareName(table))) found = true;
2297
+ });
2298
+ return found;
2299
+ }
2300
+ function bareName(qualified) {
2301
+ const parts = qualified.split(".");
2302
+ return parts[parts.length - 1] ?? qualified;
2303
+ }
2304
+
1714
2305
  // src/report/tree.ts
1715
2306
  function treeLines(tree, glyphs) {
1716
2307
  const lines = [];
@@ -1751,21 +2342,28 @@ function nodeSummary(node) {
1751
2342
  // src/report/json.ts
1752
2343
  var JSON_SCHEMA_VERSION = 1;
1753
2344
  function renderJson(result, pretty = true) {
2345
+ return JSON.stringify(buildReport(result), null, pretty ? 2 : 0);
2346
+ }
2347
+ function buildReport(result) {
1754
2348
  const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
1755
2349
  const counts = { error: 0, warn: 0, info: 0 };
1756
2350
  for (const d of diagnostics) counts[d.severity]++;
1757
- const report = {
2351
+ return {
1758
2352
  schemaVersion: JSON_SCHEMA_VERSION,
1759
2353
  verdict: result.verdict,
1760
2354
  worstSeverity: result.worstSeverity,
1761
2355
  summary: {
1762
2356
  planningTimeMs: tree.planningTime ?? null,
1763
2357
  executionTimeMs: executionMs(tree) ?? null,
2358
+ serializationTimeMs: tree.serializationTime ?? null,
1764
2359
  hasAnalyze: tree.hasAnalyze,
1765
2360
  hasBuffers: tree.hasBuffers,
1766
2361
  nodeCount: flatten(tree.root).length,
1767
2362
  findings: counts
1768
2363
  },
2364
+ triggers: tree.triggers,
2365
+ jit: tree.jit ?? null,
2366
+ settings: tree.settings ?? null,
1769
2367
  diagnostics,
1770
2368
  bottlenecks: bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0).map((n) => ({
1771
2369
  id: n.id,
@@ -1776,9 +2374,9 @@ function renderJson(result, pretty = true) {
1776
2374
  pctOfTotal: n.metrics.pctOfTotal ?? null,
1777
2375
  totalRows: n.metrics.totalRows ?? null
1778
2376
  })),
2377
+ stats: aggregateStats(tree),
1779
2378
  plan: serializeNode(tree.root)
1780
2379
  };
1781
- return JSON.stringify(report, null, pretty ? 2 : 0);
1782
2380
  }
1783
2381
  function serializeNode(node) {
1784
2382
  const { children, metrics, raw, ...fields } = node;
@@ -2074,14 +2672,15 @@ function render(result, opts) {
2074
2672
 
2075
2673
  // src/index.ts
2076
2674
  function analyze(input, options = {}) {
2077
- const trees = parseExplainJson(input);
2675
+ const trees = parseExplain(input);
2078
2676
  const tree = selectStatement(trees, options.statement);
2079
2677
  if (options.redact) redactPlanTree(tree);
2080
2678
  computeMetrics(tree);
2081
2679
  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);
2680
+ const extra = planNotices(tree);
2681
+ if (options.sql) extra.push(...analyzeLocks(options.sql, tree));
2682
+ if (extra.length) {
2683
+ result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
2085
2684
  result.worstSeverity = result.diagnostics.reduce(
2086
2685
  (worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
2087
2686
  null
@@ -2163,6 +2762,50 @@ async function resolvePlanInput(file) {
2163
2762
  if (!text.trim()) throw opError("PGX_EMPTY_INPUT");
2164
2763
  return text;
2165
2764
  }
2765
+
2766
+ // src/util/log.ts
2767
+ var level = "normal";
2768
+ function setLogLevel(l) {
2769
+ level = l;
2770
+ }
2771
+ function isDebug() {
2772
+ return level === "debug";
2773
+ }
2774
+ function write(msg) {
2775
+ process.stderr.write(`${msg}
2776
+ `);
2777
+ }
2778
+ function logInfo(msg) {
2779
+ if (level !== "quiet") write(msg);
2780
+ }
2781
+ function logVerbose(msg) {
2782
+ if (level === "verbose" || level === "debug") write(msg);
2783
+ }
2784
+ function logError(msg) {
2785
+ write(msg);
2786
+ }
2787
+ function browserCommand(platform) {
2788
+ if (platform === "darwin") return "open";
2789
+ if (platform === "win32") return "start";
2790
+ return "xdg-open";
2791
+ }
2792
+ function openInBrowser(target, platform = process.platform) {
2793
+ const cmd = browserCommand(platform);
2794
+ try {
2795
+ const child = spawn(cmd, [target], {
2796
+ stdio: "ignore",
2797
+ detached: true,
2798
+ shell: platform === "win32"
2799
+ // `start` is a shell builtin
2800
+ });
2801
+ child.on("error", () => {
2802
+ });
2803
+ child.unref();
2804
+ } catch {
2805
+ }
2806
+ }
2807
+
2808
+ // src/commands/emit.ts
2166
2809
  async function emit(result, opts) {
2167
2810
  configureColor(opts.format === "terminal" ? opts.color : "never");
2168
2811
  const text = render(result, {
@@ -2173,6 +2816,12 @@ async function emit(result, opts) {
2173
2816
  });
2174
2817
  if (opts.output) {
2175
2818
  await writeFile(opts.output, text);
2819
+ if (opts.format === "html" && opts.openHtml) openInBrowser(opts.output);
2820
+ } else if (opts.format === "html" && opts.openHtml) {
2821
+ const file = join(tmpdir(), `pg-explain-${Date.now()}.html`);
2822
+ await writeFile(file, text);
2823
+ logInfo(`Opened HTML report: ${file}`);
2824
+ openInBrowser(file);
2176
2825
  } else {
2177
2826
  process.stdout.write(text.endsWith("\n") ? text : `${text}
2178
2827
  `);
@@ -2253,7 +2902,7 @@ function gateTrips(result, failOn) {
2253
2902
  }
2254
2903
 
2255
2904
  // src/commands/completion.ts
2256
- var SUBCOMMANDS = "run diff completion";
2905
+ var SUBCOMMANDS = "run diff locks studio completion";
2257
2906
  var FLAGS = "--format --output --tldr --redact --ascii --color --no-color --fail-on --strict --config --statement --quiet --verbose --debug --help --version";
2258
2907
  var FORMATS2 = "terminal markdown json html text";
2259
2908
  var BASH = `# pg-explain bash completion
@@ -2499,28 +3148,6 @@ async function readPlan(path) {
2499
3148
  }
2500
3149
  }
2501
3150
 
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
3151
  // src/db/version.ts
2525
3152
  function capabilities(versionNum) {
2526
3153
  const major = Math.floor(versionNum / 1e4);
@@ -2742,6 +3369,86 @@ async function runExplain(opts) {
2742
3369
  function msInt(ms) {
2743
3370
  return Math.max(0, Math.floor(ms));
2744
3371
  }
3372
+ async function queryReadOnly(connection, sql, params = [], timeoutMs = 1e4) {
3373
+ const ca = connection.sslrootcert ? await readFile(connection.sslrootcert, "utf8").catch(() => void 0) : void 0;
3374
+ const client = await newClient(buildClientConfig(connection, ca));
3375
+ try {
3376
+ await client.connect();
3377
+ } catch (err) {
3378
+ throw mapConnectError(err);
3379
+ }
3380
+ try {
3381
+ await client.query(`SET statement_timeout = ${msInt(timeoutMs)}`);
3382
+ const res = await client.query({ text: sql, values: params });
3383
+ return res.rows;
3384
+ } catch (err) {
3385
+ throw mapQueryError(err);
3386
+ } finally {
3387
+ await client.end().catch(() => {
3388
+ });
3389
+ }
3390
+ }
3391
+ async function explainScript(connection, units, opts) {
3392
+ const ca = connection.sslrootcert ? await readFile(connection.sslrootcert, "utf8").catch(() => void 0) : void 0;
3393
+ const client = await newClient(buildClientConfig(connection, ca));
3394
+ try {
3395
+ await client.connect();
3396
+ } catch (err) {
3397
+ throw mapConnectError(err);
3398
+ }
3399
+ try {
3400
+ const caps = capabilities(await fetchVersionNum(client));
3401
+ await client.query("BEGIN");
3402
+ const results = [];
3403
+ try {
3404
+ await client.query(`SET LOCAL statement_timeout = ${msInt(opts.statementTimeoutMs)}`);
3405
+ await client.query(`SET LOCAL lock_timeout = ${msInt(opts.lockTimeoutMs)}`);
3406
+ await client.query("SET LOCAL transaction_read_only = on");
3407
+ for (const unit of units) {
3408
+ const flags = {
3409
+ analyze: false,
3410
+ // never execute
3411
+ buffers: false,
3412
+ // BUFFERS requires ANALYZE pre-16
3413
+ verbose: opts.verbose ?? false,
3414
+ settings: opts.settings ?? false,
3415
+ wal: false,
3416
+ timing: false,
3417
+ costs: true,
3418
+ summary: false,
3419
+ genericPlan: caps.genericPlan && /\$\d+/.test(unit.sql),
3420
+ compat: true
3421
+ // auto-omit anything the server is too old for
3422
+ };
3423
+ try {
3424
+ const { prefix } = buildExplain(flags, caps);
3425
+ const res = await client.query(`${prefix} ${unit.sql}`);
3426
+ const r = { label: unit.label, planJson: extractPlanJson(res.rows) };
3427
+ if (unit.loopNote) r.loopNote = unit.loopNote;
3428
+ results.push(r);
3429
+ } catch (err) {
3430
+ const diag = err instanceof AppError ? err.diagnostic : mapQueryError(err).diagnostic;
3431
+ const r = { label: unit.label, error: diag };
3432
+ if (unit.loopNote) r.loopNote = unit.loopNote;
3433
+ results.push(r);
3434
+ await client.query("ROLLBACK").catch(() => {
3435
+ });
3436
+ await client.query("BEGIN").catch(() => {
3437
+ });
3438
+ await client.query("SET LOCAL transaction_read_only = on").catch(() => {
3439
+ });
3440
+ }
3441
+ }
3442
+ return { units: results, caps };
3443
+ } finally {
3444
+ await client.query("ROLLBACK").catch(() => {
3445
+ });
3446
+ }
3447
+ } finally {
3448
+ await client.end().catch(() => {
3449
+ });
3450
+ }
3451
+ }
2745
3452
  async function fetchVersionNum(client) {
2746
3453
  try {
2747
3454
  const res = await client.query("SHOW server_version_num");
@@ -2791,7 +3498,9 @@ function mapQueryError(err) {
2791
3498
  if (err instanceof AppError) return err;
2792
3499
  const e = asPgError(err);
2793
3500
  const msg = e.message ?? "";
2794
- const meta = e.code ? { sqlState: e.code } : void 0;
3501
+ const meta = {};
3502
+ if (e.code) meta.sqlState = e.code;
3503
+ if (e.position) meta.position = Number(e.position);
2795
3504
  switch (e.code) {
2796
3505
  case "57014":
2797
3506
  return /statement timeout/i.test(msg) ? opError("PGX_STATEMENT_TIMEOUT", { detail: msg, meta }, err) : opError("PGX_QUERY_CANCELED", { detail: msg, meta }, err);
@@ -2811,34 +3520,501 @@ function mapQueryError(err) {
2811
3520
  }
2812
3521
  }
2813
3522
 
3523
+ // src/locks/live.ts
3524
+ var SQL = `
3525
+ SELECT a.pid,
3526
+ a.usename AS "user",
3527
+ a.state,
3528
+ a.wait_event_type AS "waitEventType",
3529
+ a.wait_event AS "waitEvent",
3530
+ EXTRACT(EPOCH FROM (now() - a.query_start)) AS "ageSeconds",
3531
+ a.query,
3532
+ pg_blocking_pids(a.pid) AS "blockedBy"
3533
+ FROM pg_stat_activity a
3534
+ WHERE a.backend_type = 'client backend' AND a.pid <> pg_backend_pid()
3535
+ ORDER BY cardinality(pg_blocking_pids(a.pid)) DESC, a.query_start NULLS LAST;
3536
+ `;
3537
+ async function liveLocks(connection, capturedAt) {
3538
+ const rows = await queryReadOnly(connection, SQL);
3539
+ const sessions = rows.map((r) => ({
3540
+ pid: r.pid,
3541
+ user: r.user,
3542
+ state: r.state,
3543
+ waitEventType: r.waitEventType,
3544
+ waitEvent: r.waitEvent,
3545
+ ageSeconds: r.ageSeconds == null ? null : Number(r.ageSeconds),
3546
+ query: r.query,
3547
+ blockedBy: r.blockedBy ?? []
3548
+ }));
3549
+ return { sessions, blocked: sessions.filter((s) => s.blockedBy.length > 0), capturedAt };
3550
+ }
3551
+
3552
+ // src/commands/locks.ts
3553
+ async function runLocks(args) {
3554
+ const snapshot = await liveLocks(args.connection, Date.now());
3555
+ configureColor(args.format === "terminal" ? args.color : "never");
3556
+ const text = args.format === "json" ? `${JSON.stringify(snapshot, null, 2)}
3557
+ ` : renderLocks(snapshot);
3558
+ if (args.output) await writeFile(args.output, text);
3559
+ else process.stdout.write(text);
3560
+ return args.failOnBlocked && snapshot.blocked.length > 0 ? 1 /* CiGate */ : 0 /* Success */;
3561
+ }
3562
+ function renderLocks(live) {
3563
+ const c = colors();
3564
+ const out = [
3565
+ c.bold("Live locks"),
3566
+ c.dim(`${live.sessions.length} client session(s) \xB7 ${live.blocked.length} blocked`),
3567
+ ""
3568
+ ];
3569
+ if (live.blocked.length === 0) {
3570
+ out.push("No lock contention right now \u2014 nothing is waiting on another session.");
3571
+ return `${out.join("\n")}
3572
+ `;
3573
+ }
3574
+ for (const s of live.blocked) {
3575
+ const age = s.ageSeconds != null ? ` \xB7 waiting ${s.ageSeconds.toFixed(0)}s` : "";
3576
+ const wait = s.waitEvent ? ` \xB7 ${s.waitEventType ?? "?"}/${s.waitEvent}` : "";
3577
+ out.push(
3578
+ `${c.yellow("\u26A0")} pid ${c.bold(String(s.pid))} (${s.user ?? "?"}) blocked by pid ${s.blockedBy.join(", ")}${age}${wait}`
3579
+ );
3580
+ if (s.query) out.push(c.dim(` ${s.query.replace(/\s+/g, " ").slice(0, 200)}`));
3581
+ out.push(
3582
+ c.dim(
3583
+ ` inspect the blocker; cancel with SELECT pg_cancel_backend(${s.blockedBy[0]}); or terminate with pg_terminate_backend(\u2026).`
3584
+ ),
3585
+ ""
3586
+ );
3587
+ }
3588
+ return `${out.join("\n")}
3589
+ `;
3590
+ }
3591
+
3592
+ // src/server/schema.ts
3593
+ var SQL2 = `
3594
+ SELECT c.relname AS relation,
3595
+ c.reltuples::bigint AS "estRows",
3596
+ pg_total_relation_size(c.oid) AS "totalBytes",
3597
+ pg_relation_size(c.oid) AS "tableBytes",
3598
+ (SELECT array_agg(ir.relname::text ORDER BY ir.relname)
3599
+ FROM pg_index i JOIN pg_class ir ON ir.oid = i.indexrelid
3600
+ WHERE i.indrelid = c.oid) AS indexes,
3601
+ s.last_vacuum AS "lastVacuum",
3602
+ s.last_autovacuum AS "lastAutovacuum",
3603
+ s.last_analyze AS "lastAnalyze",
3604
+ s.last_autoanalyze AS "lastAutoanalyze",
3605
+ s.n_mod_since_analyze AS "modSinceAnalyze",
3606
+ s.n_live_tup AS "liveTup"
3607
+ FROM pg_class c
3608
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
3609
+ WHERE c.relkind IN ('r', 'p') AND c.relname = ANY($1);
3610
+ `;
3611
+ var toNum = (v) => v == null ? null : Number(v);
3612
+ async function relationStats(connection, relations) {
3613
+ const names = [...new Set(relations.filter(Boolean))];
3614
+ if (names.length === 0) return [];
3615
+ const rows = await queryReadOnly(connection, SQL2, [names]);
3616
+ return rows.map((r) => ({
3617
+ relation: r.relation,
3618
+ estRows: toNum(r.estRows),
3619
+ totalBytes: toNum(r.totalBytes),
3620
+ tableBytes: toNum(r.tableBytes),
3621
+ indexes: r.indexes ?? [],
3622
+ lastVacuum: r.lastVacuum,
3623
+ lastAutovacuum: r.lastAutovacuum,
3624
+ lastAnalyze: r.lastAnalyze,
3625
+ lastAutoanalyze: r.lastAutoanalyze,
3626
+ modSinceAnalyze: toNum(r.modSinceAnalyze),
3627
+ liveTup: toNum(r.liveTup)
3628
+ }));
3629
+ }
3630
+
3631
+ // src/diagnostics/stale-stats.ts
3632
+ var RULE_ID = "PGX_STALE_STATISTICS";
3633
+ var DOCS4 = "https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-STATISTICS";
3634
+ var MIN_ROWS = 1e3;
3635
+ function staleStatsFindings(stats, config) {
3636
+ if (config.rules[RULE_ID]?.enabled === false) return [];
3637
+ const severity = config.rules[RULE_ID]?.severity ?? "warn";
3638
+ const ratioLimit = config.thresholds.staleStatsModRatio;
3639
+ const out = [];
3640
+ for (const s of stats) {
3641
+ const rows = s.liveTup ?? s.estRows ?? 0;
3642
+ if (rows < MIN_ROWS) continue;
3643
+ const neverAnalyzed = !s.lastAnalyze && !s.lastAutoanalyze;
3644
+ const modRatio = s.modSinceAnalyze != null && rows > 0 ? s.modSinceAnalyze / rows : 0;
3645
+ if (!neverAnalyzed && modRatio < ratioLimit) continue;
3646
+ out.push(
3647
+ finding(RULE_ID, severity, {
3648
+ title: neverAnalyzed ? `Table ${s.relation} has never been analyzed` : `Planner statistics on ${s.relation} are stale`,
3649
+ detail: neverAnalyzed ? `${s.relation} (~${Math.round(rows).toLocaleString()} rows) has no planner statistics \u2014 pg_stat_user_tables shows no manual or auto ANALYZE.` : `${s.modSinceAnalyze?.toLocaleString()} rows of ${s.relation} changed since its last ANALYZE (${(modRatio * 100).toFixed(0)}% of ~${Math.round(rows).toLocaleString()} live rows).`,
3650
+ cause: "The planner chooses plans from per-table statistics. When they are missing or stale, row estimates drift, which cascades into bad join orders, wrong scan types, and misestimates like PGX_ROW_MISESTIMATE.",
3651
+ remediation: {
3652
+ summary: `Run ANALYZE on ${s.relation}, and if it keeps going stale, lower its autovacuum analyze threshold.`,
3653
+ steps: [
3654
+ "ANALYZE the table now to refresh statistics.",
3655
+ "If the table churns heavily, tune per-table autovacuum settings so auto-analyze keeps up."
3656
+ ],
3657
+ commands: [
3658
+ { label: "Refresh statistics", sql: `ANALYZE ${s.relation};` },
3659
+ {
3660
+ label: "Analyze more eagerly on churny tables",
3661
+ sql: `ALTER TABLE ${s.relation} SET (autovacuum_analyze_scale_factor = 0.02);`
3662
+ }
3663
+ ]
3664
+ },
3665
+ docsUrl: DOCS4,
3666
+ meta: {
3667
+ relation: s.relation,
3668
+ modSinceAnalyze: s.modSinceAnalyze ?? 0,
3669
+ liveTup: s.liveTup ?? 0
3670
+ }
3671
+ })
3672
+ );
3673
+ }
3674
+ return out;
3675
+ }
3676
+ async function checkStaleStats(connection, result, config) {
3677
+ try {
3678
+ const relations = [
3679
+ ...new Set(
3680
+ flatten(result.tree.root).map((n) => n.relationName).filter((r) => !!r)
3681
+ )
3682
+ ];
3683
+ if (!relations.length) return;
3684
+ appendFindings(result, staleStatsFindings(await relationStats(connection, relations), config));
3685
+ } catch {
3686
+ }
3687
+ }
3688
+ function appendFindings(result, extra) {
3689
+ if (!extra.length) return;
3690
+ result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
3691
+ result.worstSeverity = result.diagnostics.reduce(
3692
+ (worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
3693
+ null
3694
+ );
3695
+ }
3696
+
3697
+ // src/sql/extract.ts
3698
+ var DML = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "VALUES", "TABLE", "WITH"]);
3699
+ function classifyStatement(sql) {
3700
+ const kw = firstKeyword(sql);
3701
+ if (!kw) return "empty";
3702
+ if (kw === "DO") return "do-block";
3703
+ if (DML.has(kw) || kw === "EXECUTE") return "explainable";
3704
+ return "utility";
3705
+ }
3706
+ function extractAnalyzableUnits(sql) {
3707
+ const units = [];
3708
+ for (const stmt of splitStatements(sql)) {
3709
+ const cls = classifyStatement(stmt);
3710
+ if (cls === "empty") continue;
3711
+ if (cls === "explainable") {
3712
+ units.push({ kind: "explainable", label: unitLabel(stmt), sql: stmt });
3713
+ } else if (cls === "do-block") {
3714
+ units.push(...extractFromDo(stmt));
3715
+ } else {
3716
+ const kw = firstKeyword(stmt);
3717
+ units.push({
3718
+ kind: "skipped",
3719
+ label: `${kw} \u2026`,
3720
+ reason: `EXPLAIN cannot analyze a ${kw} statement (it is a utility/transaction-control command, not an optimizable query).`
3721
+ });
3722
+ }
3723
+ }
3724
+ return units;
3725
+ }
3726
+ function extractFromDo(doSql) {
3727
+ const body = dollarBody(doSql);
3728
+ if (body === null) {
3729
+ return [
3730
+ { kind: "skipped", label: "DO block", reason: "Could not find the block body ($$ \u2026 $$)." }
3731
+ ];
3732
+ }
3733
+ const out = [];
3734
+ for (const frag of splitStatements(body)) {
3735
+ const stripped = stripControl(frag);
3736
+ if (!stripped) continue;
3737
+ const kw = firstKeyword(stripped.sql);
3738
+ if (kw === "EXECUTE") {
3739
+ out.push({
3740
+ kind: "skipped",
3741
+ label: `${stripped.context}EXECUTE (dynamic SQL)`,
3742
+ reason: "Dynamic SQL built at runtime \u2014 the statement text isn't known statically, so it can't be analyzed."
3743
+ });
3744
+ continue;
3745
+ }
3746
+ if (DML.has(kw)) {
3747
+ const unit = {
3748
+ kind: "explainable",
3749
+ label: stripped.context + unitLabel(stripped.sql),
3750
+ sql: stripped.sql
3751
+ };
3752
+ if (stripped.loop) unit.loopNote = "runs once per loop iteration in the block";
3753
+ out.push(unit);
3754
+ }
3755
+ }
3756
+ if (out.length === 0) {
3757
+ out.push({
3758
+ kind: "skipped",
3759
+ label: "DO block",
3760
+ reason: "No top-level DML statements found to analyze."
3761
+ });
3762
+ }
3763
+ return out;
3764
+ }
3765
+ function stripControl(frag) {
3766
+ let rest = frag;
3767
+ let context = "";
3768
+ let loop = false;
3769
+ for (let guard = 0; guard < 8; guard++) {
3770
+ rest = rest.replace(/^\s+/, "");
3771
+ const masked = maskNonCode(rest);
3772
+ const kw2 = (/^[A-Za-z_]+/.exec(masked)?.[0] ?? "").toUpperCase();
3773
+ if (kw2 === "IF" || kw2 === "ELSIF") {
3774
+ const then = /\bTHEN\b/i.exec(masked);
3775
+ if (!then) break;
3776
+ context += kw2 === "IF" ? "IF-branch \u203A " : "ELSIF-branch \u203A ";
3777
+ rest = rest.slice(then.index + 4);
3778
+ } else if (kw2 === "ELSE") {
3779
+ context += "ELSE-branch \u203A ";
3780
+ rest = rest.replace(/^\s*ELSE\b/i, "");
3781
+ } else if (kw2 === "FOR" || kw2 === "WHILE") {
3782
+ const lp = /\bLOOP\b/i.exec(masked);
3783
+ if (!lp) break;
3784
+ loop = true;
3785
+ context += "loop \u203A ";
3786
+ rest = rest.slice(lp.index + 4);
3787
+ } else if (kw2 === "LOOP") {
3788
+ loop = true;
3789
+ context += "loop \u203A ";
3790
+ rest = rest.replace(/^\s*LOOP\b/i, "");
3791
+ } else if (kw2 === "BEGIN" || kw2 === "THEN") {
3792
+ rest = rest.replace(/^\s*(BEGIN|THEN)\b/i, "");
3793
+ } else {
3794
+ break;
3795
+ }
3796
+ }
3797
+ rest = rest.replace(/^\s+/, "").replace(/;\s*$/, "").trim();
3798
+ if (!rest) return null;
3799
+ const kw = firstKeyword(rest);
3800
+ if (kw === "PERFORM") return { context, sql: rest.replace(/^\s*PERFORM\b/i, "SELECT"), loop };
3801
+ if (DML.has(kw) || kw === "EXECUTE") return { context, sql: rest, loop };
3802
+ return null;
3803
+ }
3804
+ function firstKeyword(sql) {
3805
+ const m = /^[A-Za-z_]+/.exec(maskNonCode(sql).trim());
3806
+ return m ? m[0].toUpperCase() : "";
3807
+ }
3808
+ function unitLabel(stmt) {
3809
+ const kw = firstKeyword(stmt);
3810
+ const t = targetTable2(stmt, kw);
3811
+ return t ? `${kw} ${t}` : kw || "statement";
3812
+ }
3813
+ function targetTable2(stmt, kw) {
3814
+ const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : kw === "INSERT" ? /\bINSERT\s+INTO\s+([A-Za-z_][\w.]*)/i : kw === "UPDATE" ? /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i : void 0;
3815
+ return re ? re.exec(stmt)?.[1] ?? void 0 : void 0;
3816
+ }
3817
+ function dollarBody(doSql) {
3818
+ const m = /\$([A-Za-z_]*)\$/.exec(doSql);
3819
+ if (!m) return null;
3820
+ const tag = m[0];
3821
+ const start = m.index + tag.length;
3822
+ const end = doSql.indexOf(tag, start);
3823
+ return end < 0 ? null : doSql.slice(start, end);
3824
+ }
3825
+ function maskNonCode(sql) {
3826
+ const out = sql.split("");
3827
+ const n = sql.length;
3828
+ let i = 0;
3829
+ const blank = (a, b) => {
3830
+ for (let k = a; k < b && k < n; k++) if (out[k] !== "\n") out[k] = " ";
3831
+ };
3832
+ while (i < n) {
3833
+ const two = sql.slice(i, i + 2);
3834
+ if (two === "--") {
3835
+ let j = sql.indexOf("\n", i);
3836
+ if (j < 0) j = n;
3837
+ blank(i, j);
3838
+ i = j;
3839
+ } else if (two === "/*") {
3840
+ let j = sql.indexOf("*/", i + 2);
3841
+ j = j < 0 ? n : j + 2;
3842
+ blank(i, j);
3843
+ i = j;
3844
+ } else if (sql[i] === "'" || sql[i] === '"') {
3845
+ const q = sql[i];
3846
+ let j = i + 1;
3847
+ while (j < n) {
3848
+ if (sql[j] === q) {
3849
+ if (sql[j + 1] === q) j += 2;
3850
+ else {
3851
+ j++;
3852
+ break;
3853
+ }
3854
+ } else j++;
3855
+ }
3856
+ blank(i, j);
3857
+ i = j;
3858
+ } else if (sql[i] === "$") {
3859
+ const m = /^\$[A-Za-z_]*\$/.exec(sql.slice(i));
3860
+ if (m) {
3861
+ const tag = m[0];
3862
+ let j = sql.indexOf(tag, i + tag.length);
3863
+ j = j < 0 ? n : j + tag.length;
3864
+ blank(i, j);
3865
+ i = j;
3866
+ } else i++;
3867
+ } else i++;
3868
+ }
3869
+ return out.join("");
3870
+ }
3871
+ async function analyzeScript(connection, sql, opts) {
3872
+ const units = extractAnalyzableUnits(sql);
3873
+ const explainable = units.filter((u) => u.kind === "explainable");
3874
+ const exec = explainable.length ? await explainScript(
3875
+ connection,
3876
+ explainable.map((u) => ({
3877
+ label: u.label,
3878
+ sql: u.sql,
3879
+ ...u.loopNote ? { loopNote: u.loopNote } : {}
3880
+ })),
3881
+ {
3882
+ statementTimeoutMs: opts.statementTimeoutMs,
3883
+ lockTimeoutMs: opts.lockTimeoutMs,
3884
+ verbose: opts.verbose,
3885
+ settings: opts.settings
3886
+ }
3887
+ ) : null;
3888
+ let ei = 0;
3889
+ const out = units.map((u) => {
3890
+ if (u.kind === "skipped") return { label: u.label, status: "skipped", reason: u.reason };
3891
+ const r = exec?.units[ei++];
3892
+ if (!r) return { label: u.label, status: "skipped", reason: "not analyzed" };
3893
+ if (r.error) {
3894
+ return {
3895
+ label: u.label,
3896
+ status: "error",
3897
+ reason: r.error.detail,
3898
+ errorCode: r.error.code,
3899
+ ...r.loopNote ? { loopNote: r.loopNote } : {}
3900
+ };
3901
+ }
3902
+ const result = analyze(r.planJson, {
3903
+ sql: u.sql,
3904
+ config: opts.config,
3905
+ redact: opts.redact
3906
+ });
3907
+ return {
3908
+ label: u.label,
3909
+ status: "analyzed",
3910
+ result,
3911
+ report: buildReport(result),
3912
+ ...r.loopNote ? { loopNote: r.loopNote } : {}
3913
+ };
3914
+ });
3915
+ return { executed: false, units: out, ...exec ? { serverMajor: exec.caps.major } : {} };
3916
+ }
3917
+ async function emitScript(analysis, opts) {
3918
+ configureColor(opts.format === "terminal" ? opts.color : "never");
3919
+ const text = opts.format === "json" ? renderJsonScript(analysis, opts.pretty ?? true) : renderTextScript(analysis, opts);
3920
+ if (opts.output) await writeFile(opts.output, text);
3921
+ else process.stdout.write(text.endsWith("\n") ? text : `${text}
3922
+ `);
3923
+ if (opts.failOn) {
3924
+ for (const u of analysis.units) {
3925
+ if (u.result?.worstSeverity && severityAtLeast(u.result.worstSeverity, opts.failOn))
3926
+ return 1 /* CiGate */;
3927
+ }
3928
+ }
3929
+ return 0 /* Success */;
3930
+ }
3931
+ function renderJsonScript(analysis, pretty) {
3932
+ const units = analysis.units.map((u) => ({
3933
+ label: u.label,
3934
+ status: u.status,
3935
+ loopNote: u.loopNote ?? null,
3936
+ report: u.report ?? null,
3937
+ reason: u.reason ?? null,
3938
+ errorCode: u.errorCode ?? null
3939
+ }));
3940
+ return JSON.stringify(
3941
+ { executed: false, serverMajor: analysis.serverMajor ?? null, units },
3942
+ null,
3943
+ pretty ? 2 : 0
3944
+ );
3945
+ }
3946
+ function renderTextScript(analysis, opts) {
3947
+ const c = colors();
3948
+ const analyzed = analysis.units.filter((u) => u.status === "analyzed").length;
3949
+ const skipped = analysis.units.length - analyzed;
3950
+ const out = [];
3951
+ out.push(
3952
+ c.bold("Cost-only analysis \u2014 nothing was executed.") + c.dim(` ${analyzed} analyzed, ${skipped} skipped/failed.`)
3953
+ );
3954
+ out.push("");
3955
+ for (const u of analysis.units) {
3956
+ out.push(c.bold(`\u25B8 ${u.label}`) + (u.loopNote ? c.dim(` (${u.loopNote})`) : ""));
3957
+ if (u.status === "analyzed" && u.result) {
3958
+ out.push(render(u.result, { format: opts.format, tldr: opts.tldr, ascii: opts.ascii }));
3959
+ } else {
3960
+ const tag = u.status === "error" ? c.yellow("could not analyze") : c.dim("skipped");
3961
+ out.push(
3962
+ ` ${tag}: ${u.reason ?? "(no detail)"}${u.errorCode ? c.dim(` [${u.errorCode}]`) : ""}`
3963
+ );
3964
+ }
3965
+ out.push("");
3966
+ }
3967
+ return out.join("\n").trimEnd();
3968
+ }
3969
+
2814
3970
  // src/commands/run.ts
2815
3971
  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.`
3972
+ const fullSql = await resolveSql(args);
3973
+ const sql = args.statementIndex !== void 0 ? selectStatement2(splitStatements(fullSql), args.statementIndex) : fullSql;
3974
+ const units = extractAnalyzableUnits(sql);
3975
+ const single = units.length === 1 && units[0]?.kind === "explainable" ? units[0] : null;
3976
+ const measured = single?.kind === "explainable" && args.flags.analyze && !args.flags.genericPlan && (isReadOnlyStatement(single.sql) || args.forceWrite);
3977
+ if (measured && single) {
3978
+ const result = await runExplain({
3979
+ connection: args.connection,
3980
+ statement: single.sql,
3981
+ params: args.params,
3982
+ flags: args.flags,
3983
+ statementTimeoutMs: args.statementTimeoutMs,
3984
+ lockTimeoutMs: args.lockTimeoutMs,
3985
+ forceWrite: args.forceWrite,
3986
+ rollback: args.rollback
2823
3987
  });
3988
+ if (result.omitted.length) {
3989
+ logInfo(
3990
+ `Note: server is PostgreSQL ${result.caps.major}; skipped unsupported option(s): ${result.omitted.join(", ")}.`
3991
+ );
3992
+ }
3993
+ const analysis2 = analyze(result.json, {
3994
+ config: args.config,
3995
+ redact: args.redact,
3996
+ sql: single.sql
3997
+ });
3998
+ await checkStaleStats(args.connection, analysis2, args.config);
3999
+ return emit(analysis2, args);
2824
4000
  }
2825
- const result = await runExplain({
2826
- connection: args.connection,
2827
- statement,
2828
- params: args.params,
2829
- flags: args.flags,
4001
+ const analysis = await analyzeScript(args.connection, sql, {
4002
+ config: args.config,
4003
+ redact: args.redact,
2830
4004
  statementTimeoutMs: args.statementTimeoutMs,
2831
4005
  lockTimeoutMs: args.lockTimeoutMs,
2832
- forceWrite: args.forceWrite,
2833
- rollback: args.rollback
4006
+ verbose: args.flags.verbose,
4007
+ settings: args.flags.settings
4008
+ });
4009
+ return emitScript(analysis, {
4010
+ format: args.format,
4011
+ output: args.output,
4012
+ color: args.color,
4013
+ ascii: args.ascii,
4014
+ tldr: args.tldr,
4015
+ pretty: args.pretty,
4016
+ failOn: args.failOn
2834
4017
  });
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
4018
  }
2843
4019
  async function resolveSql(args) {
2844
4020
  if (args.query) return args.query;
@@ -2880,6 +4056,35 @@ function selectStatement2(statements, index) {
2880
4056
  return statements[0];
2881
4057
  }
2882
4058
 
4059
+ // src/commands/studio.ts
4060
+ async function runStudio(args) {
4061
+ const loopback = args.host === "127.0.0.1" || args.host === "localhost" || args.host === "::1";
4062
+ if (!loopback && !args.unsafeHost) {
4063
+ logInfo(
4064
+ `Refusing to bind ${args.host}: the studio can connect to arbitrary databases, so exposing it off-loopback is an SSRF/credential risk. Pass --unsafe-host to override.`
4065
+ );
4066
+ return 2 /* Usage */;
4067
+ }
4068
+ const mod = await import(new URL("./server.js", import.meta.url).href);
4069
+ const server = await mod.startStudio({ host: args.host, port: args.port });
4070
+ logInfo(`
4071
+ pgexplain studio ${server.url}
4072
+ Press Ctrl-C to stop.
4073
+ `);
4074
+ if (args.open) openInBrowser(server.url);
4075
+ process.removeAllListeners("SIGINT");
4076
+ process.removeAllListeners("SIGTERM");
4077
+ await new Promise((resolve) => {
4078
+ const stop = () => {
4079
+ logInfo("\nShutting down\u2026");
4080
+ server.close().finally(resolve);
4081
+ };
4082
+ process.once("SIGINT", stop);
4083
+ process.once("SIGTERM", stop);
4084
+ });
4085
+ return 0 /* Success */;
4086
+ }
4087
+
2883
4088
  // src/diagnostics/print.ts
2884
4089
  function formatDiagnostic(d) {
2885
4090
  const c = colors();
@@ -2915,6 +4120,11 @@ var outputArgs = {
2915
4120
  },
2916
4121
  tldr: { type: "boolean", description: "Summary + findings only (no plan tree)" },
2917
4122
  redact: { type: "boolean", description: "Strip literal values from expressions (safe to share)" },
4123
+ open: {
4124
+ type: "boolean",
4125
+ description: "Open the HTML report in your browser (default: on when interactive)"
4126
+ },
4127
+ "no-open": { type: "boolean", description: "Never open the HTML report in the browser" },
2918
4128
  ascii: { type: "boolean", description: "Use ASCII tree glyphs instead of Unicode" },
2919
4129
  color: { type: "string", default: "auto", description: "auto | always | never" },
2920
4130
  "no-color": { type: "boolean", description: "Disable color (same as --color never)" },
@@ -2948,6 +4158,7 @@ function emitOptionsFrom(args) {
2948
4158
  if (args.output) opts.output = args.output;
2949
4159
  const failOn = resolveFailOn(args);
2950
4160
  if (failOn) opts.failOn = failOn;
4161
+ opts.openHtml = args["no-open"] ? false : args.open ? true : Boolean(process.stdout.isTTY) && !process.env.CI;
2951
4162
  return opts;
2952
4163
  }
2953
4164
  function resolveFormat(value) {
@@ -3157,6 +4368,100 @@ var diffCmd = defineCommand({
3157
4368
  }
3158
4369
  }
3159
4370
  });
4371
+ var locksCmd = defineCommand({
4372
+ meta: {
4373
+ name: "locks",
4374
+ description: "Snapshot live lock contention: who is blocked, and by whom."
4375
+ },
4376
+ args: {
4377
+ dsn: {
4378
+ type: "string",
4379
+ description: "Connection string (or use --host/--port/\u2026 or PG* env vars)"
4380
+ },
4381
+ host: { type: "string", description: "Server host" },
4382
+ port: { type: "string", description: "Server port" },
4383
+ dbname: { type: "string", alias: "d", description: "Database name" },
4384
+ user: { type: "string", alias: "U", description: "Role name" },
4385
+ sslmode: { type: "string", description: "disable | require | verify-ca | verify-full" },
4386
+ sslrootcert: { type: "string", description: "Path to a CA certificate (PEM)" },
4387
+ "connect-timeout": {
4388
+ type: "string",
4389
+ default: "10s",
4390
+ description: "Connection timeout (e.g. 30s)"
4391
+ },
4392
+ format: { type: "string", default: "terminal", alias: "f", description: "terminal | json" },
4393
+ output: { type: "string", alias: "o", description: "Write to a file instead of stdout" },
4394
+ color: { type: "string", default: "auto", description: "auto | always | never" },
4395
+ "no-color": { type: "boolean", description: "Disable color" },
4396
+ "fail-on-blocked": {
4397
+ type: "boolean",
4398
+ description: "Exit 1 if any session is currently blocked"
4399
+ },
4400
+ quiet: { type: "boolean", alias: "q", description: "Suppress non-error logs" },
4401
+ debug: { type: "boolean", description: "Print stack traces on internal errors" }
4402
+ },
4403
+ async run({ args }) {
4404
+ try {
4405
+ applyGlobalFlags(args);
4406
+ if (!["terminal", "json"].includes(args.format)) {
4407
+ throw usageError(`Unknown locks --format '${args.format}'`, "Use terminal or json.");
4408
+ }
4409
+ const connection = {
4410
+ connectTimeoutMs: parseDurationMs(args["connect-timeout"])
4411
+ };
4412
+ if (args.dsn) connection.dsn = args.dsn;
4413
+ if (args.host) connection.host = args.host;
4414
+ if (args.port) connection.port = Number(args.port);
4415
+ if (args.dbname) connection.database = args.dbname;
4416
+ if (args.user) connection.user = args.user;
4417
+ if (args.sslmode) connection.sslmode = args.sslmode;
4418
+ if (args.sslrootcert) connection.sslrootcert = args.sslrootcert;
4419
+ process.exitCode = await runLocks({
4420
+ connection,
4421
+ format: args.format,
4422
+ output: args.output,
4423
+ color: args["no-color"] ? "never" : args.color,
4424
+ failOnBlocked: !!args["fail-on-blocked"]
4425
+ });
4426
+ } catch (err) {
4427
+ process.exitCode = handleFatal(err);
4428
+ }
4429
+ }
4430
+ });
4431
+ var studioCmd = defineCommand({
4432
+ meta: { name: "studio", description: "Launch the local pgexplain Studio web app." },
4433
+ args: {
4434
+ port: { type: "string", default: "5177", description: "Port to listen on" },
4435
+ host: {
4436
+ type: "string",
4437
+ default: "127.0.0.1",
4438
+ description: "Host to bind (loopback only unless --unsafe-host)"
4439
+ },
4440
+ "no-open": { type: "boolean", description: "Do not open the browser automatically" },
4441
+ "unsafe-host": {
4442
+ type: "boolean",
4443
+ description: "Allow binding a non-loopback host (SSRF/credential risk)"
4444
+ },
4445
+ debug: { type: "boolean", description: "Print stack traces on internal errors" }
4446
+ },
4447
+ async run({ args }) {
4448
+ try {
4449
+ applyGlobalFlags(args);
4450
+ const port = Number(args.port);
4451
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
4452
+ throw usageError(`Invalid --port '${args.port}'`, "Use a port between 0 and 65535.");
4453
+ }
4454
+ process.exitCode = await runStudio({
4455
+ host: args.host,
4456
+ port,
4457
+ open: !args["no-open"],
4458
+ unsafeHost: !!args["unsafe-host"]
4459
+ });
4460
+ } catch (err) {
4461
+ process.exitCode = handleFatal(err);
4462
+ }
4463
+ }
4464
+ });
3160
4465
  var main = defineCommand({
3161
4466
  meta: {
3162
4467
  name: "pg-explain",
@@ -3196,7 +4501,7 @@ var argv = process.argv.slice(2);
3196
4501
  if (argv[0] === "completion") {
3197
4502
  process.exitCode = runCompletion(argv[1]);
3198
4503
  } 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 });
4504
+ const started = argv[0] === "run" ? runMain(runCmd, { rawArgs: argv.slice(1) }) : argv[0] === "diff" ? runMain(diffCmd, { rawArgs: argv.slice(1) }) : argv[0] === "locks" ? runMain(locksCmd, { rawArgs: argv.slice(1) }) : argv[0] === "studio" ? runMain(studioCmd, { rawArgs: argv.slice(1) }) : runMain(main, { rawArgs: argv });
3200
4505
  started.catch((err) => {
3201
4506
  process.exitCode = handleFatal(err);
3202
4507
  });