@indiekitai/pg-dash 0.4.5 → 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
@@ -300,30 +300,44 @@ SHOW shared_buffers;`,
300
300
  }
301
301
  try {
302
302
  const r = await client.query(`
303
- SELECT pid, state, now() - state_change AS idle_duration,
303
+ SELECT pid,
304
304
  client_addr::text, application_name,
305
305
  extract(epoch from now() - state_change)::int AS idle_seconds
306
306
  FROM pg_stat_activity
307
- WHERE state IN ('idle', 'idle in transaction')
307
+ WHERE state = 'idle in transaction'
308
308
  AND now() - state_change > $1 * interval '1 minute'
309
309
  AND pid != pg_backend_pid()
310
+ ORDER BY idle_seconds DESC
310
311
  `, [longQueryThreshold]);
311
- for (const row of r.rows) {
312
- const isIdleTx = row.state === "idle in transaction";
312
+ if (r.rows.length === 1) {
313
+ const row = r.rows[0];
313
314
  issues.push({
314
- id: `maint-idle-${row.pid}`,
315
- severity: isIdleTx ? "warning" : "info",
315
+ id: `maint-idle-tx-${row.pid}`,
316
+ severity: "warning",
316
317
  category: "maintenance",
317
- title: `${isIdleTx ? "Idle in transaction" : "Idle connection"} (PID ${row.pid})`,
318
- description: `PID ${row.pid} from ${row.client_addr || "local"} (${row.application_name || "unknown"}) has been ${row.state} for ${Math.round(row.idle_seconds / 60)} minutes.`,
318
+ title: `Idle-in-transaction connection (PID ${row.pid})`,
319
+ description: `PID ${row.pid} from ${row.client_addr || "local"} (${row.application_name || "unknown"}) has been idle in transaction for ${Math.round(row.idle_seconds / 60)} minutes. This holds locks and blocks VACUUM.`,
319
320
  fix: `SELECT pg_terminate_backend(${row.pid});`,
320
- impact: isIdleTx ? "Idle-in-transaction connections hold locks and prevent VACUUM." : "Idle connections consume connection slots.",
321
+ impact: "Idle-in-transaction connections hold locks and prevent VACUUM.",
322
+ effort: "quick"
323
+ });
324
+ } else if (r.rows.length > 1) {
325
+ const pids = r.rows.map((row) => row.pid);
326
+ const maxMin = Math.round(r.rows[0].idle_seconds / 60);
327
+ issues.push({
328
+ id: `maint-idle-tx-multi`,
329
+ severity: "warning",
330
+ category: "maintenance",
331
+ title: `${r.rows.length} idle-in-transaction connections (longest: ${maxMin}m)`,
332
+ description: `${r.rows.length} connections have been idle in transaction for over ${longQueryThreshold} minutes. These hold locks and prevent VACUUM from running.`,
333
+ fix: `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle in transaction' AND now() - state_change > interval '${longQueryThreshold} minutes';`,
334
+ impact: "Idle-in-transaction connections hold locks and prevent VACUUM.",
321
335
  effort: "quick"
322
336
  });
323
337
  }
324
338
  } catch (err) {
325
- console.error("[advisor] Error checking idle connections:", err.message);
326
- skipped.push("idle connections: " + err.message);
339
+ console.error("[advisor] Error checking idle-in-transaction connections:", err.message);
340
+ skipped.push("idle-in-transaction: " + err.message);
327
341
  }
328
342
  try {
329
343
  const r = await client.query(`
@@ -2065,6 +2079,97 @@ var init_env_differ = __esm({
2065
2079
  }
2066
2080
  });
2067
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
+
2068
2173
  // src/cli.ts
2069
2174
  import { parseArgs } from "util";
2070
2175
 
@@ -4042,6 +4147,7 @@ var { values, positionals } = parseArgs({
4042
4147
  "slack-webhook": { type: "string" },
4043
4148
  "discord-webhook": { type: "string" },
4044
4149
  "no-open": { type: "boolean", default: false },
4150
+ "no-analyze": { type: "boolean", default: false },
4045
4151
  json: { type: "boolean", default: false },
4046
4152
  host: { type: "string" },
4047
4153
  user: { type: "string", short: "u" },
@@ -4085,6 +4191,8 @@ Usage:
4085
4191
  pg-dash check <connection-string> Run health check and exit
4086
4192
  pg-dash health <connection-string> Alias for check
4087
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
4088
4196
  pg-dash diff-env --source <url> --target <url> Compare two environments
4089
4197
  pg-dash schema-diff <connection-string> Show latest schema changes
4090
4198
  pg-dash --host localhost --user postgres --db mydb
@@ -4126,7 +4234,7 @@ Environment variables:
4126
4234
  `);
4127
4235
  process.exit(0);
4128
4236
  }
4129
- 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"];
4130
4238
  var subcommand = positionals[0];
4131
4239
  function isValidConnectionString(s) {
4132
4240
  return s.startsWith("postgresql://") || s.startsWith("postgres://") || s.includes("@") || // user@host shorthand
@@ -4483,6 +4591,166 @@ Migration check: ${filePath}`);
4483
4591
  console.error(`Error: ${err.message}`);
4484
4592
  process.exit(1);
4485
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;
4486
4754
  } else {
4487
4755
  if (subcommand && !isValidConnectionString(subcommand) && KNOWN_SUBCOMMANDS.indexOf(subcommand) === -1) {
4488
4756
  console.error(
@@ -4524,4 +4792,7 @@ Run pg-dash --help for usage.`
4524
4792
  webhook
4525
4793
  });
4526
4794
  }
4795
+ var nodeColor2;
4796
+ var renderNode2;
4797
+ var collectNodes3;
4527
4798
  //# sourceMappingURL=cli.js.map