@indiekitai/pg-dash 0.4.6 → 0.5.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
@@ -2079,6 +2079,97 @@ var init_env_differ = __esm({
2079
2079
  }
2080
2080
  });
2081
2081
 
2082
+ // src/server/locks.ts
2083
+ var locks_exports = {};
2084
+ __export(locks_exports, {
2085
+ formatDurationSecs: () => formatDurationSecs,
2086
+ getLockReport: () => getLockReport
2087
+ });
2088
+ function formatDurationSecs(secs) {
2089
+ const h = Math.floor(secs / 3600);
2090
+ const m = Math.floor(secs % 3600 / 60);
2091
+ const s = secs % 60;
2092
+ return [
2093
+ String(h).padStart(2, "0"),
2094
+ String(m).padStart(2, "0"),
2095
+ String(s).padStart(2, "0")
2096
+ ].join(":");
2097
+ }
2098
+ async function getLockReport(pool) {
2099
+ const [locksResult, longResult] = await Promise.all([
2100
+ pool.query(`
2101
+ SELECT
2102
+ blocked.pid AS blocked_pid,
2103
+ blocked.query AS blocked_query,
2104
+ EXTRACT(EPOCH FROM (NOW() - blocked.query_start))::int AS blocked_secs,
2105
+ blocking.pid AS blocking_pid,
2106
+ blocking.query AS blocking_query,
2107
+ EXTRACT(EPOCH FROM (NOW() - blocking.query_start))::int AS blocking_secs,
2108
+ blocked_locks.relation::regclass::text AS table_name,
2109
+ blocked_locks.locktype
2110
+ FROM pg_catalog.pg_locks blocked_locks
2111
+ JOIN pg_catalog.pg_stat_activity blocked ON blocked.pid = blocked_locks.pid
2112
+ JOIN pg_catalog.pg_locks blocking_locks
2113
+ ON blocking_locks.locktype = blocked_locks.locktype
2114
+ AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
2115
+ AND blocking_locks.pid != blocked_locks.pid
2116
+ AND blocking_locks.granted = true
2117
+ JOIN pg_catalog.pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
2118
+ WHERE NOT blocked_locks.granted
2119
+ `),
2120
+ pool.query(`
2121
+ SELECT
2122
+ pid,
2123
+ EXTRACT(EPOCH FROM (NOW() - query_start))::int AS duration_secs,
2124
+ query,
2125
+ state,
2126
+ wait_event_type
2127
+ FROM pg_stat_activity
2128
+ WHERE state != 'idle'
2129
+ AND query_start IS NOT NULL
2130
+ AND EXTRACT(EPOCH FROM (NOW() - query_start)) > 5
2131
+ AND query NOT LIKE '%pg_stat_activity%'
2132
+ ORDER BY duration_secs DESC
2133
+ LIMIT 20
2134
+ `)
2135
+ ]);
2136
+ const seen = /* @__PURE__ */ new Set();
2137
+ const waitingLocks = [];
2138
+ for (const row of locksResult.rows) {
2139
+ const key = `${row.blocked_pid}:${row.blocking_pid}`;
2140
+ if (!seen.has(key)) {
2141
+ seen.add(key);
2142
+ waitingLocks.push({
2143
+ blockedPid: parseInt(row.blocked_pid, 10),
2144
+ blockedQuery: row.blocked_query,
2145
+ blockedDuration: formatDurationSecs(parseInt(row.blocked_secs, 10) || 0),
2146
+ blockingPid: parseInt(row.blocking_pid, 10),
2147
+ blockingQuery: row.blocking_query,
2148
+ blockingDuration: formatDurationSecs(parseInt(row.blocking_secs, 10) || 0),
2149
+ table: row.table_name ?? null,
2150
+ lockType: row.locktype
2151
+ });
2152
+ }
2153
+ }
2154
+ const longRunningQueries = longResult.rows.map((row) => ({
2155
+ pid: parseInt(row.pid, 10),
2156
+ duration: formatDurationSecs(parseInt(row.duration_secs, 10) || 0),
2157
+ query: row.query,
2158
+ state: row.state,
2159
+ waitEventType: row.wait_event_type ?? null
2160
+ }));
2161
+ return {
2162
+ waitingLocks,
2163
+ longRunningQueries,
2164
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
2165
+ };
2166
+ }
2167
+ var init_locks = __esm({
2168
+ "src/server/locks.ts"() {
2169
+ "use strict";
2170
+ }
2171
+ });
2172
+
2082
2173
  // src/cli.ts
2083
2174
  import { parseArgs } from "util";
2084
2175
 
@@ -4056,6 +4147,7 @@ var { values, positionals } = parseArgs({
4056
4147
  "slack-webhook": { type: "string" },
4057
4148
  "discord-webhook": { type: "string" },
4058
4149
  "no-open": { type: "boolean", default: false },
4150
+ "no-analyze": { type: "boolean", default: false },
4059
4151
  json: { type: "boolean", default: false },
4060
4152
  host: { type: "string" },
4061
4153
  user: { type: "string", short: "u" },
@@ -4099,6 +4191,8 @@ Usage:
4099
4191
  pg-dash check <connection-string> Run health check and exit
4100
4192
  pg-dash health <connection-string> Alias for check
4101
4193
  pg-dash check-migration <file> [connection] Analyze migration SQL for risks
4194
+ pg-dash explain <query> <connection> EXPLAIN ANALYZE a query in the terminal
4195
+ pg-dash watch-locks <connection> Real-time lock + long-query monitor
4102
4196
  pg-dash diff-env --source <url> --target <url> Compare two environments
4103
4197
  pg-dash schema-diff <connection-string> Show latest schema changes
4104
4198
  pg-dash --host localhost --user postgres --db mydb
@@ -4140,7 +4234,7 @@ Environment variables:
4140
4234
  `);
4141
4235
  process.exit(0);
4142
4236
  }
4143
- var KNOWN_SUBCOMMANDS = ["check", "health", "check-migration", "schema-diff", "diff-env"];
4237
+ var KNOWN_SUBCOMMANDS = ["check", "health", "check-migration", "schema-diff", "diff-env", "explain", "watch-locks"];
4144
4238
  var subcommand = positionals[0];
4145
4239
  function isValidConnectionString(s) {
4146
4240
  return s.startsWith("postgresql://") || s.startsWith("postgres://") || s.includes("@") || // user@host shorthand
@@ -4497,6 +4591,166 @@ Migration check: ${filePath}`);
4497
4591
  console.error(`Error: ${err.message}`);
4498
4592
  process.exit(1);
4499
4593
  }
4594
+ } else if (subcommand === "explain") {
4595
+ const query = positionals[1];
4596
+ if (!query) {
4597
+ console.error('Error: provide a SQL query.\n\nUsage: pg-dash explain "<query>" <connection>');
4598
+ process.exit(1);
4599
+ }
4600
+ const connStr = positionals[2] || resolveConnectionString(2);
4601
+ if (!connStr) {
4602
+ console.error("Error: provide a connection string.");
4603
+ process.exit(1);
4604
+ }
4605
+ const doAnalyze = !values["no-analyze"];
4606
+ const fmt = values.json ? "json" : "text";
4607
+ const { Pool: Pool3 } = await import("pg");
4608
+ const pool = new Pool3({ connectionString: connStr, max: 1, connectionTimeoutMillis: 1e4 });
4609
+ try {
4610
+ let nodeColor = function(type, rows, analyzed) {
4611
+ if (type.includes("Seq Scan")) return analyzed && rows >= 1e3 ? "\x1B[31m\x1B[1m" : "\x1B[31m";
4612
+ if (type.includes("Index")) return "\x1B[32m";
4613
+ if (type === "Hash Join" || type === "Hash") return "\x1B[33m";
4614
+ if (type === "Sort") return "\x1B[35m";
4615
+ return "\x1B[37m";
4616
+ }, renderNode = function(node, indent = 0, isLast = true) {
4617
+ const lines = [];
4618
+ const prefix = indent === 0 ? "" : " ".repeat(indent - 1) + (isLast ? "\u2514\u2500 " : "\u251C\u2500 ");
4619
+ const analyzed = node["Actual Total Time"] !== void 0;
4620
+ const rows = node["Actual Rows"] ?? node["Plan Rows"];
4621
+ const color = nodeColor(node["Node Type"], rows, analyzed);
4622
+ const rel = node["Relation Name"] ? ` on ${dim}${node["Alias"] || node["Relation Name"]}${reset}` : "";
4623
+ const cost = `${dim}cost=${node["Startup Cost"].toFixed(2)}..${node["Total Cost"].toFixed(2)}${reset}`;
4624
+ const timing = analyzed ? ` ${cyan}actual=${node["Actual Total Time"].toFixed(3)}ms${reset}` : "";
4625
+ const rowStr = analyzed ? ` ${dim}rows=${node["Actual Rows"]}/${node["Plan Rows"]}${reset}` : ` ${dim}rows=${node["Plan Rows"]}${reset}`;
4626
+ const idx = node["Index Name"] ? ` ${dim}idx=${node["Index Name"]}${reset}` : "";
4627
+ const filter = node["Filter"] ? ` ${dim}filter=${String(node["Filter"]).slice(0, 40)}${reset}` : "";
4628
+ lines.push(`${prefix}${color}${node["Node Type"]}${reset}${rel} ${cost}${timing}${rowStr}${idx}${filter}`);
4629
+ if (node.Plans?.length) {
4630
+ for (let i = 0; i < node.Plans.length; i++) {
4631
+ lines.push(renderNode(node.Plans[i], indent + 1, i === node.Plans.length - 1));
4632
+ }
4633
+ }
4634
+ return lines.join("\n");
4635
+ }, collectNodes2 = function(n) {
4636
+ return [n, ...n.Plans?.flatMap(collectNodes2) ?? []];
4637
+ };
4638
+ nodeColor2 = nodeColor, renderNode2 = renderNode, collectNodes3 = collectNodes2;
4639
+ const explainSql = doAnalyze ? `EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) ${query}` : `EXPLAIN (COSTS, VERBOSE, FORMAT JSON) ${query}`;
4640
+ const res = await pool.query(explainSql);
4641
+ await pool.end();
4642
+ const rawPlan = res.rows[0]["QUERY PLAN"];
4643
+ const planObj = Array.isArray(rawPlan) ? rawPlan[0] : rawPlan;
4644
+ const root = planObj.Plan;
4645
+ const planningTime = planObj["Planning Time"];
4646
+ const executionTime = planObj["Execution Time"];
4647
+ const reset = "\x1B[0m";
4648
+ const dim = "\x1B[2m";
4649
+ const cyan = "\x1B[36m";
4650
+ const allNodes = collectNodes2(root);
4651
+ const seqScans = allNodes.filter((n) => n["Node Type"] === "Seq Scan" && n["Relation Name"]).map((n) => n["Relation Name"]);
4652
+ const recs = [];
4653
+ for (const n of allNodes) {
4654
+ const rows = n["Actual Rows"] ?? n["Plan Rows"];
4655
+ if (n["Node Type"] === "Seq Scan" && n["Relation Name"] && rows >= 1e3) {
4656
+ recs.push(`\u26A0 Seq Scan on "${n["Relation Name"]}" (${rows} rows). Consider adding an index.`);
4657
+ }
4658
+ if (n["Node Type"] === "Sort") {
4659
+ recs.push(`\u2139 Sort on [${(n["Sort Key"] ?? []).join(", ")}]. An index might eliminate this.`);
4660
+ }
4661
+ if (n["Hash Batches"] && n["Hash Batches"] > 1) {
4662
+ recs.push(`\u26A0 Hash Join used ${n["Hash Batches"]} batches. Increase work_mem to avoid disk spilling.`);
4663
+ }
4664
+ }
4665
+ if (fmt === "json") {
4666
+ console.log(JSON.stringify({ query, planningTime, executionTime, seqScans, recommendations: recs }, null, 2));
4667
+ } else {
4668
+ if (query) console.log(`
4669
+ \x1B[1mQuery:\x1B[0m ${dim}${query.slice(0, 120)}${reset}`);
4670
+ console.log("\n" + renderNode(root));
4671
+ console.log(`
4672
+ ${dim}\u2500\u2500\u2500 Summary ${"\u2500".repeat(36)}${reset}`);
4673
+ if (executionTime !== void 0) console.log(` Execution time: ${cyan}${executionTime.toFixed(3)}ms${reset}`);
4674
+ if (planningTime !== void 0) console.log(` Planning time: ${dim}${planningTime.toFixed(3)}ms${reset}`);
4675
+ if (seqScans.length > 0) console.log(` Seq Scans: \x1B[31m${seqScans.join(", ")}${reset}`);
4676
+ if (recs.length > 0) {
4677
+ console.log(`
4678
+ ${dim}\u2500\u2500\u2500 Recommendations ${"\u2500".repeat(28)}${reset}`);
4679
+ for (const r of recs) console.log(` ${r}`);
4680
+ }
4681
+ console.log();
4682
+ }
4683
+ } catch (err) {
4684
+ await pool.end().catch(() => {
4685
+ });
4686
+ console.error(`Error: ${err.message}`);
4687
+ process.exit(1);
4688
+ }
4689
+ process.exit(0);
4690
+ } else if (subcommand === "watch-locks") {
4691
+ const connStr = positionals[1] || resolveConnectionString(1);
4692
+ if (!connStr) {
4693
+ console.error("Error: provide a connection string.\n\nUsage: pg-dash watch-locks <connection>");
4694
+ process.exit(1);
4695
+ }
4696
+ const intervalSec = values.interval ? parseInt(values.interval, 10) : 3;
4697
+ const { Pool: Pool3 } = await import("pg");
4698
+ const pool = new Pool3({ connectionString: connStr, max: 2, connectionTimeoutMillis: 1e4 });
4699
+ const { getLockReport: getLockReport2 } = await Promise.resolve().then(() => (init_locks(), locks_exports));
4700
+ const reset = "\x1B[0m";
4701
+ const dim = "\x1B[2m";
4702
+ const red = "\x1B[31m";
4703
+ const yellow = "\x1B[33m";
4704
+ const cyan = "\x1B[36m";
4705
+ const bold = "\x1B[1m";
4706
+ process.stdout.write("\x1B[?25l");
4707
+ const cleanup = () => {
4708
+ process.stdout.write("\x1B[?25h");
4709
+ pool.end();
4710
+ process.exit(0);
4711
+ };
4712
+ process.on("SIGINT", cleanup);
4713
+ process.on("SIGTERM", cleanup);
4714
+ async function tick() {
4715
+ try {
4716
+ const report = await getLockReport2(pool);
4717
+ console.clear();
4718
+ const ts = (/* @__PURE__ */ new Date()).toLocaleTimeString();
4719
+ console.log(`${bold}pg-dash watch-locks${reset} ${dim}(Ctrl+C to exit \u2014 refresh every ${intervalSec}s \u2014 ${ts})${reset}
4720
+ `);
4721
+ if (report.waitingLocks.length === 0) {
4722
+ console.log(` ${dim}No lock waits detected.${reset}`);
4723
+ } else {
4724
+ console.log(`${bold}${red} Lock Waits (${report.waitingLocks.length})${reset}`);
4725
+ for (const lw of report.waitingLocks) {
4726
+ console.log(`
4727
+ ${red}BLOCKED${reset} pid=${lw.blockedPid} waiting ${lw.blockedDuration}`);
4728
+ console.log(` Query: ${dim}${lw.blockedQuery.slice(0, 100)}${reset}`);
4729
+ console.log(` ${yellow}BLOCKING${reset} pid=${lw.blockingPid} running ${lw.blockingDuration}`);
4730
+ console.log(` Query: ${dim}${lw.blockingQuery.slice(0, 100)}${reset}`);
4731
+ if (lw.table) console.log(` Table: ${lw.table} Lock: ${lw.lockType}`);
4732
+ }
4733
+ }
4734
+ if (report.longRunningQueries.length > 0) {
4735
+ console.log(`
4736
+ ${bold}${yellow} Long-running Queries (${report.longRunningQueries.length})${reset}`);
4737
+ for (const q of report.longRunningQueries) {
4738
+ console.log(`
4739
+ pid=${q.pid} duration=${q.duration} state=${q.state}`);
4740
+ console.log(` ${dim}${q.query.slice(0, 120)}${reset}`);
4741
+ }
4742
+ }
4743
+ if (report.waitingLocks.length === 0 && report.longRunningQueries.length === 0) {
4744
+ console.log(`
4745
+ ${dim}No long-running queries.${reset}`);
4746
+ }
4747
+ } catch (err) {
4748
+ console.error(`Error: ${err.message}`);
4749
+ }
4750
+ }
4751
+ await tick();
4752
+ const timer = setInterval(tick, intervalSec * 1e3);
4753
+ void timer;
4500
4754
  } else {
4501
4755
  if (subcommand && !isValidConnectionString(subcommand) && KNOWN_SUBCOMMANDS.indexOf(subcommand) === -1) {
4502
4756
  console.error(
@@ -4538,4 +4792,7 @@ Run pg-dash --help for usage.`
4538
4792
  webhook
4539
4793
  });
4540
4794
  }
4795
+ var nodeColor2;
4796
+ var renderNode2;
4797
+ var collectNodes3;
4541
4798
  //# sourceMappingURL=cli.js.map