@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/README.md +87 -6
- package/README.zh-CN.md +85 -4
- package/dist/cli.js +894 -22
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +724 -19
- package/dist/mcp.js.map +1 -1
- package/dist/ui/assets/{index-RQDs_hnz.js → index-BuNkJbnD.js} +6 -6
- package/dist/ui/assets/index-Cpb430iS.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/dist/ui/assets/index-D5LMag3w.css +0 -1
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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(
|
|
962
|
+
fs2.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
|
|
962
963
|
}
|
|
963
|
-
function loadSnapshot(
|
|
964
|
-
|
|
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(
|
|
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
|
|
974
|
-
const
|
|
975
|
-
const newIssues = current.issues.filter((i) => !
|
|
976
|
-
const resolvedIssues = prev.issues.filter((i) => !
|
|
977
|
-
const unchanged = current.issues.filter((i) =>
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|