@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/dist/mcp.js CHANGED
@@ -347,11 +347,13 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
347
347
  try {
348
348
  const r = await client.query(`
349
349
  SELECT schemaname, relname, n_dead_tup, n_live_tup,
350
- CASE WHEN n_live_tup > 0 THEN round(n_dead_tup::numeric / n_live_tup * 100, 1) ELSE 0 END AS dead_pct,
350
+ CASE WHEN (n_live_tup + n_dead_tup) > 0
351
+ THEN round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 1) ELSE 0 END AS dead_pct,
351
352
  pg_size_pretty(pg_total_relation_size(relid)) AS size
352
353
  FROM pg_stat_user_tables
353
- WHERE n_live_tup > 1000 AND n_dead_tup::float / GREATEST(n_live_tup, 1) > 0.1
354
- ORDER BY n_dead_tup DESC LIMIT 10
354
+ WHERE (n_live_tup + n_dead_tup) > 0
355
+ AND n_dead_tup::float / GREATEST(n_live_tup + n_dead_tup, 1) > 0.1
356
+ ORDER BY n_dead_tup::float / GREATEST(n_live_tup + n_dead_tup, 1) DESC LIMIT 20
355
357
  `);
356
358
  for (const row of r.rows) {
357
359
  const pct = parseFloat(row.dead_pct);
@@ -360,7 +362,7 @@ CREATE INDEX CONCURRENTLY idx_${row.relname}_<column> ON ${row.schemaname}.${row
360
362
  severity: pct > 30 ? "critical" : "warning",
361
363
  category: "performance",
362
364
  title: `Table bloat on ${row.relname} (${row.dead_pct}% dead)`,
363
- 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}.`,
365
+ 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}.`,
364
366
  fix: `VACUUM FULL ${row.schemaname}.${row.relname};`,
365
367
  impact: "Dead tuples waste storage and degrade scan performance.",
366
368
  effort: pct > 30 ? "moderate" : "quick"
@@ -424,6 +426,18 @@ SHOW shared_buffers;`,
424
426
  effort: "moderate"
425
427
  });
426
428
  }
429
+ } else {
430
+ issues.push({
431
+ id: `perf-no-pg-stat-statements`,
432
+ severity: "warning",
433
+ category: "performance",
434
+ title: `pg_stat_statements extension not installed`,
435
+ description: `The pg_stat_statements extension is not installed. Without it, slow query detection, query regression analysis, and query performance trending are all unavailable.`,
436
+ fix: `CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
437
+ -- Also add to shared_preload_libraries in postgresql.conf and restart`,
438
+ impact: "No visibility into slow queries, query regressions, or performance trends. You are flying blind on query performance.",
439
+ effort: "moderate"
440
+ });
427
441
  }
428
442
  } catch (err) {
429
443
  console.error("[advisor] Error checking slow queries:", err.message);
@@ -595,8 +609,8 @@ SHOW shared_buffers;`,
595
609
  FROM pg_stat_user_indexes
596
610
  WHERE idx_scan = 0
597
611
  AND indexrelname NOT LIKE '%_pkey'
598
- AND pg_relation_size(indexrelid) > 1048576
599
- ORDER BY pg_relation_size(indexrelid) DESC LIMIT 10
612
+ AND pg_relation_size(indexrelid) > 8192
613
+ ORDER BY pg_relation_size(indexrelid) DESC LIMIT 20
600
614
  `);
601
615
  for (const row of r.rows) {
602
616
  issues.push({
@@ -777,6 +791,35 @@ SELECT pg_reload_conf();`,
777
791
  console.error("[advisor] Error checking autovacuum:", err.message);
778
792
  skipped.push("autovacuum: " + err.message);
779
793
  }
794
+ try {
795
+ const r = await client.query(`
796
+ SELECT schemaname, relname, n_live_tup, n_dead_tup,
797
+ last_autovacuum, last_vacuum
798
+ FROM pg_stat_user_tables
799
+ WHERE n_live_tup < 50
800
+ AND n_dead_tup > 0
801
+ AND last_autovacuum IS NULL
802
+ AND last_vacuum IS NULL
803
+ `);
804
+ if (r.rows.length >= 5) {
805
+ const tables = r.rows.map((row) => row.relname);
806
+ const totalDead = r.rows.reduce((sum, row) => sum + parseInt(row.n_dead_tup, 10), 0);
807
+ issues.push({
808
+ id: `maint-autovacuum-small-tables`,
809
+ severity: "warning",
810
+ category: "maintenance",
811
+ title: `${r.rows.length} small tables never autovacuumed (default threshold=50 too high)`,
812
+ 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` : ""}.`,
813
+ fix: `-- Lower per-table threshold for small tables:
814
+ ${tables.slice(0, 5).map((t) => `ALTER TABLE ${t} SET (autovacuum_vacuum_threshold = 5, autovacuum_vacuum_scale_factor = 0.1);`).join("\n")}`,
815
+ impact: "Dead tuples accumulate on small tables indefinitely, causing bloat.",
816
+ effort: "moderate"
817
+ });
818
+ }
819
+ } catch (err) {
820
+ console.error("[advisor] Error checking autovacuum small tables:", err.message);
821
+ skipped.push("autovacuum small tables: " + err.message);
822
+ }
780
823
  try {
781
824
  const sbRes = await client.query(`SELECT setting, unit FROM pg_settings WHERE name = 'shared_buffers'`);
782
825
  const memRes = await client.query(`
@@ -952,12 +995,7 @@ function getIgnoredIssues() {
952
995
  return [];
953
996
  }
954
997
  }
955
- function isSafeFix(sql) {
956
- const trimmed = sql.trim();
957
- if (!trimmed) return false;
958
- const statements = trimmed.replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
959
- if (statements.length !== 1) return false;
960
- const upper = statements[0].toUpperCase();
998
+ function isSafeSingleStatement(upper) {
961
999
  if (upper.startsWith("EXPLAIN ANALYZE")) {
962
1000
  const afterExplain = upper.replace(/^EXPLAIN\s+ANALYZE\s+/, "").trimStart();
963
1001
  return afterExplain.startsWith("SELECT");
@@ -971,8 +1009,18 @@ function isSafeFix(sql) {
971
1009
  "SELECT PG_TERMINATE_BACKEND(",
972
1010
  "SELECT PG_CANCEL_BACKEND("
973
1011
  ];
1012
+ if (upper.startsWith("ALTER TABLE")) {
1013
+ return /^ALTER TABLE\s+\S+\s+SET\s*\(/i.test(upper);
1014
+ }
974
1015
  return ALLOWED_PREFIXES.some((p) => upper.startsWith(p));
975
1016
  }
1017
+ function isSafeFix(sql) {
1018
+ const trimmed = sql.trim();
1019
+ if (!trimmed) return false;
1020
+ const statements = trimmed.replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
1021
+ if (statements.length === 0) return false;
1022
+ return statements.every((stmt) => isSafeSingleStatement(stmt.toUpperCase()));
1023
+ }
976
1024
 
977
1025
  // src/server/queries/slow-queries.ts
978
1026
  async function getSlowQueries(pool2) {
@@ -2986,14 +3034,25 @@ server.tool("pg_dash_schema_changes", "Get recent schema changes", {}, async ()
2986
3034
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
2987
3035
  }
2988
3036
  });
2989
- server.tool("pg_dash_fix", "Execute a safe fix (VACUUM, ANALYZE, REINDEX, etc.)", { sql: z.string().describe("SQL to execute (must be a safe operation)") }, async ({ sql }) => {
3037
+ server.tool("pg_dash_fix", "Execute safe fix(es) \u2014 supports batch: multiple VACUUM/ANALYZE/REINDEX statements separated by semicolons", { sql: z.string().describe("SQL to execute (must be safe operations; multiple statements separated by ; are supported)") }, async ({ sql }) => {
2990
3038
  try {
2991
- if (!isSafeFix(sql)) return { content: [{ type: "text", text: "Operation not allowed. Only VACUUM, ANALYZE, REINDEX, CREATE/DROP INDEX CONCURRENTLY, pg_terminate_backend, pg_cancel_backend, and EXPLAIN ANALYZE are permitted." }], isError: true };
3039
+ if (!isSafeFix(sql)) return { content: [{ type: "text", text: "Operation not allowed. Only VACUUM, ANALYZE, REINDEX, CREATE/DROP INDEX CONCURRENTLY, ALTER TABLE SET, pg_terminate_backend, pg_cancel_backend, and EXPLAIN ANALYZE are permitted." }], isError: true };
2992
3040
  const client = await pool.connect();
2993
3041
  try {
3042
+ const statements = sql.trim().replace(/;\s*$/, "").split(";").map((s) => s.trim()).filter(Boolean);
2994
3043
  const start = Date.now();
2995
- const result = await client.query(sql);
2996
- return { content: [{ type: "text", text: JSON.stringify({ ok: true, duration: Date.now() - start, rowCount: result.rowCount, rows: result.rows || [] }, null, 2) }] };
3044
+ const results = [];
3045
+ for (const stmt of statements) {
3046
+ try {
3047
+ await client.query(stmt);
3048
+ results.push({ sql: stmt, ok: true });
3049
+ } catch (err) {
3050
+ results.push({ sql: stmt, ok: false, error: err.message });
3051
+ }
3052
+ }
3053
+ const succeeded = results.filter((r) => r.ok).length;
3054
+ const failed = results.filter((r) => !r.ok).length;
3055
+ return { content: [{ type: "text", text: JSON.stringify({ ok: failed === 0, duration: Date.now() - start, total: statements.length, succeeded, failed, results }, null, 2) }] };
2997
3056
  } finally {
2998
3057
  client.release();
2999
3058
  }
@@ -3010,15 +3069,15 @@ server.tool("pg_dash_alerts", "Get alert history", {}, async () => {
3010
3069
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
3011
3070
  }
3012
3071
  });
3013
- server.tool("pg_dash_explain", "Run EXPLAIN ANALYZE on a SELECT query (read-only, wrapped in BEGIN/ROLLBACK)", { query: z.string().describe("SELECT query to explain") }, async ({ query }) => {
3072
+ server.tool("pg_dash_explain", "Run EXPLAIN ANALYZE on a SELECT query (read-only, wrapped in BEGIN/ROLLBACK)", { sql: z.string().describe("SELECT query to explain") }, async ({ sql }) => {
3014
3073
  try {
3015
- if (!/^\s*SELECT\b/i.test(query)) return { content: [{ type: "text", text: "Error: Only SELECT queries are allowed" }], isError: true };
3074
+ if (!/^\s*SELECT\b/i.test(sql)) return { content: [{ type: "text", text: "Error: Only SELECT queries are allowed" }], isError: true };
3016
3075
  const client = await pool.connect();
3017
3076
  try {
3018
3077
  await client.query("SET statement_timeout = '30s'");
3019
3078
  await client.query("BEGIN");
3020
3079
  try {
3021
- const r = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`);
3080
+ const r = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${sql}`);
3022
3081
  await client.query("ROLLBACK");
3023
3082
  await client.query("RESET statement_timeout");
3024
3083
  return { content: [{ type: "text", text: JSON.stringify(r.rows[0]["QUERY PLAN"], null, 2) }] };