pgexplain 0.1.0 → 0.2.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/index.js CHANGED
@@ -442,6 +442,308 @@ var DEFAULT_CONFIG = {
442
442
  thresholds: { ...DEFAULT_THRESHOLDS },
443
443
  rules: {}
444
444
  };
445
+
446
+ // src/core/parse-text.ts
447
+ var cap = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
448
+ var numeric = (v) => {
449
+ const t = v.trim();
450
+ return /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : t;
451
+ };
452
+ function splitList(s) {
453
+ const out = [];
454
+ let depth = 0;
455
+ let cur = "";
456
+ for (const ch of s) {
457
+ if (ch === "(") depth++;
458
+ else if (ch === ")") depth--;
459
+ if (ch === "," && depth === 0) {
460
+ if (cur.trim()) out.push(cur.trim());
461
+ cur = "";
462
+ } else {
463
+ cur += ch;
464
+ }
465
+ }
466
+ if (cur.trim()) out.push(cur.trim());
467
+ return out;
468
+ }
469
+ function splitIntoLines(text) {
470
+ const out = [];
471
+ const lines = text.split(/\r?\n/);
472
+ const count = (s, re) => (s.match(re) || []).length;
473
+ const closingFirst = (s) => {
474
+ const c = s.indexOf(")");
475
+ const o = s.indexOf("(");
476
+ return c !== -1 && c < o;
477
+ };
478
+ const sameIndent = (a, b) => a.search(/\S/) === b.search(/\S/);
479
+ for (const line of lines) {
480
+ const prev = out[out.length - 1];
481
+ if (prev && count(prev, /\)/g) !== count(prev, /\(/g)) {
482
+ out[out.length - 1] += line;
483
+ } else if (/^(?:Total\s+runtime|Planning(\s+time)?|Execution\s+time|Time|Filter|Output|JIT|Trigger|Settings|Serialization)/i.test(
484
+ line
485
+ )) {
486
+ out.push(line);
487
+ } else if (/^\S/.test(line) || /^\s*\(/.test(line) || closingFirst(line)) {
488
+ if (prev) out[out.length - 1] += line;
489
+ else out.push(line);
490
+ } else if (prev && /,\s*$/.test(prev) && !sameIndent(prev, line) && !/^\s*->/i.test(line)) {
491
+ out[out.length - 1] += line;
492
+ } else {
493
+ out.push(line);
494
+ }
495
+ }
496
+ return out;
497
+ }
498
+ var estimation = String.raw`\(cost=(\d+\.\d+)\.\.(\d+\.\d+)\s+rows=(\d+)\s+width=(\d+)\)`;
499
+ var actual = String.raw`(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|(never\s+executed))`;
500
+ var nodeRe = new RegExp(
501
+ String.raw`^(\s*->\s*|\s*)(Finalize|Simple|Partial)*\s*([^\r\n\t\f\v(]*?)\s*` + String.raw`(?:(?:${estimation}\s+\(${actual}\))|(?:${estimation})|(?:\(${actual}\)))\s*$`
502
+ );
503
+ var subRe = /^((?:Sub|Init)Plan)\s*(?:\d+\s*)?(?:\(returns.*\))?\s*$/;
504
+ var cteRe = /^CTE\s+(\S+)\s*$/;
505
+ var workerRe = /^Worker\s+(\d+):\s+(?:actual(?:\stime=(\d+\.\d+)\.\.(\d+\.\d+))?\srows=(\d+(?:\.\d+)?)\sloops=(\d+)|never\s+executed)(.*)$/;
506
+ var triggerRe = /^Trigger\s+(.*):\s+time=(\d+\.\d+)\s+calls=(\d+)\s*$/;
507
+ var headerRe = /^(QUERY PLAN|-{2,}|#|\(\d+ rows?\))/;
508
+ function splitNodeType(text) {
509
+ let s = text.trim();
510
+ let indexName;
511
+ let relationName;
512
+ let schema;
513
+ let alias;
514
+ const using = s.match(/\susing (\S+)/);
515
+ if (using?.[1]) {
516
+ indexName = using[1];
517
+ s = s.replace(using[0] ?? "", "");
518
+ }
519
+ const on = s.match(/\son (\S+?)(?:\s+(\S+))?\s*$/);
520
+ if (on?.[1]) {
521
+ let rel = on[1];
522
+ alias = on[2];
523
+ const dot = rel.lastIndexOf(".");
524
+ if (dot !== -1) {
525
+ schema = rel.slice(0, dot);
526
+ rel = rel.slice(dot + 1);
527
+ }
528
+ relationName = rel;
529
+ s = s.slice(0, on.index).trim();
530
+ }
531
+ const nodeType = s.replace(/^Parallel\s+/, "").trim();
532
+ if (nodeType === "Bitmap Index Scan" && relationName && !indexName) {
533
+ indexName = relationName;
534
+ relationName = void 0;
535
+ schema = void 0;
536
+ alias = void 0;
537
+ }
538
+ const out = { "Node Type": nodeType };
539
+ if (relationName) out["Relation Name"] = relationName;
540
+ if (indexName) out["Index Name"] = indexName;
541
+ if (schema) out.Schema = schema;
542
+ if (alias && alias !== relationName) out.Alias = alias;
543
+ return out;
544
+ }
545
+ function parseSort(text, node) {
546
+ const m = text.match(/^Sort Method:\s+(.*?)\s+(Memory|Disk):\s+(\S+)kB\s*$/);
547
+ if (!m?.[1] || !m[2] || m[3] === void 0) return false;
548
+ node["Sort Method"] = m[1].trim();
549
+ node["Sort Space Type"] = m[2];
550
+ node["Sort Space Used"] = Number(m[3]);
551
+ return true;
552
+ }
553
+ function parseBuffers(text, node) {
554
+ const m = text.match(/^Buffers:\s+(.*)$/);
555
+ if (!m?.[1]) return false;
556
+ for (const group of m[1].split(/,\s+/)) {
557
+ const g = group.match(/^(shared|temp|local)\s+(.*)$/);
558
+ if (!g?.[1] || g[2] === void 0) continue;
559
+ const type = cap(g[1]);
560
+ for (const kv of g[2].trim().split(/\s+/)) {
561
+ const [method, value] = kv.split("=");
562
+ if (method && value !== void 0) node[`${type} ${cap(method)} Blocks`] = Number(value);
563
+ }
564
+ }
565
+ return true;
566
+ }
567
+ function parseWal(text, node) {
568
+ const m = text.match(/^WAL:\s+(.*)$/);
569
+ if (!m?.[1]) return false;
570
+ for (const kv of m[1].trim().split(/\s+/)) {
571
+ const [k, value] = kv.split("=");
572
+ if (!k || value === void 0) continue;
573
+ node[`WAL ${k === "fpi" ? "FPI" : cap(k)}`] = Number(value);
574
+ }
575
+ return true;
576
+ }
577
+ function parseIoTimings(text, node) {
578
+ const m = text.match(/^I\/O Timings:\s+(.*)$/);
579
+ if (!m?.[1]) return false;
580
+ const read = m[1].match(/(?:^|\s)read=(\d+(?:\.\d+)?)/);
581
+ const write = m[1].match(/(?:^|\s)write=(\d+(?:\.\d+)?)/);
582
+ if (read?.[1]) node["I/O Read Time"] = Number(read[1]);
583
+ if (write?.[1]) node["I/O Write Time"] = Number(write[1]);
584
+ return true;
585
+ }
586
+ function parseSettings(text) {
587
+ const out = {};
588
+ for (const pair of splitList(text)) {
589
+ const m = pair.match(/^(\S+)\s*=\s*(.*)$/);
590
+ if (m?.[1] && m[2] !== void 0) out[m[1]] = m[2].replace(/^'|'$/g, "");
591
+ }
592
+ return out;
593
+ }
594
+ var LIST_KEYS = /* @__PURE__ */ new Set(["Output", "Sort Key", "Presorted Key", "Group Key"]);
595
+ function parseTextToStatements(input) {
596
+ const statements = [];
597
+ let stmt = null;
598
+ let stack = [];
599
+ let current = null;
600
+ let jit = null;
601
+ const finish = () => {
602
+ if (stmt?.Plan) statements.push(stmt);
603
+ stmt = null;
604
+ stack = [];
605
+ current = null;
606
+ jit = null;
607
+ };
608
+ for (let raw of splitIntoLines(input)) {
609
+ raw = raw.replace(/"\s*$/, "").replace(/^\s*"/, "").replace(/\t/g, " ");
610
+ const depth = raw.match(/^\s*/)?.[0].length ?? 0;
611
+ const line = raw.slice(depth);
612
+ if (line === "" || headerRe.test(line)) {
613
+ if (line === "" && stmt?.Plan) finish();
614
+ continue;
615
+ }
616
+ const nodeM = nodeRe.exec(line);
617
+ const subM = subRe.exec(line);
618
+ const cteM = cteRe.exec(line);
619
+ if (nodeM && !subM && !cteM) {
620
+ if (!stmt) stmt = {};
621
+ jit = null;
622
+ const node = { ...splitNodeType(nodeM[3] ?? "") };
623
+ if (nodeM[2]) node["Partial Mode"] = nodeM[2];
624
+ const startup = nodeM[4] ?? nodeM[13];
625
+ const total = nodeM[5] ?? nodeM[14];
626
+ if (startup && total) {
627
+ node["Startup Cost"] = Number(startup);
628
+ node["Total Cost"] = Number(total);
629
+ node["Plan Rows"] = Number(nodeM[6] ?? nodeM[15]);
630
+ node["Plan Width"] = Number(nodeM[7] ?? nodeM[16]);
631
+ }
632
+ const st = nodeM[8] ?? nodeM[17];
633
+ const tt = nodeM[9] ?? nodeM[18];
634
+ if (st && tt) {
635
+ node["Actual Startup Time"] = Number(st);
636
+ node["Actual Total Time"] = Number(tt);
637
+ }
638
+ const rows = nodeM[10] ?? nodeM[19];
639
+ const loops = nodeM[11] ?? nodeM[20];
640
+ if (rows && loops) {
641
+ node["Actual Rows"] = Number(rows);
642
+ node["Actual Loops"] = Number(loops);
643
+ }
644
+ if (nodeM[12] ?? nodeM[21]) {
645
+ node["Actual Loops"] = 0;
646
+ node["Actual Rows"] = 0;
647
+ }
648
+ stack = stack.filter((f) => f.depth < depth);
649
+ const parent = stack[stack.length - 1];
650
+ if (!parent) {
651
+ stmt.Plan = node;
652
+ } else {
653
+ if (parent.rel) {
654
+ node["Parent Relationship"] = parent.rel;
655
+ if (parent.name) node["Subplan Name"] = parent.name;
656
+ }
657
+ const parentNode = parent.node;
658
+ if (!parentNode.Plans) parentNode.Plans = [];
659
+ parentNode.Plans.push(node);
660
+ }
661
+ stack.push({ depth, node });
662
+ current = node;
663
+ continue;
664
+ }
665
+ if (subM || cteM) {
666
+ stack = stack.filter((f) => f.depth < depth);
667
+ const parent = stack[stack.length - 1];
668
+ if (!parent) continue;
669
+ if (cteM?.[1])
670
+ stack.push({ depth, node: parent.node, rel: "InitPlan", name: `CTE ${cteM[1]}` });
671
+ else if (subM?.[1])
672
+ stack.push({
673
+ depth,
674
+ node: parent.node,
675
+ rel: subM[1],
676
+ name: (subM[0] ?? "").trim()
677
+ });
678
+ continue;
679
+ }
680
+ const workerM = workerRe.exec(line);
681
+ if (workerM && current) {
682
+ const worker = { "Worker Number": Number(workerM[1]) };
683
+ if (workerM[2] && workerM[3]) {
684
+ worker["Actual Startup Time"] = Number(workerM[2]);
685
+ worker["Actual Total Time"] = Number(workerM[3]);
686
+ }
687
+ if (workerM[4] && workerM[5]) {
688
+ worker["Actual Rows"] = Number(workerM[4]);
689
+ worker["Actual Loops"] = Number(workerM[5]);
690
+ }
691
+ if (!Array.isArray(current.Workers)) current.Workers = [];
692
+ current.Workers.push(worker);
693
+ continue;
694
+ }
695
+ const trigM = triggerRe.exec(line);
696
+ if (trigM && stmt) {
697
+ if (!Array.isArray(stmt.Triggers)) stmt.Triggers = [];
698
+ stmt.Triggers.push({
699
+ "Trigger Name": trigM[1],
700
+ Time: Number(trigM[2]),
701
+ Calls: Number(trigM[3])
702
+ });
703
+ continue;
704
+ }
705
+ const kv = line.match(/^([^:]+):\s*(.*)$/);
706
+ if (!kv?.[1]) continue;
707
+ const key = kv[1].trim();
708
+ const value = (kv[2] ?? "").trim();
709
+ if (key === "JIT") {
710
+ jit = {};
711
+ if (stmt) stmt.JIT = jit;
712
+ continue;
713
+ }
714
+ if (jit) {
715
+ if (key === "Functions") jit.Functions = Number(value);
716
+ else if (key === "Timing") {
717
+ const timing = {};
718
+ for (const part of value.split(/,\s*/)) {
719
+ const t = part.match(/^(\S+)\s+(\d+\.\d+)\s*ms/);
720
+ if (t?.[1]) timing[t[1]] = Number(t[2]);
721
+ }
722
+ jit.Timing = timing;
723
+ }
724
+ continue;
725
+ }
726
+ if (key === "Planning Time") {
727
+ if (stmt) stmt["Planning Time"] = parseFloat(value);
728
+ continue;
729
+ }
730
+ if (key === "Execution Time" || key === "Total runtime") {
731
+ if (stmt) stmt["Execution Time"] = parseFloat(value);
732
+ continue;
733
+ }
734
+ if (key === "Settings") {
735
+ if (stmt) stmt.Settings = parseSettings(value);
736
+ continue;
737
+ }
738
+ if (!current) continue;
739
+ if (parseSort(line, current) || parseBuffers(line, current) || parseWal(line, current) || parseIoTimings(line, current)) {
740
+ continue;
741
+ }
742
+ current[key] = LIST_KEYS.has(key) ? splitList(value) : numeric(value);
743
+ }
744
+ finish();
745
+ return statements;
746
+ }
445
747
  var PlanNodeSchema = z.looseObject({
446
748
  "Node Type": z.string(),
447
749
  get Plans() {
@@ -561,8 +863,13 @@ function normalizeNode(raw, nextId) {
561
863
  ioReadTime: num(raw, "I/O Read Time"),
562
864
  ioWriteTime: num(raw, "I/O Write Time"),
563
865
  workersPlanned: num(raw, "Workers Planned"),
564
- workersLaunched: num(raw, "Workers Launched")
866
+ workersLaunched: num(raw, "Workers Launched"),
867
+ walRecords: num(raw, "WAL Records"),
868
+ walBytes: num(raw, "WAL Bytes"),
869
+ walFpi: num(raw, "WAL FPI")
565
870
  });
871
+ const workers = parseWorkers(raw.Workers);
872
+ if (workers.length) node.workers = workers;
566
873
  const childPlans = raw.Plans;
567
874
  if (Array.isArray(childPlans)) {
568
875
  for (const child of childPlans) {
@@ -576,6 +883,24 @@ function assign(target, fields) {
576
883
  if (v !== void 0) target[k] = v;
577
884
  }
578
885
  }
886
+ function parseWorkers(raw) {
887
+ if (!Array.isArray(raw)) return [];
888
+ const out = [];
889
+ for (const w of raw) {
890
+ const r = w;
891
+ const number = num(r, "Worker Number");
892
+ if (number === void 0) continue;
893
+ const stat = { number };
894
+ assign(stat, {
895
+ actualRows: num(r, "Actual Rows"),
896
+ actualLoops: num(r, "Actual Loops"),
897
+ actualStartupTime: num(r, "Actual Startup Time"),
898
+ actualTotalTime: num(r, "Actual Total Time")
899
+ });
900
+ out.push(stat);
901
+ }
902
+ return out;
903
+ }
579
904
  function parseTriggers(raw) {
580
905
  if (!Array.isArray(raw)) return [];
581
906
  return raw.map((t) => {
@@ -609,6 +934,36 @@ function parseJit(raw) {
609
934
  }
610
935
  return jit;
611
936
  }
937
+ function statementToTree(stmt) {
938
+ let id = 0;
939
+ const root = normalizeNode(stmt.Plan, () => id++);
940
+ const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
941
+ const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
942
+ const tree = {
943
+ root,
944
+ triggers: parseTriggers(stmt.Triggers),
945
+ hasAnalyze,
946
+ hasBuffers,
947
+ raw: stmt.Plan
948
+ };
949
+ if (typeof stmt["Planning Time"] === "number") tree.planningTime = stmt["Planning Time"];
950
+ if (typeof stmt["Execution Time"] === "number") tree.executionTime = stmt["Execution Time"];
951
+ const serialization = stmt.Serialization;
952
+ if (serialization && typeof serialization === "object") {
953
+ const t = num(serialization, "Time");
954
+ if (t !== void 0) tree.serializationTime = t;
955
+ }
956
+ const jit = parseJit(stmt.JIT);
957
+ if (jit) tree.jit = jit;
958
+ if (stmt.Settings) tree.settings = stmt.Settings;
959
+ return tree;
960
+ }
961
+ function parseExplain(input) {
962
+ return /^\s*[[{]/.test(input) ? parseExplainJson(input) : parseExplainText(input);
963
+ }
964
+ function parseExplainText(input) {
965
+ return parseTextToStatements(input).map(statementToTree);
966
+ }
612
967
  function parseExplainJson(input) {
613
968
  const json = parseJsonWithLocation(input);
614
969
  let candidate = json;
@@ -623,25 +978,7 @@ function parseExplainJson(input) {
623
978
  location: { kind: "input" }
624
979
  });
625
980
  }
626
- return result.data.map((stmt) => {
627
- let id = 0;
628
- const root = normalizeNode(stmt.Plan, () => id++);
629
- const hasAnalyze = root.actualLoops !== void 0 || stmt["Execution Time"] !== void 0;
630
- const hasBuffers = root.sharedHitBlocks !== void 0 || root.sharedReadBlocks !== void 0;
631
- const tree = {
632
- root,
633
- triggers: parseTriggers(stmt.Triggers),
634
- hasAnalyze,
635
- hasBuffers,
636
- raw: stmt.Plan
637
- };
638
- if (stmt["Planning Time"] !== void 0) tree.planningTime = stmt["Planning Time"];
639
- if (stmt["Execution Time"] !== void 0) tree.executionTime = stmt["Execution Time"];
640
- const jit = parseJit(stmt.JIT);
641
- if (jit) tree.jit = jit;
642
- if (stmt.Settings) tree.settings = stmt.Settings;
643
- return tree;
644
- });
981
+ return result.data.map((stmt) => statementToTree(stmt));
645
982
  }
646
983
  function walk(node, visit) {
647
984
  visit(node);
@@ -706,6 +1043,31 @@ function executionMs(tree) {
706
1043
  function bottlenecks(tree, n = 5) {
707
1044
  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);
708
1045
  }
1046
+ function aggregateStats(tree) {
1047
+ const total = executionMs(tree) ?? 0;
1048
+ const groupBy = (keyOf) => {
1049
+ const acc = /* @__PURE__ */ new Map();
1050
+ for (const n of flatten(tree.root)) {
1051
+ const key = keyOf(n);
1052
+ if (!key) continue;
1053
+ const e = acc.get(key) ?? { count: 0, selfMs: 0 };
1054
+ e.count++;
1055
+ e.selfMs += n.metrics.selfMs ?? 0;
1056
+ acc.set(key, e);
1057
+ }
1058
+ return [...acc.entries()].map(([key, e]) => ({
1059
+ key,
1060
+ count: e.count,
1061
+ selfMs: e.selfMs,
1062
+ pctOfTotal: total > 0 ? 100 * e.selfMs / total : 0
1063
+ })).sort((a, b) => b.selfMs - a.selfMs || b.count - a.count);
1064
+ };
1065
+ return {
1066
+ byNodeType: groupBy((n) => n.nodeType),
1067
+ byRelation: groupBy((n) => n.relationName),
1068
+ byIndex: groupBy((n) => n.indexName)
1069
+ };
1070
+ }
709
1071
  function nodeLabel(node) {
710
1072
  let label = node.nodeType;
711
1073
  if (node.indexName && node.relationName)
@@ -1290,8 +1652,8 @@ var rowMisestimate = {
1290
1652
  const target = rel ?? "the underlying table";
1291
1653
  const under = estimateDirection === "under";
1292
1654
  const direction = under ? "underestimate" : "overestimate";
1293
- const actual = totalRows ?? 0;
1294
- const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(actual)} were produced \u2014 a ${fmtInt(factor)}x ${direction}${onRel}.`;
1655
+ const actual2 = totalRows ?? 0;
1656
+ const detail = `Postgres estimated ${fmtInt(node.planRows)} rows but ${fmtInt(actual2)} were produced \u2014 a ${fmtInt(factor)}x ${direction}${onRel}.`;
1295
1657
  return [
1296
1658
  makeFinding(rowMisestimate, ctx, node, {
1297
1659
  // Severity: underestimates are the dangerous ones (under-sized joins/memory).
@@ -1326,7 +1688,7 @@ ANALYZE ${rel ?? "<relation>"};`
1326
1688
  docsUrl: `${DOCS2}/planner-stats.html`,
1327
1689
  meta: {
1328
1690
  estimatedRows: Math.round(node.planRows),
1329
- actualRows: Math.round(actual),
1691
+ actualRows: Math.round(actual2),
1330
1692
  factor,
1331
1693
  direction: estimateDirection
1332
1694
  }
@@ -1671,6 +2033,138 @@ function redactPlanTree(tree) {
1671
2033
  walk(tree.root, redactNode);
1672
2034
  }
1673
2035
 
2036
+ // src/locks/advisor.ts
2037
+ var DOCS3 = "https://www.postgresql.org/docs/current";
2038
+ function analyzeLocks(sql, tree) {
2039
+ const code = stripSql(sql);
2040
+ const upper = code.toUpperCase();
2041
+ const kw = (code.trim().split(/\s+/)[0] ?? "").toUpperCase();
2042
+ const out = [];
2043
+ const add = (id, severity, parts) => {
2044
+ out.push({
2045
+ code: id,
2046
+ domain: "plan",
2047
+ severity,
2048
+ title: parts.title,
2049
+ detail: parts.detail,
2050
+ cause: parts.cause,
2051
+ remediation: { summary: parts.fix, commands: parts.commands },
2052
+ docsUrl: `${DOCS3}/explicit-locking.html`
2053
+ });
2054
+ };
2055
+ 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)) {
2056
+ add("PGX_LOCK_TABLE_REWRITE", "error", {
2057
+ title: "Operation rewrites the table under an ACCESS EXCLUSIVE lock",
2058
+ detail: "VACUUM FULL / CLUSTER / a column-type change rewrites the whole table and holds ACCESS EXCLUSIVE for the duration.",
2059
+ cause: "ACCESS EXCLUSIVE blocks every reader and writer until the rewrite finishes \u2014 an outage on a busy table.",
2060
+ 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.",
2061
+ commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
2062
+ });
2063
+ }
2064
+ if (/\bCREATE\s+(UNIQUE\s+)?INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
2065
+ add("PGX_DDL_NO_CONCURRENTLY", "warn", {
2066
+ title: "CREATE INDEX without CONCURRENTLY blocks writes",
2067
+ detail: "A plain CREATE INDEX takes a SHARE lock, blocking all writes to the table until the build completes.",
2068
+ cause: "On a large or busy table the build can take minutes, during which inserts/updates/deletes are blocked.",
2069
+ 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).",
2070
+ commands: [{ label: "Build online", sql: "CREATE INDEX CONCURRENTLY ON <table> (<cols>);" }]
2071
+ });
2072
+ }
2073
+ if (/\bDROP\s+INDEX\b/.test(upper) && !/\bCONCURRENTLY\b/.test(upper)) {
2074
+ add("PGX_DROP_INDEX_NO_CONCURRENTLY", "warn", {
2075
+ title: "DROP INDEX without CONCURRENTLY takes ACCESS EXCLUSIVE",
2076
+ detail: "A plain DROP INDEX locks the table with ACCESS EXCLUSIVE.",
2077
+ cause: "Readers and writers block until the drop completes.",
2078
+ fix: "Use DROP INDEX CONCURRENTLY to avoid blocking.",
2079
+ commands: [{ label: "Drop online", sql: "DROP INDEX CONCURRENTLY <index>;" }]
2080
+ });
2081
+ }
2082
+ if (/\bTRUNCATE\b/.test(upper)) {
2083
+ add("PGX_LOCK_TRUNCATE", "info", {
2084
+ title: "TRUNCATE takes an ACCESS EXCLUSIVE lock",
2085
+ detail: "TRUNCATE briefly locks the table with ACCESS EXCLUSIVE.",
2086
+ cause: "It is fast (no row scan) but still blocks all access while it runs and is transactional.",
2087
+ 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.",
2088
+ commands: [{ label: "Bound the wait", sql: "SET lock_timeout = '3s';" }]
2089
+ });
2090
+ }
2091
+ if (/\bLOCK\s+TABLE\b/.test(upper)) {
2092
+ add("PGX_LOCK_TABLE_EXPLICIT", "info", {
2093
+ title: "Explicit LOCK TABLE",
2094
+ detail: "An explicit LOCK TABLE acquires the named lock mode for the rest of the transaction.",
2095
+ cause: "Holding a strong lock longer than necessary blocks other sessions.",
2096
+ fix: "Use the lowest lock mode that suffices and keep the transaction short."
2097
+ });
2098
+ }
2099
+ if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/.test(upper) && !/\bLIMIT\b/.test(upper)) {
2100
+ add("PGX_SELECT_FOR_UPDATE_UNBOUNDED", "warn", {
2101
+ title: "Row-locking SELECT without a LIMIT",
2102
+ detail: "SELECT \u2026 FOR UPDATE/SHARE locks every row it matches, held until the transaction ends.",
2103
+ cause: "Locking an unbounded set increases contention and deadlock risk with concurrent updaters.",
2104
+ fix: "Bound the set with a deterministic ORDER BY + LIMIT (and process in batches); a consistent lock order also avoids deadlocks.",
2105
+ commands: [{ label: "Bound + order", sql: "SELECT \u2026 ORDER BY id FOR UPDATE LIMIT 100;" }]
2106
+ });
2107
+ }
2108
+ if (kw === "UPDATE" || kw === "DELETE") {
2109
+ if (!/\bWHERE\b/.test(upper)) {
2110
+ add("PGX_WRITE_NO_WHERE", "warn", {
2111
+ title: `${kw} without a WHERE clause locks every row`,
2112
+ detail: `This ${kw} touches the whole table, taking a row lock on every row until commit.`,
2113
+ cause: "All rows are locked for the transaction's duration, blocking concurrent writers and bloating the table.",
2114
+ fix: "Add a WHERE clause; for large rewrites, update in batches (e.g. by primary-key ranges) and commit between batches."
2115
+ });
2116
+ } else if (tree && hasSeqScanOnTarget(tree, targetTable(code, kw))) {
2117
+ const rel = targetTable(code, kw);
2118
+ add("PGX_UPDATE_UNINDEXED_PREDICATE", "warn", {
2119
+ title: `${kw} scans ${rel ?? "the table"} sequentially to find rows`,
2120
+ 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.`,
2121
+ cause: "An unindexed predicate means more rows scanned and locked, and the locks are held until commit.",
2122
+ fix: `Index the ${kw}'s WHERE columns so it finds rows via an index and locks only what it changes.`,
2123
+ commands: [
2124
+ {
2125
+ label: "Index the predicate",
2126
+ sql: `CREATE INDEX ON ${rel ?? "<table>"} (<where columns>);`
2127
+ }
2128
+ ]
2129
+ });
2130
+ }
2131
+ }
2132
+ if (/^(ALTER|CREATE|DROP)\b/.test(kw) && !/\bCONCURRENTLY\b/.test(upper) && !/\bSET\s+LOCK_TIMEOUT\b/.test(upper)) {
2133
+ add("PGX_DDL_NO_LOCK_TIMEOUT", "warn", {
2134
+ title: "DDL without a lock_timeout can stall the whole table",
2135
+ 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.",
2136
+ cause: "A blocked ACCESS EXCLUSIVE request sits at the head of the lock queue and blocks new readers/writers too.",
2137
+ fix: "Set a short lock_timeout before the DDL and retry, so it fails fast instead of forming a queue.",
2138
+ commands: [
2139
+ {
2140
+ label: "Fail fast, then retry",
2141
+ sql: "SET lock_timeout = '3s';\n-- run the DDL; on timeout, retry later"
2142
+ }
2143
+ ]
2144
+ });
2145
+ }
2146
+ return out;
2147
+ }
2148
+ function stripSql(sql) {
2149
+ return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ").replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '"x"');
2150
+ }
2151
+ function targetTable(code, kw) {
2152
+ const re = kw === "DELETE" ? /\bDELETE\s+FROM\s+([A-Za-z_][\w.]*)/i : /\bUPDATE\s+(?:ONLY\s+)?([A-Za-z_][\w.]*)/i;
2153
+ const m = re.exec(code);
2154
+ return m?.[1];
2155
+ }
2156
+ function hasSeqScanOnTarget(tree, table) {
2157
+ let found = false;
2158
+ walk(tree.root, (n) => {
2159
+ if (n.nodeType === "Seq Scan" && (!table || n.relationName === bareName(table))) found = true;
2160
+ });
2161
+ return found;
2162
+ }
2163
+ function bareName(qualified) {
2164
+ const parts = qualified.split(".");
2165
+ return parts[parts.length - 1] ?? qualified;
2166
+ }
2167
+
1674
2168
  // src/report/tree.ts
1675
2169
  function treeLines(tree, glyphs) {
1676
2170
  const lines = [];
@@ -1711,21 +2205,28 @@ function nodeSummary(node) {
1711
2205
  // src/report/json.ts
1712
2206
  var JSON_SCHEMA_VERSION = 1;
1713
2207
  function renderJson(result, pretty = true) {
2208
+ return JSON.stringify(buildReport(result), null, pretty ? 2 : 0);
2209
+ }
2210
+ function buildReport(result) {
1714
2211
  const { tree, diagnostics, bottlenecks: bottlenecks2 } = result;
1715
2212
  const counts = { error: 0, warn: 0, info: 0 };
1716
2213
  for (const d of diagnostics) counts[d.severity]++;
1717
- const report = {
2214
+ return {
1718
2215
  schemaVersion: JSON_SCHEMA_VERSION,
1719
2216
  verdict: result.verdict,
1720
2217
  worstSeverity: result.worstSeverity,
1721
2218
  summary: {
1722
2219
  planningTimeMs: tree.planningTime ?? null,
1723
2220
  executionTimeMs: executionMs(tree) ?? null,
2221
+ serializationTimeMs: tree.serializationTime ?? null,
1724
2222
  hasAnalyze: tree.hasAnalyze,
1725
2223
  hasBuffers: tree.hasBuffers,
1726
2224
  nodeCount: flatten(tree.root).length,
1727
2225
  findings: counts
1728
2226
  },
2227
+ triggers: tree.triggers,
2228
+ jit: tree.jit ?? null,
2229
+ settings: tree.settings ?? null,
1729
2230
  diagnostics,
1730
2231
  bottlenecks: bottlenecks2.filter((n) => (n.metrics.selfMs ?? 0) > 0).map((n) => ({
1731
2232
  id: n.id,
@@ -1736,9 +2237,9 @@ function renderJson(result, pretty = true) {
1736
2237
  pctOfTotal: n.metrics.pctOfTotal ?? null,
1737
2238
  totalRows: n.metrics.totalRows ?? null
1738
2239
  })),
2240
+ stats: aggregateStats(tree),
1739
2241
  plan: serializeNode(tree.root)
1740
2242
  };
1741
- return JSON.stringify(report, null, pretty ? 2 : 0);
1742
2243
  }
1743
2244
  function serializeNode(node) {
1744
2245
  const { children, metrics, raw, ...fields } = node;
@@ -2031,14 +2532,15 @@ function render(result, opts) {
2031
2532
 
2032
2533
  // src/index.ts
2033
2534
  function analyze(input, options = {}) {
2034
- const trees = parseExplainJson(input);
2535
+ const trees = parseExplain(input);
2035
2536
  const tree = selectStatement(trees, options.statement);
2036
2537
  if (options.redact) redactPlanTree(tree);
2037
2538
  computeMetrics(tree);
2038
2539
  const result = runAdvisor(tree, options.config ?? DEFAULT_CONFIG);
2039
- const notices = planNotices(tree);
2040
- if (notices.length) {
2041
- result.diagnostics = [...result.diagnostics, ...notices].sort(bySeverity);
2540
+ const extra = planNotices(tree);
2541
+ if (options.sql) extra.push(...analyzeLocks(options.sql, tree));
2542
+ if (extra.length) {
2543
+ result.diagnostics = [...result.diagnostics, ...extra].sort(bySeverity);
2042
2544
  result.worstSeverity = result.diagnostics.reduce(
2043
2545
  (worst, d) => worst === null ? d.severity : maxSeverity(worst, d.severity),
2044
2546
  null
@@ -2070,6 +2572,6 @@ function planNotices(tree) {
2070
2572
  return notices;
2071
2573
  }
2072
2574
 
2073
- export { AppError, DEFAULT_CONFIG, DEFAULT_THRESHOLDS, ExitCode, FORMATS, JSON_SCHEMA_VERSION, analyze, bottlenecks, computeMetrics, executionMs, flatten, isFormat, nodeLabel, parseExplainJson, render, runAdvisor, scrubCredentials, walk };
2575
+ export { AppError, DEFAULT_CONFIG, DEFAULT_THRESHOLDS, ExitCode, FORMATS, JSON_SCHEMA_VERSION, analyze, analyzeLocks, bottlenecks, computeMetrics, executionMs, flatten, isFormat, nodeLabel, parseExplain, parseExplainJson, render, runAdvisor, scrubCredentials, walk };
2074
2576
  //# sourceMappingURL=index.js.map
2075
2577
  //# sourceMappingURL=index.js.map