@indiekitai/pg-dash 0.3.2 → 0.3.4

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
@@ -840,6 +840,28 @@ SELECT pg_reload_conf();`,
840
840
  const ignoredSet = new Set(ignoredIds);
841
841
  const activeIssues = issues.filter((i) => !ignoredSet.has(i.id));
842
842
  const ignoredCount = issues.length - activeIssues.length;
843
+ const batchFixes = [];
844
+ const groups = /* @__PURE__ */ new Map();
845
+ for (const issue of activeIssues) {
846
+ const prefix = issue.id.replace(/-[^-]+$/, "");
847
+ if (!groups.has(prefix)) groups.set(prefix, []);
848
+ groups.get(prefix).push(issue);
849
+ }
850
+ const BATCH_TITLES = {
851
+ "schema-fk-no-idx": "Create all missing FK indexes",
852
+ "schema-unused-idx": "Drop all unused indexes",
853
+ "schema-no-pk": "Fix all tables missing primary keys",
854
+ "maint-vacuum": "VACUUM all overdue tables",
855
+ "maint-analyze": "ANALYZE all tables missing statistics",
856
+ "perf-bloated-idx": "REINDEX all bloated indexes",
857
+ "perf-bloat": "VACUUM FULL all bloated tables"
858
+ };
859
+ for (const [prefix, group] of groups) {
860
+ if (group.length <= 1) continue;
861
+ const title = BATCH_TITLES[prefix] || `Fix all ${group.length} ${prefix} issues`;
862
+ const sql = group.map((i) => i.fix.split("\n").filter((l) => !l.trim().startsWith("--")).join("\n").trim()).filter(Boolean).join(";\n") + ";";
863
+ batchFixes.push({ type: prefix, title: `${title} (${group.length})`, count: group.length, sql });
864
+ }
843
865
  const score = computeAdvisorScore(activeIssues);
844
866
  return {
845
867
  score,
@@ -847,7 +869,8 @@ SELECT pg_reload_conf();`,
847
869
  issues: activeIssues,
848
870
  breakdown: computeBreakdown(activeIssues),
849
871
  skipped,
850
- ignoredCount
872
+ ignoredCount,
873
+ batchFixes
851
874
  };
852
875
  } finally {
853
876
  client.release();
@@ -894,11 +917,81 @@ function isSafeFix(sql) {
894
917
  return ALLOWED_PREFIXES.some((p) => upper.startsWith(p));
895
918
  }
896
919
 
920
+ // src/server/queries/slow-queries.ts
921
+ async function getSlowQueries(pool2) {
922
+ const client = await pool2.connect();
923
+ try {
924
+ const extCheck = await client.query(
925
+ "SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'"
926
+ );
927
+ if (extCheck.rows.length === 0) {
928
+ return [];
929
+ }
930
+ const r = await client.query(`
931
+ SELECT
932
+ queryid::text,
933
+ query,
934
+ calls::int,
935
+ total_exec_time AS total_time,
936
+ mean_exec_time AS mean_time,
937
+ rows::int,
938
+ round(total_exec_time::numeric / 1000, 2)::text || 's' AS total_time_pretty,
939
+ round(mean_exec_time::numeric, 2)::text || 'ms' AS mean_time_pretty
940
+ FROM pg_stat_statements
941
+ WHERE query NOT LIKE '%pg_stat%'
942
+ AND query NOT LIKE '%pg_catalog%'
943
+ ORDER BY total_exec_time DESC
944
+ LIMIT 50
945
+ `);
946
+ return r.rows;
947
+ } catch {
948
+ return [];
949
+ } finally {
950
+ client.release();
951
+ }
952
+ }
953
+
954
+ // src/server/snapshot.ts
955
+ import fs2 from "fs";
956
+ import path2 from "path";
957
+ var SNAPSHOT_FILE = "last-check.json";
958
+ function saveSnapshot(dataDir2, result) {
959
+ fs2.mkdirSync(dataDir2, { recursive: true });
960
+ const snapshot = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), result };
961
+ fs2.writeFileSync(path2.join(dataDir2, SNAPSHOT_FILE), JSON.stringify(snapshot, null, 2));
962
+ }
963
+ function loadSnapshot(dataDir2) {
964
+ const filePath = path2.join(dataDir2, SNAPSHOT_FILE);
965
+ if (!fs2.existsSync(filePath)) return null;
966
+ try {
967
+ return JSON.parse(fs2.readFileSync(filePath, "utf-8"));
968
+ } catch {
969
+ return null;
970
+ }
971
+ }
972
+ function diffSnapshots(prev, current) {
973
+ const prevIds = new Set(prev.issues.map((i) => i.id));
974
+ const currIds = new Set(current.issues.map((i) => i.id));
975
+ const newIssues = current.issues.filter((i) => !prevIds.has(i.id));
976
+ const resolvedIssues = prev.issues.filter((i) => !currIds.has(i.id));
977
+ const unchanged = current.issues.filter((i) => prevIds.has(i.id));
978
+ return {
979
+ scoreDelta: current.score - prev.score,
980
+ previousScore: prev.score,
981
+ currentScore: current.score,
982
+ previousGrade: prev.grade,
983
+ currentGrade: current.grade,
984
+ newIssues,
985
+ resolvedIssues,
986
+ unchanged
987
+ };
988
+ }
989
+
897
990
  // src/mcp.ts
898
991
  import Database2 from "better-sqlite3";
899
- import path2 from "path";
992
+ import path3 from "path";
900
993
  import os2 from "os";
901
- import fs2, { readFileSync } from "fs";
994
+ import fs3, { readFileSync } from "fs";
902
995
  var pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
903
996
  var connString = process.argv[2] || process.env.PG_DASH_CONNECTION_STRING;
904
997
  if (!connString) {
@@ -908,19 +1001,19 @@ if (!connString) {
908
1001
  }
909
1002
  var pool = new Pool({ connectionString: connString });
910
1003
  var longQueryThreshold = parseInt(process.env.PG_DASH_LONG_QUERY_THRESHOLD || "5", 10);
911
- var dataDir = process.env.PG_DASH_DATA_DIR || path2.join(os2.homedir(), ".pg-dash");
912
- fs2.mkdirSync(dataDir, { recursive: true });
1004
+ var dataDir = process.env.PG_DASH_DATA_DIR || path3.join(os2.homedir(), ".pg-dash");
1005
+ fs3.mkdirSync(dataDir, { recursive: true });
913
1006
  var schemaDb = null;
914
1007
  var alertsDb = null;
915
1008
  try {
916
- const schemaPath = path2.join(dataDir, "schema.db");
917
- if (fs2.existsSync(schemaPath)) schemaDb = new Database2(schemaPath, { readonly: true });
1009
+ const schemaPath = path3.join(dataDir, "schema.db");
1010
+ if (fs3.existsSync(schemaPath)) schemaDb = new Database2(schemaPath, { readonly: true });
918
1011
  } catch (err) {
919
1012
  console.error("[mcp] Error:", err.message);
920
1013
  }
921
1014
  try {
922
- const alertsPath = path2.join(dataDir, "alerts.db");
923
- if (fs2.existsSync(alertsPath)) alertsDb = new Database2(alertsPath, { readonly: true });
1015
+ const alertsPath = path3.join(dataDir, "alerts.db");
1016
+ if (fs3.existsSync(alertsPath)) alertsDb = new Database2(alertsPath, { readonly: true });
924
1017
  } catch (err) {
925
1018
  console.error("[mcp] Error:", err.message);
926
1019
  }
@@ -999,6 +1092,153 @@ server.tool("pg_dash_alerts", "Get alert history", {}, async () => {
999
1092
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1000
1093
  }
1001
1094
  });
1095
+ 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 }) => {
1096
+ try {
1097
+ if (!/^\s*SELECT\b/i.test(query)) return { content: [{ type: "text", text: "Error: Only SELECT queries are allowed" }], isError: true };
1098
+ const client = await pool.connect();
1099
+ try {
1100
+ await client.query("SET statement_timeout = '30s'");
1101
+ await client.query("BEGIN");
1102
+ try {
1103
+ const r = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`);
1104
+ await client.query("ROLLBACK");
1105
+ await client.query("RESET statement_timeout");
1106
+ return { content: [{ type: "text", text: JSON.stringify(r.rows[0]["QUERY PLAN"], null, 2) }] };
1107
+ } catch (err) {
1108
+ await client.query("ROLLBACK").catch(() => {
1109
+ });
1110
+ await client.query("RESET statement_timeout").catch(() => {
1111
+ });
1112
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1113
+ }
1114
+ } finally {
1115
+ client.release();
1116
+ }
1117
+ } catch (err) {
1118
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1119
+ }
1120
+ });
1121
+ server.tool("pg_dash_batch_fix", "Get batch fix SQL for issues (optionally filtered by category)", { category: z.string().optional().describe("Filter by issue type prefix, e.g. 'schema-missing-fk-index'") }, async ({ category }) => {
1122
+ try {
1123
+ const report = await getAdvisorReport(pool, longQueryThreshold);
1124
+ let fixes = report.batchFixes;
1125
+ if (category) fixes = fixes.filter((f) => f.type.startsWith(category));
1126
+ if (fixes.length === 0) return { content: [{ type: "text", text: "No batch fixes found" + (category ? ` for category '${category}'` : "") }] };
1127
+ const combined = fixes.map((f) => `-- ${f.title}
1128
+ ${f.sql}`).join("\n\n");
1129
+ return { content: [{ type: "text", text: combined }] };
1130
+ } catch (err) {
1131
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1132
+ }
1133
+ });
1134
+ server.tool("pg_dash_slow_queries", "Get top slow queries from pg_stat_statements", {
1135
+ limit: z.number().optional().default(20).describe("Max queries to return (default 20)"),
1136
+ orderBy: z.enum(["total_time", "mean_time", "calls"]).optional().default("total_time").describe("Sort order")
1137
+ }, async ({ limit, orderBy }) => {
1138
+ try {
1139
+ const all = await getSlowQueries(pool);
1140
+ if (all.length === 0) return { content: [{ type: "text", text: "No slow query data available. pg_stat_statements may not be installed." }] };
1141
+ const sorted = [...all].sort((a, b) => b[orderBy] - a[orderBy]);
1142
+ return { content: [{ type: "text", text: JSON.stringify(sorted.slice(0, limit), null, 2) }] };
1143
+ } catch (err) {
1144
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1145
+ }
1146
+ });
1147
+ server.tool("pg_dash_table_sizes", "Get table sizes with data/index breakdown (top 30)", {}, async () => {
1148
+ try {
1149
+ const client = await pool.connect();
1150
+ try {
1151
+ const r = await client.query(`
1152
+ SELECT schemaname, relname,
1153
+ pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as total_size,
1154
+ pg_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as table_size,
1155
+ pg_indexes_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as index_size
1156
+ FROM pg_stat_user_tables
1157
+ ORDER BY pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) DESC
1158
+ LIMIT 30
1159
+ `);
1160
+ const tables = r.rows.map((row) => ({
1161
+ schema: row.schemaname,
1162
+ name: row.relname,
1163
+ totalSize: parseInt(row.total_size),
1164
+ tableSize: parseInt(row.table_size),
1165
+ indexSize: parseInt(row.index_size)
1166
+ }));
1167
+ return { content: [{ type: "text", text: JSON.stringify(tables, null, 2) }] };
1168
+ } finally {
1169
+ client.release();
1170
+ }
1171
+ } catch (err) {
1172
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1173
+ }
1174
+ });
1175
+ server.tool("pg_dash_export", "Export full health report", { format: z.enum(["json", "md"]).optional().default("json").describe("Output format: json or md") }, async ({ format }) => {
1176
+ try {
1177
+ const [overview, advisor] = await Promise.all([
1178
+ getOverview(pool),
1179
+ getAdvisorReport(pool, longQueryThreshold)
1180
+ ]);
1181
+ if (format === "md") {
1182
+ const lines = [];
1183
+ lines.push(`# pg-dash Health Report`);
1184
+ lines.push(`
1185
+ Generated: ${(/* @__PURE__ */ new Date()).toISOString()}
1186
+ `);
1187
+ lines.push(`## Overview
1188
+ `);
1189
+ lines.push(`- **PostgreSQL**: ${overview.version}`);
1190
+ lines.push(`- **Database Size**: ${overview.dbSize}`);
1191
+ lines.push(`- **Connections**: ${overview.connections.active} active / ${overview.connections.idle} idle / ${overview.connections.max} max`);
1192
+ lines.push(`
1193
+ ## Health Score: ${advisor.score}/100 (Grade: ${advisor.grade})
1194
+ `);
1195
+ lines.push(`| Category | Grade | Score | Issues |`);
1196
+ lines.push(`|----------|-------|-------|--------|`);
1197
+ for (const [cat, b] of Object.entries(advisor.breakdown)) {
1198
+ lines.push(`| ${cat} | ${b.grade} | ${b.score}/100 | ${b.count} |`);
1199
+ }
1200
+ if (advisor.issues.length > 0) {
1201
+ lines.push(`
1202
+ ### Issues (${advisor.issues.length})
1203
+ `);
1204
+ for (const issue of advisor.issues) {
1205
+ const icon = issue.severity === "critical" ? "\u{1F534}" : issue.severity === "warning" ? "\u{1F7E1}" : "\u{1F535}";
1206
+ lines.push(`- ${icon} [${issue.severity}] ${issue.title}`);
1207
+ }
1208
+ }
1209
+ if (advisor.batchFixes.length > 0) {
1210
+ lines.push(`
1211
+ ### \u{1F527} Batch Fixes
1212
+ `);
1213
+ for (const fix of advisor.batchFixes) {
1214
+ lines.push(`\`\`\`sql
1215
+ ${fix.sql}
1216
+ \`\`\`
1217
+ `);
1218
+ }
1219
+ }
1220
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1221
+ }
1222
+ return { content: [{ type: "text", text: JSON.stringify({ overview, advisor, exportedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) }] };
1223
+ } catch (err) {
1224
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1225
+ }
1226
+ });
1227
+ server.tool("pg_dash_diff", "Compare current health with last saved snapshot", {}, async () => {
1228
+ try {
1229
+ const prev = loadSnapshot(dataDir);
1230
+ const current = await getAdvisorReport(pool, longQueryThreshold);
1231
+ if (!prev) {
1232
+ saveSnapshot(dataDir, current);
1233
+ return { content: [{ type: "text", text: JSON.stringify({ message: "No previous snapshot found. Current result saved as baseline.", score: current.score, grade: current.grade, issues: current.issues.length }, null, 2) }] };
1234
+ }
1235
+ const diff = diffSnapshots(prev.result, current);
1236
+ saveSnapshot(dataDir, current);
1237
+ return { content: [{ type: "text", text: JSON.stringify({ ...diff, previousTimestamp: prev.timestamp }, null, 2) }] };
1238
+ } catch (err) {
1239
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1240
+ }
1241
+ });
1002
1242
  var transport = new StdioServerTransport();
1003
1243
  await server.connect(transport);
1004
1244
  //# sourceMappingURL=mcp.js.map