@indiekitai/pg-dash 0.3.4 → 0.3.6

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
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
- #!/usr/bin/env node
3
2
 
4
3
  // src/mcp.ts
5
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { Pool } from "pg";
6
+ import { Pool as Pool2 } from "pg";
8
7
  import { z } from "zod";
9
8
 
10
9
  // src/server/queries/overview.ts
@@ -954,27 +953,28 @@ async function getSlowQueries(pool2) {
954
953
  // src/server/snapshot.ts
955
954
  import fs2 from "fs";
956
955
  import path2 from "path";
957
- var SNAPSHOT_FILE = "last-check.json";
958
- function saveSnapshot(dataDir2, result) {
959
- fs2.mkdirSync(dataDir2, { recursive: true });
956
+ function normalizeIssueId(id) {
957
+ return id.replace(/-\d+$/, "");
958
+ }
959
+ function saveSnapshot(snapshotPath, result) {
960
+ fs2.mkdirSync(path2.dirname(snapshotPath), { recursive: true });
960
961
  const snapshot = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), result };
961
- fs2.writeFileSync(path2.join(dataDir2, SNAPSHOT_FILE), JSON.stringify(snapshot, null, 2));
962
+ fs2.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
962
963
  }
963
- function loadSnapshot(dataDir2) {
964
- const filePath = path2.join(dataDir2, SNAPSHOT_FILE);
965
- if (!fs2.existsSync(filePath)) return null;
964
+ function loadSnapshot(snapshotPath) {
965
+ if (!fs2.existsSync(snapshotPath)) return null;
966
966
  try {
967
- return JSON.parse(fs2.readFileSync(filePath, "utf-8"));
967
+ return JSON.parse(fs2.readFileSync(snapshotPath, "utf-8"));
968
968
  } catch {
969
969
  return null;
970
970
  }
971
971
  }
972
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));
973
+ const prevNormIds = new Set(prev.issues.map((i) => normalizeIssueId(i.id)));
974
+ const currNormIds = new Set(current.issues.map((i) => normalizeIssueId(i.id)));
975
+ const newIssues = current.issues.filter((i) => !prevNormIds.has(normalizeIssueId(i.id)));
976
+ const resolvedIssues = prev.issues.filter((i) => !currNormIds.has(normalizeIssueId(i.id)));
977
+ const unchanged = current.issues.filter((i) => prevNormIds.has(normalizeIssueId(i.id)));
978
978
  return {
979
979
  scoreDelta: current.score - prev.score,
980
980
  previousScore: prev.score,
@@ -987,6 +987,612 @@ function diffSnapshots(prev, current) {
987
987
  };
988
988
  }
989
989
 
990
+ // src/server/env-differ.ts
991
+ import { Pool } from "pg";
992
+ async function fetchTables(pool2) {
993
+ const res = await pool2.query(`
994
+ SELECT table_name
995
+ FROM information_schema.tables
996
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
997
+ ORDER BY table_name
998
+ `);
999
+ return res.rows.map((r) => r.table_name);
1000
+ }
1001
+ async function fetchColumns(pool2) {
1002
+ const res = await pool2.query(`
1003
+ SELECT table_name, column_name, data_type, is_nullable, column_default
1004
+ FROM information_schema.columns
1005
+ WHERE table_schema = 'public'
1006
+ ORDER BY table_name, ordinal_position
1007
+ `);
1008
+ return res.rows;
1009
+ }
1010
+ async function fetchIndexes(pool2) {
1011
+ const res = await pool2.query(`
1012
+ SELECT tablename, indexname
1013
+ FROM pg_indexes
1014
+ WHERE schemaname = 'public' AND indexname NOT LIKE '%_pkey'
1015
+ ORDER BY tablename, indexname
1016
+ `);
1017
+ return res.rows;
1018
+ }
1019
+ function diffTables(sourceTables, targetTables) {
1020
+ const sourceSet = new Set(sourceTables);
1021
+ const targetSet = new Set(targetTables);
1022
+ return {
1023
+ missingTables: sourceTables.filter((t) => !targetSet.has(t)),
1024
+ extraTables: targetTables.filter((t) => !sourceSet.has(t))
1025
+ };
1026
+ }
1027
+ function groupColumnsByTable(columns) {
1028
+ const map = /* @__PURE__ */ new Map();
1029
+ for (const col of columns) {
1030
+ if (!map.has(col.table_name)) map.set(col.table_name, /* @__PURE__ */ new Map());
1031
+ const info = {
1032
+ name: col.column_name,
1033
+ type: col.data_type,
1034
+ nullable: col.is_nullable === "YES"
1035
+ };
1036
+ if (col.column_default !== null && col.column_default !== void 0) {
1037
+ info.default = col.column_default;
1038
+ }
1039
+ map.get(col.table_name).set(col.column_name, info);
1040
+ }
1041
+ return map;
1042
+ }
1043
+ function diffColumns(sourceCols, targetCols, commonTables) {
1044
+ const sourceByTable = groupColumnsByTable(sourceCols);
1045
+ const targetByTable = groupColumnsByTable(targetCols);
1046
+ const diffs = [];
1047
+ for (const table of commonTables) {
1048
+ const srcMap = sourceByTable.get(table) ?? /* @__PURE__ */ new Map();
1049
+ const tgtMap = targetByTable.get(table) ?? /* @__PURE__ */ new Map();
1050
+ const missingColumns = [];
1051
+ const extraColumns = [];
1052
+ const typeDiffs = [];
1053
+ for (const [colName, srcInfo] of srcMap) {
1054
+ if (!tgtMap.has(colName)) {
1055
+ missingColumns.push(srcInfo);
1056
+ } else {
1057
+ const tgtInfo = tgtMap.get(colName);
1058
+ if (srcInfo.type !== tgtInfo.type) {
1059
+ typeDiffs.push({ column: colName, sourceType: srcInfo.type, targetType: tgtInfo.type });
1060
+ }
1061
+ }
1062
+ }
1063
+ for (const [colName, tgtInfo] of tgtMap) {
1064
+ if (!srcMap.has(colName)) {
1065
+ extraColumns.push(tgtInfo);
1066
+ }
1067
+ }
1068
+ if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0) {
1069
+ diffs.push({ table, missingColumns, extraColumns, typeDiffs });
1070
+ }
1071
+ }
1072
+ return diffs;
1073
+ }
1074
+ function groupIndexesByTable(indexes) {
1075
+ const map = /* @__PURE__ */ new Map();
1076
+ for (const idx of indexes) {
1077
+ if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new Set());
1078
+ map.get(idx.tablename).add(idx.indexname);
1079
+ }
1080
+ return map;
1081
+ }
1082
+ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
1083
+ const srcByTable = groupIndexesByTable(sourceIdxs);
1084
+ const tgtByTable = groupIndexesByTable(targetIdxs);
1085
+ const diffs = [];
1086
+ const allTables = /* @__PURE__ */ new Set([
1087
+ ...sourceIdxs.map((i) => i.tablename),
1088
+ ...targetIdxs.map((i) => i.tablename)
1089
+ ]);
1090
+ for (const table of allTables) {
1091
+ if (!commonTables.includes(table)) continue;
1092
+ const srcSet = srcByTable.get(table) ?? /* @__PURE__ */ new Set();
1093
+ const tgtSet = tgtByTable.get(table) ?? /* @__PURE__ */ new Set();
1094
+ const missingIndexes = [...srcSet].filter((i) => !tgtSet.has(i));
1095
+ const extraIndexes = [...tgtSet].filter((i) => !srcSet.has(i));
1096
+ if (missingIndexes.length > 0 || extraIndexes.length > 0) {
1097
+ diffs.push({ table, missingIndexes, extraIndexes });
1098
+ }
1099
+ }
1100
+ return diffs;
1101
+ }
1102
+ function countSchemaDrifts(schema) {
1103
+ let n = schema.missingTables.length + schema.extraTables.length;
1104
+ for (const cd of schema.columnDiffs) {
1105
+ n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length;
1106
+ }
1107
+ for (const id of schema.indexDiffs) {
1108
+ n += id.missingIndexes.length + id.extraIndexes.length;
1109
+ }
1110
+ return n;
1111
+ }
1112
+ async function diffEnvironments(sourceConn, targetConn, options) {
1113
+ const sourcePool = new Pool({ connectionString: sourceConn, connectionTimeoutMillis: 1e4 });
1114
+ const targetPool = new Pool({ connectionString: targetConn, connectionTimeoutMillis: 1e4 });
1115
+ try {
1116
+ const [
1117
+ sourceTables,
1118
+ targetTables,
1119
+ sourceCols,
1120
+ targetCols,
1121
+ sourceIdxs,
1122
+ targetIdxs
1123
+ ] = await Promise.all([
1124
+ fetchTables(sourcePool),
1125
+ fetchTables(targetPool),
1126
+ fetchColumns(sourcePool),
1127
+ fetchColumns(targetPool),
1128
+ fetchIndexes(sourcePool),
1129
+ fetchIndexes(targetPool)
1130
+ ]);
1131
+ const { missingTables, extraTables } = diffTables(sourceTables, targetTables);
1132
+ const sourceSet = new Set(sourceTables);
1133
+ const targetSet = new Set(targetTables);
1134
+ const commonTables = sourceTables.filter((t) => targetSet.has(t));
1135
+ const columnDiffs = diffColumns(sourceCols, targetCols, commonTables);
1136
+ const indexDiffs = diffIndexes(sourceIdxs, targetIdxs, commonTables);
1137
+ const schema = { missingTables, extraTables, columnDiffs, indexDiffs };
1138
+ const schemaDrifts = countSchemaDrifts(schema);
1139
+ let health;
1140
+ if (options?.includeHealth) {
1141
+ const longQueryThreshold2 = 5;
1142
+ const [srcReport, tgtReport] = await Promise.all([
1143
+ getAdvisorReport(sourcePool, longQueryThreshold2),
1144
+ getAdvisorReport(targetPool, longQueryThreshold2)
1145
+ ]);
1146
+ const srcIssueKeys = new Set(srcReport.issues.map((i) => i.title));
1147
+ const tgtIssueKeys = new Set(tgtReport.issues.map((i) => i.title));
1148
+ const sourceOnlyIssues = srcReport.issues.filter((i) => !tgtIssueKeys.has(i.title)).map((i) => `${i.severity}: ${i.title}`);
1149
+ const targetOnlyIssues = tgtReport.issues.filter((i) => !srcIssueKeys.has(i.title)).map((i) => `${i.severity}: ${i.title}`);
1150
+ health = {
1151
+ source: { score: srcReport.score, grade: srcReport.grade, url: maskConnectionString(sourceConn) },
1152
+ target: { score: tgtReport.score, grade: tgtReport.grade, url: maskConnectionString(targetConn) },
1153
+ sourceOnlyIssues,
1154
+ targetOnlyIssues
1155
+ };
1156
+ }
1157
+ return {
1158
+ schema,
1159
+ health,
1160
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
1161
+ summary: {
1162
+ schemaDrifts,
1163
+ identical: schemaDrifts === 0
1164
+ }
1165
+ };
1166
+ } finally {
1167
+ await Promise.allSettled([sourcePool.end(), targetPool.end()]);
1168
+ }
1169
+ }
1170
+ function maskConnectionString(connStr) {
1171
+ try {
1172
+ const url = new URL(connStr);
1173
+ if (url.password) url.password = "***";
1174
+ return url.toString();
1175
+ } catch {
1176
+ return "<redacted>";
1177
+ }
1178
+ }
1179
+
1180
+ // src/server/query-analyzer.ts
1181
+ function collectNodes(node, acc = []) {
1182
+ if (!node || typeof node !== "object") return acc;
1183
+ acc.push(node);
1184
+ const plans = node["Plans"] ?? node["plans"];
1185
+ if (Array.isArray(plans)) {
1186
+ for (const child of plans) collectNodes(child, acc);
1187
+ }
1188
+ return acc;
1189
+ }
1190
+ function extractColumnsFromFilter(filter) {
1191
+ const colPattern = /\(?"?([a-z_][a-z0-9_]*)"?\s*(?:=|<|>|<=|>=|<>|!=|IS\s+(?:NOT\s+)?NULL|~~|!~~)/gi;
1192
+ const found = /* @__PURE__ */ new Set();
1193
+ let m;
1194
+ while ((m = colPattern.exec(filter)) !== null) {
1195
+ const col = m[1].toLowerCase();
1196
+ if (!["and", "or", "not", "true", "false", "null"].includes(col)) {
1197
+ found.add(col);
1198
+ }
1199
+ }
1200
+ return Array.from(found);
1201
+ }
1202
+ async function getExistingIndexColumns(pool2, tableName) {
1203
+ try {
1204
+ const r = await pool2.query(
1205
+ `SELECT indexdef FROM pg_indexes WHERE tablename = $1`,
1206
+ [tableName]
1207
+ );
1208
+ return r.rows.map((row) => {
1209
+ const m = /\(([^)]+)\)/.exec(row.indexdef);
1210
+ if (!m) return [];
1211
+ return m[1].split(",").map((c) => c.trim().replace(/^"|"$/g, "").toLowerCase());
1212
+ });
1213
+ } catch {
1214
+ return [];
1215
+ }
1216
+ }
1217
+ function rateBenefit(rowCount) {
1218
+ if (rowCount > 1e5) return "high";
1219
+ if (rowCount >= 1e4) return "medium";
1220
+ return "low";
1221
+ }
1222
+ function fmtRows(n) {
1223
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
1224
+ if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
1225
+ return String(n);
1226
+ }
1227
+ async function analyzeExplainPlan(explainJson, pool2) {
1228
+ const result = {
1229
+ planNodes: [],
1230
+ seqScans: [],
1231
+ missingIndexes: [],
1232
+ costEstimate: { totalCost: 0 },
1233
+ recommendations: []
1234
+ };
1235
+ if (!explainJson || !Array.isArray(explainJson) || explainJson.length === 0) {
1236
+ return result;
1237
+ }
1238
+ const topLevel = explainJson[0];
1239
+ const planRoot = topLevel?.["Plan"] ?? topLevel?.["plan"];
1240
+ const planningTime = topLevel?.["Planning Time"] ?? void 0;
1241
+ const executionTime = topLevel?.["Execution Time"] ?? void 0;
1242
+ if (!planRoot) return result;
1243
+ const allNodes = collectNodes(planRoot);
1244
+ result.planNodes = allNodes.map((n) => {
1245
+ const s = {
1246
+ nodeType: n["Node Type"] ?? "Unknown",
1247
+ totalCost: n["Total Cost"] ?? 0
1248
+ };
1249
+ if (n["Relation Name"]) s.table = n["Relation Name"];
1250
+ if (n["Actual Rows"] !== void 0) s.actualRows = n["Actual Rows"];
1251
+ if (n["Actual Total Time"] !== void 0) s.actualTime = n["Actual Total Time"];
1252
+ if (n["Filter"]) s.filter = n["Filter"];
1253
+ return s;
1254
+ });
1255
+ result.costEstimate = {
1256
+ totalCost: planRoot["Total Cost"] ?? 0,
1257
+ actualTime: executionTime,
1258
+ planningTime
1259
+ };
1260
+ const seqScanNodes = allNodes.filter((n) => n["Node Type"] === "Seq Scan");
1261
+ for (const node of seqScanNodes) {
1262
+ const table = node["Relation Name"] ?? "unknown";
1263
+ const rowCount = node["Plan Rows"] ?? node["Actual Rows"] ?? 0;
1264
+ const filter = node["Filter"];
1265
+ const info = { table, rowCount, filter };
1266
+ if (rowCount > 1e4) {
1267
+ info.suggestion = filter ? `Consider adding an index to support the filter on ${table}` : `Full table scan on large table ${table} \u2014 review query`;
1268
+ }
1269
+ result.seqScans.push(info);
1270
+ }
1271
+ for (const scan of result.seqScans) {
1272
+ if (!scan.filter) continue;
1273
+ const cols = extractColumnsFromFilter(scan.filter);
1274
+ if (cols.length === 0) continue;
1275
+ let existingIndexCols = [];
1276
+ if (pool2) {
1277
+ existingIndexCols = await getExistingIndexColumns(pool2, scan.table);
1278
+ }
1279
+ for (const col of cols) {
1280
+ const alreadyCovered = existingIndexCols.some(
1281
+ (idxCols) => idxCols.length > 0 && idxCols[0] === col
1282
+ );
1283
+ if (alreadyCovered) continue;
1284
+ const benefit = rateBenefit(scan.rowCount);
1285
+ const idxName = `idx_${scan.table}_${col}`;
1286
+ const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${col})`;
1287
+ result.missingIndexes.push({
1288
+ table: scan.table,
1289
+ columns: [col],
1290
+ reason: `Seq Scan with Filter on ${col} (${fmtRows(scan.rowCount)} rows)`,
1291
+ sql,
1292
+ estimatedBenefit: benefit
1293
+ });
1294
+ }
1295
+ }
1296
+ for (const scan of result.seqScans) {
1297
+ if (scan.rowCount > 1e4) {
1298
+ const filterPart = scan.filter ? ` \u2014 consider adding index on ${extractColumnsFromFilter(scan.filter).join(", ") || "filter columns"}` : " \u2014 no filter; full scan may be intentional";
1299
+ result.recommendations.push(
1300
+ `Seq Scan on ${scan.table} (${fmtRows(scan.rowCount)} rows)${filterPart}`
1301
+ );
1302
+ }
1303
+ }
1304
+ if (planningTime !== void 0) {
1305
+ const label = planningTime > 10 ? "high \u2014 check statistics" : "normal";
1306
+ result.recommendations.push(`Planning time ${planningTime.toFixed(1)}ms \u2014 ${label}`);
1307
+ }
1308
+ if (result.missingIndexes.length === 0 && result.seqScans.length === 0) {
1309
+ result.recommendations.push("No obvious sequential scans detected \u2014 query looks efficient");
1310
+ }
1311
+ return result;
1312
+ }
1313
+ async function detectQueryRegressions(pool2, statsDb, windowHours = 24) {
1314
+ try {
1315
+ const extCheck = await pool2.query(
1316
+ "SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'"
1317
+ );
1318
+ if (extCheck.rows.length === 0) return [];
1319
+ const current = await pool2.query(`
1320
+ SELECT queryid::text AS queryid, mean_exec_time
1321
+ FROM pg_stat_statements
1322
+ WHERE query NOT LIKE '%pg_stat%'
1323
+ AND queryid IS NOT NULL
1324
+ `);
1325
+ const currentMap = /* @__PURE__ */ new Map();
1326
+ for (const row of current.rows) {
1327
+ currentMap.set(row.queryid, parseFloat(row.mean_exec_time));
1328
+ }
1329
+ if (!statsDb) return [];
1330
+ const windowMs = windowHours * 60 * 60 * 1e3;
1331
+ const since = Date.now() - windowMs;
1332
+ let historical;
1333
+ try {
1334
+ historical = statsDb.prepare(
1335
+ `SELECT queryid, mean_exec_time, timestamp
1336
+ FROM query_stats
1337
+ WHERE timestamp >= ?
1338
+ ORDER BY queryid, timestamp ASC`
1339
+ ).all(since);
1340
+ } catch {
1341
+ return [];
1342
+ }
1343
+ const baselineMap = /* @__PURE__ */ new Map();
1344
+ for (const row of historical) {
1345
+ if (!baselineMap.has(row.queryid)) {
1346
+ baselineMap.set(row.queryid, {
1347
+ meanMs: row.mean_exec_time,
1348
+ timestamp: row.timestamp
1349
+ });
1350
+ }
1351
+ }
1352
+ const regressions = [];
1353
+ for (const [queryId, baseline] of baselineMap) {
1354
+ const currentMean = currentMap.get(queryId);
1355
+ if (currentMean === void 0 || baseline.meanMs === 0) continue;
1356
+ const changePercent = (currentMean - baseline.meanMs) / baseline.meanMs * 100;
1357
+ if (changePercent > 50) {
1358
+ regressions.push({
1359
+ queryId,
1360
+ currentMeanMs: currentMean,
1361
+ previousMeanMs: baseline.meanMs,
1362
+ changePercent: Math.round(changePercent),
1363
+ degradedAt: new Date(baseline.timestamp).toISOString()
1364
+ });
1365
+ }
1366
+ }
1367
+ return regressions.sort((a, b) => b.changePercent - a.changePercent);
1368
+ } catch {
1369
+ return [];
1370
+ }
1371
+ }
1372
+
1373
+ // src/server/migration-checker.ts
1374
+ function stripComments(sql) {
1375
+ let stripped = sql.replace(
1376
+ /\/\*[\s\S]*?\*\//g,
1377
+ (match) => match.replace(/[^\n]/g, " ")
1378
+ );
1379
+ stripped = stripped.replace(/--[^\n]*/g, (match) => " ".repeat(match.length));
1380
+ return stripped;
1381
+ }
1382
+ function findLineNumber(sql, matchIndex) {
1383
+ const before = sql.slice(0, matchIndex);
1384
+ return before.split("\n").length;
1385
+ }
1386
+ function bareTable(name) {
1387
+ return name.replace(/^public\./i, "").replace(/"/g, "").toLowerCase().trim();
1388
+ }
1389
+ function extractOperatedTables(sql) {
1390
+ sql = stripComments(sql);
1391
+ const indexTables = [];
1392
+ const alterTables = [];
1393
+ const dropTables = [];
1394
+ const refTables = [];
1395
+ const idxRe = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:\w+\s+)?ON\s+([\w."]+)/gi;
1396
+ let m;
1397
+ while ((m = idxRe.exec(sql)) !== null) indexTables.push(bareTable(m[1]));
1398
+ const altRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)/gi;
1399
+ while ((m = altRe.exec(sql)) !== null) alterTables.push(bareTable(m[1]));
1400
+ const dropRe = /\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)/gi;
1401
+ while ((m = dropRe.exec(sql)) !== null) dropTables.push(bareTable(m[1]));
1402
+ const refRe = /\bREFERENCES\s+([\w."]+)/gi;
1403
+ while ((m = refRe.exec(sql)) !== null) refTables.push(bareTable(m[1]));
1404
+ return { indexTables, alterTables, dropTables, refTables };
1405
+ }
1406
+ function staticCheck(sql) {
1407
+ const issues = [];
1408
+ sql = stripComments(sql);
1409
+ const createdTablesRe = /\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."]+)/gi;
1410
+ const createdTables = /* @__PURE__ */ new Set();
1411
+ let m;
1412
+ while ((m = createdTablesRe.exec(sql)) !== null) createdTables.add(bareTable(m[1]));
1413
+ const idxRe = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?!CONCURRENTLY)((?:IF\s+NOT\s+EXISTS\s+)?(?:\w+\s+)?ON\s+([\w."]+))/gi;
1414
+ while ((m = idxRe.exec(sql)) !== null) {
1415
+ const table = bareTable(m[2]);
1416
+ const lineNumber = findLineNumber(sql, m.index);
1417
+ if (!createdTables.has(table)) {
1418
+ issues.push({
1419
+ severity: "warning",
1420
+ code: "INDEX_WITHOUT_CONCURRENTLY",
1421
+ message: `CREATE INDEX on existing table will lock writes. Use CREATE INDEX CONCURRENTLY to avoid downtime.`,
1422
+ suggestion: "Replace CREATE INDEX with CREATE INDEX CONCURRENTLY",
1423
+ lineNumber,
1424
+ tableName: table
1425
+ });
1426
+ }
1427
+ }
1428
+ const idxConcRe = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\b/gi;
1429
+ while ((m = idxConcRe.exec(sql)) !== null) {
1430
+ issues.push({
1431
+ severity: "info",
1432
+ code: "INDEX_CONCURRENTLY_OK",
1433
+ message: "CREATE INDEX CONCURRENTLY \u2014 safe, no write lock",
1434
+ lineNumber: findLineNumber(sql, m.index)
1435
+ });
1436
+ }
1437
+ const addColRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?[\w"]+\s+[\w\s()"',.[\]]+?(?=;|$)/gi;
1438
+ while ((m = addColRe.exec(sql)) !== null) {
1439
+ const fragment = m[0];
1440
+ const table = bareTable(m[1]);
1441
+ const lineNumber = findLineNumber(sql, m.index);
1442
+ const fragUpper = fragment.toUpperCase();
1443
+ const hasNotNull = /\bNOT\s+NULL\b/.test(fragUpper);
1444
+ const hasDefault = /\bDEFAULT\b/.test(fragUpper);
1445
+ if (hasNotNull && !hasDefault) {
1446
+ issues.push({
1447
+ severity: "error",
1448
+ code: "ADD_COLUMN_NOT_NULL_NO_DEFAULT",
1449
+ message: "ADD COLUMN NOT NULL without DEFAULT will fail if table has existing rows",
1450
+ suggestion: "Add a DEFAULT value, then remove it after migration",
1451
+ lineNumber,
1452
+ tableName: table
1453
+ });
1454
+ } else if (hasNotNull && hasDefault) {
1455
+ issues.push({
1456
+ severity: "warning",
1457
+ code: "ADD_COLUMN_REWRITES_TABLE",
1458
+ message: "ADD COLUMN with NOT NULL DEFAULT may rewrite table on PostgreSQL < 11",
1459
+ suggestion: "On PostgreSQL 11+ with a constant default this is safe. For older versions, add column nullable first.",
1460
+ lineNumber,
1461
+ tableName: table
1462
+ });
1463
+ }
1464
+ }
1465
+ const dropRe = /\bDROP\s+TABLE\b/gi;
1466
+ while ((m = dropRe.exec(sql)) !== null) {
1467
+ issues.push({
1468
+ severity: "warning",
1469
+ code: "DROP_TABLE",
1470
+ message: "DROP TABLE is destructive. Ensure this is intentional and data is backed up.",
1471
+ lineNumber: findLineNumber(sql, m.index)
1472
+ });
1473
+ }
1474
+ const truncRe = /\bTRUNCATE\b/gi;
1475
+ while ((m = truncRe.exec(sql)) !== null) {
1476
+ issues.push({
1477
+ severity: "warning",
1478
+ code: "TRUNCATE_TABLE",
1479
+ message: "TRUNCATE will delete all rows. Ensure this is intentional.",
1480
+ lineNumber: findLineNumber(sql, m.index)
1481
+ });
1482
+ }
1483
+ const delRe = /\bDELETE\s+FROM\s+[\w."]+\s*(?:;|$)/gi;
1484
+ while ((m = delRe.exec(sql)) !== null) {
1485
+ const stmt = m[0];
1486
+ if (!/\bWHERE\b/i.test(stmt)) {
1487
+ issues.push({
1488
+ severity: "warning",
1489
+ code: "DELETE_WITHOUT_WHERE",
1490
+ message: "DELETE without WHERE clause will remove all rows.",
1491
+ lineNumber: findLineNumber(sql, m.index)
1492
+ });
1493
+ }
1494
+ }
1495
+ const updRe = /\bUPDATE\s+[\w."]+\s+SET\b[^;]*(;|$)/gi;
1496
+ while ((m = updRe.exec(sql)) !== null) {
1497
+ const stmt = m[0];
1498
+ if (!/\bWHERE\b/i.test(stmt)) {
1499
+ issues.push({
1500
+ severity: "warning",
1501
+ code: "UPDATE_WITHOUT_WHERE",
1502
+ message: "UPDATE without WHERE clause will modify all rows.",
1503
+ lineNumber: findLineNumber(sql, m.index)
1504
+ });
1505
+ }
1506
+ }
1507
+ return issues;
1508
+ }
1509
+ async function dynamicCheck(sql, pool2, staticIssues) {
1510
+ const issues = [];
1511
+ const { indexTables, alterTables, dropTables, refTables } = extractOperatedTables(sql);
1512
+ const allTables = [.../* @__PURE__ */ new Set([...indexTables, ...alterTables, ...dropTables])];
1513
+ const tableStats = /* @__PURE__ */ new Map();
1514
+ if (allTables.length > 0) {
1515
+ try {
1516
+ const res = await pool2.query(
1517
+ `SELECT tablename,
1518
+ n_live_tup,
1519
+ pg_total_relation_size(schemaname||'.'||tablename) AS total_size
1520
+ FROM pg_stat_user_tables
1521
+ WHERE tablename = ANY($1)`,
1522
+ [allTables]
1523
+ );
1524
+ for (const row of res.rows) {
1525
+ tableStats.set(row.tablename, {
1526
+ rowCount: parseInt(row.n_live_tup ?? "0", 10),
1527
+ totalSize: parseInt(row.total_size ?? "0", 10)
1528
+ });
1529
+ }
1530
+ } catch (_) {
1531
+ }
1532
+ }
1533
+ for (const issue of staticIssues) {
1534
+ if (issue.code === "INDEX_WITHOUT_CONCURRENTLY" && issue.tableName) {
1535
+ const stats = tableStats.get(issue.tableName);
1536
+ if (stats) {
1537
+ const { rowCount } = stats;
1538
+ const lockSecs = Math.round(rowCount / 5e4);
1539
+ issue.estimatedRows = rowCount;
1540
+ issue.estimatedLockSeconds = lockSecs;
1541
+ if (rowCount > 1e6) {
1542
+ issue.severity = "error";
1543
+ issue.message = `CREATE INDEX on '${issue.tableName}' will lock writes for ~${lockSecs}s (${(rowCount / 1e6).toFixed(1)}M rows). CRITICAL \u2014 use CREATE INDEX CONCURRENTLY.`;
1544
+ } else if (rowCount > 1e5) {
1545
+ issue.message = `CREATE INDEX on '${issue.tableName}' will lock writes for ~${lockSecs}s (${(rowCount / 1e3).toFixed(0)}k rows).`;
1546
+ }
1547
+ }
1548
+ }
1549
+ }
1550
+ const uniqueRefTables = [...new Set(refTables)];
1551
+ for (const table of uniqueRefTables) {
1552
+ try {
1553
+ const res = await pool2.query(
1554
+ `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = $1`,
1555
+ [table]
1556
+ );
1557
+ if (res.rows.length === 0) {
1558
+ issues.push({
1559
+ severity: "error",
1560
+ code: "MISSING_TABLE",
1561
+ message: `Table '${table}' referenced in migration does not exist`,
1562
+ tableName: table
1563
+ });
1564
+ }
1565
+ } catch (_) {
1566
+ }
1567
+ }
1568
+ return issues;
1569
+ }
1570
+ async function analyzeMigration(sql, pool2) {
1571
+ const trimmed = sql.trim();
1572
+ if (!trimmed) {
1573
+ return {
1574
+ safe: true,
1575
+ issues: [],
1576
+ summary: { errors: 0, warnings: 0, infos: 0 },
1577
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
1578
+ };
1579
+ }
1580
+ const issues = staticCheck(trimmed);
1581
+ if (pool2) {
1582
+ const dynamicIssues = await dynamicCheck(trimmed, pool2, issues);
1583
+ issues.push(...dynamicIssues);
1584
+ }
1585
+ const errors = issues.filter((i) => i.severity === "error").length;
1586
+ const warnings = issues.filter((i) => i.severity === "warning").length;
1587
+ const infos = issues.filter((i) => i.severity === "info").length;
1588
+ return {
1589
+ safe: errors === 0,
1590
+ issues,
1591
+ summary: { errors, warnings, infos },
1592
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
1593
+ };
1594
+ }
1595
+
990
1596
  // src/mcp.ts
991
1597
  import Database2 from "better-sqlite3";
992
1598
  import path3 from "path";
@@ -999,7 +1605,7 @@ if (!connString) {
999
1605
  console.error(" or set PG_DASH_CONNECTION_STRING env var");
1000
1606
  process.exit(1);
1001
1607
  }
1002
- var pool = new Pool({ connectionString: connString });
1608
+ var pool = new Pool2({ connectionString: connString, connectionTimeoutMillis: 1e4 });
1003
1609
  var longQueryThreshold = parseInt(process.env.PG_DASH_LONG_QUERY_THRESHOLD || "5", 10);
1004
1610
  var dataDir = process.env.PG_DASH_DATA_DIR || path3.join(os2.homedir(), ".pg-dash");
1005
1611
  fs3.mkdirSync(dataDir, { recursive: true });
@@ -1017,6 +1623,13 @@ try {
1017
1623
  } catch (err) {
1018
1624
  console.error("[mcp] Error:", err.message);
1019
1625
  }
1626
+ var queryStatsDb = null;
1627
+ try {
1628
+ const queryStatsPath = path3.join(dataDir, "query-stats.db");
1629
+ if (fs3.existsSync(queryStatsPath)) queryStatsDb = new Database2(queryStatsPath, { readonly: true });
1630
+ } catch (err) {
1631
+ console.error("[mcp] Error:", err.message);
1632
+ }
1020
1633
  var server = new McpServer({ name: "pg-dash", version: pkg.version });
1021
1634
  server.tool("pg_dash_overview", "Get database overview (version, uptime, size, connections)", {}, async () => {
1022
1635
  try {
@@ -1226,19 +1839,111 @@ ${fix.sql}
1226
1839
  });
1227
1840
  server.tool("pg_dash_diff", "Compare current health with last saved snapshot", {}, async () => {
1228
1841
  try {
1229
- const prev = loadSnapshot(dataDir);
1842
+ const snapshotPath = path3.join(dataDir, "last-check.json");
1843
+ const prev = loadSnapshot(snapshotPath);
1230
1844
  const current = await getAdvisorReport(pool, longQueryThreshold);
1231
1845
  if (!prev) {
1232
- saveSnapshot(dataDir, current);
1846
+ saveSnapshot(snapshotPath, current);
1233
1847
  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
1848
  }
1235
1849
  const diff = diffSnapshots(prev.result, current);
1236
- saveSnapshot(dataDir, current);
1850
+ saveSnapshot(snapshotPath, current);
1237
1851
  return { content: [{ type: "text", text: JSON.stringify({ ...diff, previousTimestamp: prev.timestamp }, null, 2) }] };
1238
1852
  } catch (err) {
1239
1853
  return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1240
1854
  }
1241
1855
  });
1856
+ server.tool(
1857
+ "pg_dash_check_migration",
1858
+ "Analyze migration SQL for safety risks (lock tables, missing tables, destructive ops)",
1859
+ {
1860
+ sql: z.string().describe("Migration SQL content to analyze")
1861
+ },
1862
+ async ({ sql }) => {
1863
+ try {
1864
+ const result = await analyzeMigration(sql, pool);
1865
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1866
+ } catch (err) {
1867
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1868
+ }
1869
+ }
1870
+ );
1871
+ server.tool(
1872
+ "pg_dash_analyze_query",
1873
+ "Deep analysis of a SQL query: runs EXPLAIN ANALYZE, detects missing indexes, and provides specific optimization recommendations",
1874
+ {
1875
+ sql: z.string().describe("SELECT query to analyze")
1876
+ },
1877
+ async ({ sql }) => {
1878
+ try {
1879
+ if (!/^\s*SELECT\b/i.test(sql)) {
1880
+ return { content: [{ type: "text", text: "Error: Only SELECT queries are allowed" }], isError: true };
1881
+ }
1882
+ const client = await pool.connect();
1883
+ try {
1884
+ await client.query("SET statement_timeout = '30s'");
1885
+ await client.query("BEGIN");
1886
+ try {
1887
+ const r = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${sql}`);
1888
+ await client.query("ROLLBACK");
1889
+ await client.query("RESET statement_timeout");
1890
+ const plan = r.rows[0]["QUERY PLAN"];
1891
+ const analysis = await analyzeExplainPlan(plan, pool);
1892
+ return {
1893
+ content: [{
1894
+ type: "text",
1895
+ text: JSON.stringify({ plan, analysis }, null, 2)
1896
+ }]
1897
+ };
1898
+ } catch (err) {
1899
+ await client.query("ROLLBACK").catch(() => {
1900
+ });
1901
+ await client.query("RESET statement_timeout").catch(() => {
1902
+ });
1903
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1904
+ }
1905
+ } finally {
1906
+ client.release();
1907
+ }
1908
+ } catch (err) {
1909
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1910
+ }
1911
+ }
1912
+ );
1913
+ server.tool(
1914
+ "pg_dash_query_regressions",
1915
+ "Detect queries that have gotten significantly slower (>50% degradation) compared to historical baselines",
1916
+ {
1917
+ windowHours: z.number().optional().describe("Hours to look back (default: 24)")
1918
+ },
1919
+ async ({ windowHours }) => {
1920
+ try {
1921
+ const regressions = await detectQueryRegressions(pool, queryStatsDb, windowHours ?? 24);
1922
+ if (regressions.length === 0) {
1923
+ return { content: [{ type: "text", text: "No query regressions detected in the specified window." }] };
1924
+ }
1925
+ return { content: [{ type: "text", text: JSON.stringify(regressions, null, 2) }] };
1926
+ } catch (err) {
1927
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1928
+ }
1929
+ }
1930
+ );
1931
+ server.tool(
1932
+ "pg_dash_compare_env",
1933
+ "Compare schema and health between two PostgreSQL environments. Detects missing tables, columns, indexes.",
1934
+ {
1935
+ targetUrl: z.string().describe("Target database connection string to compare against"),
1936
+ includeHealth: z.boolean().optional().describe("Also compare health scores and issues")
1937
+ },
1938
+ async ({ targetUrl, includeHealth }) => {
1939
+ try {
1940
+ const result = await diffEnvironments(connString, targetUrl, { includeHealth: includeHealth ?? false });
1941
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1942
+ } catch (err) {
1943
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
1944
+ }
1945
+ }
1946
+ );
1242
1947
  var transport = new StdioServerTransport();
1243
1948
  await server.connect(transport);
1244
1949
  //# sourceMappingURL=mcp.js.map