@indiekitai/pg-dash 0.7.0 → 0.8.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/README.md CHANGED
@@ -27,6 +27,9 @@ npx @indiekitai/pg-dash explain "SELECT * FROM orders WHERE user_id = 1" postgre
27
27
  # Real-time lock + long-query monitor (Ctrl+C to exit)
28
28
  npx @indiekitai/pg-dash watch-locks postgres://...
29
29
 
30
+ # Analyze slow queries from pg_stat_statements
31
+ npx @indiekitai/pg-dash slow-queries postgres://... --limit 20 --min-calls 5
32
+
30
33
  # Compare two environments (local vs staging)
31
34
  npx @indiekitai/pg-dash diff-env --source postgres://localhost/db --target postgres://staging/db
32
35
 
package/README.zh-CN.md CHANGED
@@ -27,6 +27,9 @@ npx @indiekitai/pg-dash explain "SELECT * FROM orders WHERE user_id = 1" postgre
27
27
  # 实时锁监控(Ctrl+C 退出)
28
28
  npx @indiekitai/pg-dash watch-locks postgres://...
29
29
 
30
+ # 慢查询分析(基于 pg_stat_statements)
31
+ npx @indiekitai/pg-dash slow-queries postgres://... --limit 20 --min-calls 5
32
+
30
33
  # 对比两个环境(本地 vs 预发)
31
34
  npx @indiekitai/pg-dash diff-env --source postgres://localhost/db --target postgres://staging/db
32
35
 
package/dist/cli.js CHANGED
@@ -125,11 +125,13 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
125
125
  try {
126
126
  const r = await client.query(`
127
127
  SELECT schemaname, relname, n_dead_tup, n_live_tup,
128
- CASE WHEN n_live_tup > 0 THEN round(n_dead_tup::numeric / n_live_tup * 100, 1) ELSE 0 END AS dead_pct,
128
+ CASE WHEN (n_live_tup + n_dead_tup) > 0
129
+ THEN round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 1) ELSE 0 END AS dead_pct,
129
130
  pg_size_pretty(pg_total_relation_size(relid)) AS size
130
131
  FROM pg_stat_user_tables
131
- WHERE n_live_tup > 1000 AND n_dead_tup::float / GREATEST(n_live_tup, 1) > 0.1
132
- ORDER BY n_dead_tup DESC LIMIT 10
132
+ WHERE (n_live_tup + n_dead_tup) > 0
133
+ AND n_dead_tup::float / GREATEST(n_live_tup + n_dead_tup, 1) > 0.1
134
+ ORDER BY n_dead_tup::float / GREATEST(n_live_tup + n_dead_tup, 1) DESC LIMIT 20
133
135
  `);
134
136
  for (const row of r.rows) {
135
137
  const pct = parseFloat(row.dead_pct);
@@ -138,7 +140,7 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
138
140
  severity: pct > 30 ? "critical" : "warning",
139
141
  category: "performance",
140
142
  title: `Table bloat on ${row.relname} (${row.dead_pct}% dead)`,
141
- description: `${row.schemaname}.${row.relname} has ${Number(row.n_dead_tup).toLocaleString()} dead tuples (${row.dead_pct}% of ${Number(row.n_live_tup).toLocaleString()} live rows). Size: ${row.size}.`,
143
+ description: `${row.schemaname}.${row.relname} has ${Number(row.n_dead_tup).toLocaleString()} dead tuples (${row.dead_pct}% of ${(Number(row.n_live_tup) + Number(row.n_dead_tup)).toLocaleString()} total rows). Size: ${row.size}.`,
142
144
  fix: `VACUUM FULL ${row.schemaname}.${row.relname};`,
143
145
  impact: "Dead tuples waste storage and degrade scan performance.",
144
146
  effort: pct > 30 ? "moderate" : "quick"
@@ -202,6 +204,18 @@ SHOW shared_buffers;`,
202
204
  effort: "moderate"
203
205
  });
204
206
  }
207
+ } else {
208
+ issues.push({
209
+ id: `perf-no-pg-stat-statements`,
210
+ severity: "warning",
211
+ category: "performance",
212
+ title: `pg_stat_statements extension not installed`,
213
+ description: `The pg_stat_statements extension is not installed. Without it, slow query detection, query regression analysis, and query performance trending are all unavailable.`,
214
+ fix: `CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
215
+ -- Also add to shared_preload_libraries in postgresql.conf and restart`,
216
+ impact: "No visibility into slow queries, query regressions, or performance trends. You are flying blind on query performance.",
217
+ effort: "moderate"
218
+ });
205
219
  }
206
220
  } catch (err) {
207
221
  console.error("[advisor] Error checking slow queries:", err.message);
@@ -373,8 +387,8 @@ SHOW shared_buffers;`,
373
387
  FROM pg_stat_user_indexes
374
388
  WHERE idx_scan = 0
375
389
  AND indexrelname NOT LIKE '%_pkey'
376
- AND pg_relation_size(indexrelid) > 1048576
377
- ORDER BY pg_relation_size(indexrelid) DESC LIMIT 10
390
+ AND pg_relation_size(indexrelid) > 8192
391
+ ORDER BY pg_relation_size(indexrelid) DESC LIMIT 20
378
392
  `);
379
393
  for (const row of r.rows) {
380
394
  issues.push({
@@ -555,6 +569,35 @@ SELECT pg_reload_conf();`,
555
569
  console.error("[advisor] Error checking autovacuum:", err.message);
556
570
  skipped.push("autovacuum: " + err.message);
557
571
  }
572
+ try {
573
+ const r = await client.query(`
574
+ SELECT schemaname, relname, n_live_tup, n_dead_tup,
575
+ last_autovacuum, last_vacuum
576
+ FROM pg_stat_user_tables
577
+ WHERE n_live_tup < 50
578
+ AND n_dead_tup > 0
579
+ AND last_autovacuum IS NULL
580
+ AND last_vacuum IS NULL
581
+ `);
582
+ if (r.rows.length >= 5) {
583
+ const tables = r.rows.map((row) => row.relname);
584
+ const totalDead = r.rows.reduce((sum, row) => sum + parseInt(row.n_dead_tup, 10), 0);
585
+ issues.push({
586
+ id: `maint-autovacuum-small-tables`,
587
+ severity: "warning",
588
+ category: "maintenance",
589
+ title: `${r.rows.length} small tables never autovacuumed (default threshold=50 too high)`,
590
+ description: `${r.rows.length} tables with fewer than 50 live rows have never been autovacuumed because the default autovacuum_vacuum_threshold (50) exceeds their row count. Total dead tuples: ${totalDead}. Tables: ${tables.slice(0, 10).join(", ")}${tables.length > 10 ? `, ... and ${tables.length - 10} more` : ""}.`,
591
+ fix: `-- Lower per-table threshold for small tables:
592
+ ${tables.slice(0, 5).map((t) => `ALTER TABLE ${t} SET (autovacuum_vacuum_threshold = 5, autovacuum_vacuum_scale_factor = 0.1);`).join("\n")}`,
593
+ impact: "Dead tuples accumulate on small tables indefinitely, causing bloat.",
594
+ effort: "moderate"
595
+ });
596
+ }
597
+ } catch (err) {
598
+ console.error("[advisor] Error checking autovacuum small tables:", err.message);
599
+ skipped.push("autovacuum small tables: " + err.message);
600
+ }
558
601
  try {
559
602
  const sbRes = await client.query(`SELECT setting, unit FROM pg_settings WHERE name = 'shared_buffers'`);
560
603
  const memRes = await client.query(`
@@ -737,12 +780,7 @@ function unignoreIssue(issueId) {
737
780
  const db = getIgnoredDb();
738
781
  db.prepare("DELETE FROM ignored_issues WHERE issue_id = ?").run(issueId);
739
782
  }
740
- function isSafeFix(sql) {
741
- const trimmed = sql.trim();
742
- if (!trimmed) return false;
743
- const statements = trimmed.replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
744
- if (statements.length !== 1) return false;
745
- const upper = statements[0].toUpperCase();
783
+ function isSafeSingleStatement(upper) {
746
784
  if (upper.startsWith("EXPLAIN ANALYZE")) {
747
785
  const afterExplain = upper.replace(/^EXPLAIN\s+ANALYZE\s+/, "").trimStart();
748
786
  return afterExplain.startsWith("SELECT");
@@ -756,8 +794,18 @@ function isSafeFix(sql) {
756
794
  "SELECT PG_TERMINATE_BACKEND(",
757
795
  "SELECT PG_CANCEL_BACKEND("
758
796
  ];
797
+ if (upper.startsWith("ALTER TABLE")) {
798
+ return /^ALTER TABLE\s+\S+\s+SET\s*\(/i.test(upper);
799
+ }
759
800
  return ALLOWED_PREFIXES.some((p) => upper.startsWith(p));
760
801
  }
802
+ function isSafeFix(sql) {
803
+ const trimmed = sql.trim();
804
+ if (!trimmed) return false;
805
+ const statements = trimmed.replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
806
+ if (statements.length === 0) return false;
807
+ return statements.every((stmt) => isSafeSingleStatement(stmt.toUpperCase()));
808
+ }
761
809
  var SEVERITY_WEIGHT, MAX_DEDUCTION, _ignoredDb;
762
810
  var init_advisor = __esm({
763
811
  "src/server/advisor.ts"() {
@@ -4748,6 +4796,8 @@ var { values, positionals } = parseArgs({
4748
4796
  "snapshot-interval": { type: "string" },
4749
4797
  "query-stats-interval": { type: "string" },
4750
4798
  "long-query-threshold": { type: "string" },
4799
+ limit: { type: "string" },
4800
+ "min-calls": { type: "string" },
4751
4801
  help: { type: "boolean", short: "h" },
4752
4802
  version: { type: "boolean", short: "v" },
4753
4803
  threshold: { type: "string" },
@@ -4787,6 +4837,8 @@ Usage:
4787
4837
  pg-dash schema-diff <connection-string> Show latest schema changes
4788
4838
  pg-dash query-stats export <connection> Export query statistics (PG 18+)
4789
4839
  pg-dash query-stats import <file> <connection> Import query statistics (PG 18+)
4840
+ pg-dash slow-queries <connection> Analyze slow queries from pg_stat_statements
4841
+ pg-dash slow-queries <connection> --limit 20 --min-calls 5
4790
4842
  pg-dash --host localhost --user postgres --db mydb
4791
4843
 
4792
4844
  Options:
@@ -4828,7 +4880,7 @@ Environment variables:
4828
4880
  `);
4829
4881
  process.exit(0);
4830
4882
  }
4831
- var KNOWN_SUBCOMMANDS = ["check", "health", "check-migration", "schema-diff", "diff-env", "explain", "watch-locks", "query-stats"];
4883
+ var KNOWN_SUBCOMMANDS = ["check", "health", "check-migration", "schema-diff", "diff-env", "explain", "watch-locks", "query-stats", "slow-queries"];
4832
4884
  var subcommand = positionals[0];
4833
4885
  function isValidConnectionString(s) {
4834
4886
  return s.startsWith("postgresql://") || s.startsWith("postgres://") || s.includes("@") || // user@host shorthand
@@ -5537,6 +5589,93 @@ ${bold}${yellow} Long-running Queries (${report.longRunningQueries.length})${re
5537
5589
  console.error("Error: specify 'export' or 'import'.\n\nUsage:\n pg-dash query-stats export <connection> [--file output.json]\n pg-dash query-stats import <file> <connection>");
5538
5590
  process.exit(1);
5539
5591
  }
5592
+ } else if (subcommand === "slow-queries") {
5593
+ const connStr = positionals[1] || resolveConnectionString(1);
5594
+ if (!connStr) {
5595
+ console.error("Error: provide a connection string.\n\nUsage: pg-dash slow-queries <connection> [--limit 20] [--sort total|time|calls] [--min-calls 5] [--json]");
5596
+ process.exit(1);
5597
+ }
5598
+ const limit = parseInt(values.limit || values.threshold || "20", 10);
5599
+ const sortBy = values.format || "total";
5600
+ const minCalls = parseInt(values["min-calls"] || values["long-query-threshold"] || "5", 10);
5601
+ const outputJson = values.json || false;
5602
+ const { Pool: Pool3 } = await import("pg");
5603
+ const pool = new Pool3({ connectionString: connStr, connectionTimeoutMillis: 1e4 });
5604
+ try {
5605
+ let fmtTime = function(ms) {
5606
+ if (ms < 1) return `${ms.toFixed(2)}ms`;
5607
+ if (ms < 1e3) return `${ms.toFixed(1)}ms`;
5608
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
5609
+ return `${(ms / 6e4).toFixed(1)}m`;
5610
+ }, fmtNum = function(n) {
5611
+ return n.toLocaleString();
5612
+ }, truncateQuery = function(q, maxLen = 120) {
5613
+ const cleaned = q.replace(/\s+/g, " ").trim();
5614
+ return cleaned.length <= maxLen ? cleaned : cleaned.slice(0, maxLen) + "...";
5615
+ };
5616
+ fmtTime2 = fmtTime, fmtNum2 = fmtNum, truncateQuery2 = truncateQuery;
5617
+ const extCheck = await pool.query(`SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'`);
5618
+ if (extCheck.rows.length === 0) {
5619
+ console.error("Error: pg_stat_statements extension is not installed.");
5620
+ console.error(" Run: CREATE EXTENSION pg_stat_statements;");
5621
+ process.exit(1);
5622
+ }
5623
+ let orderCol = "total_exec_time";
5624
+ if (sortBy === "time") orderCol = "mean_exec_time";
5625
+ else if (sortBy === "calls") orderCol = "calls";
5626
+ const res = await pool.query(`
5627
+ SELECT query, calls, total_exec_time, mean_exec_time, stddev_exec_time, rows,
5628
+ shared_blks_hit, shared_blks_read, shared_blks_dirtied, shared_blks_written,
5629
+ temp_blks_read, temp_blks_written
5630
+ FROM pg_stat_statements
5631
+ WHERE calls >= $1
5632
+ ORDER BY ${orderCol} DESC
5633
+ LIMIT $2
5634
+ `, [minCalls, limit]);
5635
+ const totalTimeRes = await pool.query(`SELECT SUM(total_exec_time) as total FROM pg_stat_statements WHERE calls >= $1`, [minCalls]);
5636
+ const totalTime = totalTimeRes.rows[0]?.total || 0;
5637
+ if (outputJson) {
5638
+ console.log(JSON.stringify({
5639
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5640
+ sortBy,
5641
+ minCalls,
5642
+ limit,
5643
+ totalQueryTime: totalTime,
5644
+ queries: res.rows.map((r) => ({
5645
+ query: r.query,
5646
+ calls: Number(r.calls),
5647
+ totalTimeMs: Math.round(r.total_exec_time),
5648
+ meanTimeMs: Math.round(r.mean_exec_time),
5649
+ rows: Number(r.rows),
5650
+ cacheHitRatio: r.shared_blks_hit + r.shared_blks_read > 0 ? Math.round(100 * r.shared_blks_hit / (r.shared_blks_hit + r.shared_blks_read)) : null
5651
+ }))
5652
+ }, null, 2));
5653
+ } else {
5654
+ console.log(`
5655
+ pg-dash slow-queries (sorted by: ${sortBy}, min calls: ${minCalls})
5656
+ `);
5657
+ console.log("\u2550".repeat(100));
5658
+ for (let i = 0; i < res.rows.length; i++) {
5659
+ const r = res.rows[i];
5660
+ const pct = totalTime > 0 ? (r.total_exec_time / totalTime * 100).toFixed(1) : "0.0";
5661
+ const cacheHit = r.shared_blks_hit + r.shared_blks_read > 0 ? (100 * r.shared_blks_hit / (r.shared_blks_hit + r.shared_blks_read)).toFixed(1) : "N/A";
5662
+ console.log(`
5663
+ #${i + 1} (${pct}% of total time)`);
5664
+ console.log(` Calls: ${fmtNum(r.calls)} | Total: ${fmtTime(r.total_exec_time)} | Mean: ${fmtTime(r.mean_exec_time)}`);
5665
+ console.log(` Rows: ${fmtNum(r.rows)} | Cache hit: ${cacheHit}%`);
5666
+ console.log(` Query: ${truncateQuery(r.query)}`);
5667
+ }
5668
+ console.log("\n" + "\u2550".repeat(100));
5669
+ console.log(`
5670
+ Tip: CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
5671
+ `);
5672
+ }
5673
+ await pool.end();
5674
+ } catch (err) {
5675
+ console.error(`Error: ${err.message}`);
5676
+ await pool.end();
5677
+ process.exit(1);
5678
+ }
5540
5679
  } else {
5541
5680
  if (subcommand && !isValidConnectionString(subcommand) && KNOWN_SUBCOMMANDS.indexOf(subcommand) === -1) {
5542
5681
  console.error(
@@ -5581,4 +5720,7 @@ Run pg-dash --help for usage.`
5581
5720
  var nodeColor2;
5582
5721
  var renderNode2;
5583
5722
  var collectNodes3;
5723
+ var fmtTime2;
5724
+ var fmtNum2;
5725
+ var truncateQuery2;
5584
5726
  //# sourceMappingURL=cli.js.map