@indiekitai/pg-dash 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/README.zh-CN.md +11 -6
- package/dist/cli.js +124 -20
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +564 -19
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -1187,7 +1187,7 @@ async function fetchColumns(pool2) {
|
|
|
1187
1187
|
}
|
|
1188
1188
|
async function fetchIndexes(pool2) {
|
|
1189
1189
|
const res = await pool2.query(`
|
|
1190
|
-
SELECT tablename, indexname
|
|
1190
|
+
SELECT tablename, indexname, indexdef
|
|
1191
1191
|
FROM pg_indexes
|
|
1192
1192
|
WHERE schemaname = 'public' AND indexname NOT LIKE '%_pkey'
|
|
1193
1193
|
ORDER BY tablename, indexname
|
|
@@ -1228,6 +1228,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1228
1228
|
const missingColumns = [];
|
|
1229
1229
|
const extraColumns = [];
|
|
1230
1230
|
const typeDiffs = [];
|
|
1231
|
+
const nullableDiffs = [];
|
|
1232
|
+
const defaultDiffs = [];
|
|
1231
1233
|
for (const [colName, srcInfo] of srcMap) {
|
|
1232
1234
|
if (!tgtMap.has(colName)) {
|
|
1233
1235
|
missingColumns.push(srcInfo);
|
|
@@ -1236,6 +1238,12 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1236
1238
|
if (srcInfo.type !== tgtInfo.type) {
|
|
1237
1239
|
typeDiffs.push({ column: colName, sourceType: srcInfo.type, targetType: tgtInfo.type });
|
|
1238
1240
|
}
|
|
1241
|
+
if (srcInfo.nullable !== tgtInfo.nullable) {
|
|
1242
|
+
nullableDiffs.push({ column: colName, sourceNullable: srcInfo.nullable, targetNullable: tgtInfo.nullable });
|
|
1243
|
+
}
|
|
1244
|
+
if ((srcInfo.default ?? null) !== (tgtInfo.default ?? null)) {
|
|
1245
|
+
defaultDiffs.push({ column: colName, sourceDefault: srcInfo.default ?? null, targetDefault: tgtInfo.default ?? null });
|
|
1246
|
+
}
|
|
1239
1247
|
}
|
|
1240
1248
|
}
|
|
1241
1249
|
for (const [colName, tgtInfo] of tgtMap) {
|
|
@@ -1243,8 +1251,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1243
1251
|
extraColumns.push(tgtInfo);
|
|
1244
1252
|
}
|
|
1245
1253
|
}
|
|
1246
|
-
if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0) {
|
|
1247
|
-
diffs.push({ table, missingColumns, extraColumns, typeDiffs });
|
|
1254
|
+
if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0 || nullableDiffs.length > 0 || defaultDiffs.length > 0) {
|
|
1255
|
+
diffs.push({ table, missingColumns, extraColumns, typeDiffs, nullableDiffs, defaultDiffs });
|
|
1248
1256
|
}
|
|
1249
1257
|
}
|
|
1250
1258
|
return diffs;
|
|
@@ -1252,8 +1260,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1252
1260
|
function groupIndexesByTable(indexes) {
|
|
1253
1261
|
const map = /* @__PURE__ */ new Map();
|
|
1254
1262
|
for (const idx of indexes) {
|
|
1255
|
-
if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new
|
|
1256
|
-
map.get(idx.tablename).
|
|
1263
|
+
if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new Map());
|
|
1264
|
+
map.get(idx.tablename).set(idx.indexname, idx.indexdef);
|
|
1257
1265
|
}
|
|
1258
1266
|
return map;
|
|
1259
1267
|
}
|
|
@@ -1267,12 +1275,21 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
|
|
|
1267
1275
|
]);
|
|
1268
1276
|
for (const table of allTables) {
|
|
1269
1277
|
if (!commonTables.includes(table)) continue;
|
|
1270
|
-
const
|
|
1271
|
-
const
|
|
1272
|
-
const missingIndexes = [...
|
|
1273
|
-
const extraIndexes = [...
|
|
1274
|
-
|
|
1275
|
-
|
|
1278
|
+
const srcMap = srcByTable.get(table) ?? /* @__PURE__ */ new Map();
|
|
1279
|
+
const tgtMap = tgtByTable.get(table) ?? /* @__PURE__ */ new Map();
|
|
1280
|
+
const missingIndexes = [...srcMap.keys()].filter((i) => !tgtMap.has(i));
|
|
1281
|
+
const extraIndexes = [...tgtMap.keys()].filter((i) => !srcMap.has(i));
|
|
1282
|
+
const modifiedIndexes = [];
|
|
1283
|
+
for (const [name, srcDef] of srcMap) {
|
|
1284
|
+
if (tgtMap.has(name)) {
|
|
1285
|
+
const tgtDef = tgtMap.get(name);
|
|
1286
|
+
if (srcDef !== tgtDef) {
|
|
1287
|
+
modifiedIndexes.push({ name, sourceDef: srcDef, targetDef: tgtDef });
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (missingIndexes.length > 0 || extraIndexes.length > 0 || modifiedIndexes.length > 0) {
|
|
1292
|
+
diffs.push({ table, missingIndexes, extraIndexes, modifiedIndexes });
|
|
1276
1293
|
}
|
|
1277
1294
|
}
|
|
1278
1295
|
return diffs;
|
|
@@ -1280,10 +1297,10 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
|
|
|
1280
1297
|
function countSchemaDrifts(schema) {
|
|
1281
1298
|
let n = schema.missingTables.length + schema.extraTables.length;
|
|
1282
1299
|
for (const cd of schema.columnDiffs) {
|
|
1283
|
-
n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length;
|
|
1300
|
+
n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length + cd.nullableDiffs.length + cd.defaultDiffs.length;
|
|
1284
1301
|
}
|
|
1285
1302
|
for (const id of schema.indexDiffs) {
|
|
1286
|
-
n += id.missingIndexes.length + id.extraIndexes.length;
|
|
1303
|
+
n += id.missingIndexes.length + id.extraIndexes.length + id.modifiedIndexes.length;
|
|
1287
1304
|
}
|
|
1288
1305
|
n += (schema.constraintDiffs ?? []).length;
|
|
1289
1306
|
n += (schema.enumDiffs ?? []).length;
|
|
@@ -1480,12 +1497,23 @@ async function analyzeExplainPlan(explainJson, pool2) {
|
|
|
1480
1497
|
if (pool2) {
|
|
1481
1498
|
existingIndexCols = await getExistingIndexColumns(pool2, scan.table);
|
|
1482
1499
|
}
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1500
|
+
const uncoveredCols = cols.filter(
|
|
1501
|
+
(col) => !existingIndexCols.some((idxCols) => idxCols.length > 0 && idxCols[0] === col)
|
|
1502
|
+
);
|
|
1503
|
+
if (uncoveredCols.length === 0) continue;
|
|
1504
|
+
const benefit = rateBenefit(scan.rowCount);
|
|
1505
|
+
if (uncoveredCols.length >= 2) {
|
|
1506
|
+
const idxName = `idx_${scan.table}_${uncoveredCols.join("_")}`;
|
|
1507
|
+
const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${uncoveredCols.join(", ")})`;
|
|
1508
|
+
result.missingIndexes.push({
|
|
1509
|
+
table: scan.table,
|
|
1510
|
+
columns: uncoveredCols,
|
|
1511
|
+
reason: `Seq Scan with multi-column filter (${uncoveredCols.join(", ")}) on ${fmtRows(scan.rowCount)} rows \u2014 composite index preferred`,
|
|
1512
|
+
sql,
|
|
1513
|
+
estimatedBenefit: benefit
|
|
1514
|
+
});
|
|
1515
|
+
} else {
|
|
1516
|
+
const col = uncoveredCols[0];
|
|
1489
1517
|
const idxName = `idx_${scan.table}_${col}`;
|
|
1490
1518
|
const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${col})`;
|
|
1491
1519
|
result.missingIndexes.push({
|
|
@@ -1699,6 +1727,33 @@ function staticCheck(sql) {
|
|
|
1699
1727
|
tableName: table
|
|
1700
1728
|
});
|
|
1701
1729
|
}
|
|
1730
|
+
const renameTableRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+TO\s+(\w+)/gi;
|
|
1731
|
+
while ((m = renameTableRe.exec(sql)) !== null) {
|
|
1732
|
+
const oldName = m[1];
|
|
1733
|
+
const newName = m[2];
|
|
1734
|
+
issues.push({
|
|
1735
|
+
severity: "warning",
|
|
1736
|
+
code: "RENAME_TABLE",
|
|
1737
|
+
message: `Renaming table "${oldName}" to "${newName}" breaks application code referencing the old name`,
|
|
1738
|
+
suggestion: "Deploy application code that handles both names before renaming, or use a view with the old name after renaming.",
|
|
1739
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1740
|
+
tableName: oldName
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
const renameColumnRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+COLUMN\s+(\w+)\s+TO\s+(\w+)/gi;
|
|
1744
|
+
while ((m = renameColumnRe.exec(sql)) !== null) {
|
|
1745
|
+
const table = m[1];
|
|
1746
|
+
const oldCol = m[2];
|
|
1747
|
+
const newCol = m[3];
|
|
1748
|
+
issues.push({
|
|
1749
|
+
severity: "warning",
|
|
1750
|
+
code: "RENAME_COLUMN",
|
|
1751
|
+
message: `Renaming column "${oldCol}" to "${newCol}" on table "${table}" breaks application code referencing the old column name`,
|
|
1752
|
+
suggestion: "Add new column, backfill data, update application to use new column, then drop old column (expand/contract pattern).",
|
|
1753
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1754
|
+
tableName: table
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1702
1757
|
const addConRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ADD\s+CONSTRAINT\b[^;]*(;|$)/gi;
|
|
1703
1758
|
while ((m = addConRe.exec(sql)) !== null) {
|
|
1704
1759
|
const fragment = m[0];
|
|
@@ -1847,6 +1902,456 @@ async function analyzeMigration(sql, pool2) {
|
|
|
1847
1902
|
};
|
|
1848
1903
|
}
|
|
1849
1904
|
|
|
1905
|
+
// src/server/unused-indexes.ts
|
|
1906
|
+
function formatBytes(bytes) {
|
|
1907
|
+
if (bytes < 1024) return "< 1 KB";
|
|
1908
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
1909
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1910
|
+
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
1911
|
+
return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
|
|
1912
|
+
}
|
|
1913
|
+
async function getUnusedIndexes(pool2) {
|
|
1914
|
+
const [indexResult, bgwriterResult] = await Promise.all([
|
|
1915
|
+
pool2.query(`
|
|
1916
|
+
SELECT
|
|
1917
|
+
s.schemaname,
|
|
1918
|
+
s.relname AS table_name,
|
|
1919
|
+
s.indexrelname AS index_name,
|
|
1920
|
+
pg_relation_size(s.indexrelid) AS index_size_bytes,
|
|
1921
|
+
s.idx_scan,
|
|
1922
|
+
i.indexdef
|
|
1923
|
+
FROM pg_stat_user_indexes s
|
|
1924
|
+
JOIN pg_indexes i ON s.schemaname = i.schemaname
|
|
1925
|
+
AND s.relname = i.tablename
|
|
1926
|
+
AND s.indexrelname = i.indexname
|
|
1927
|
+
WHERE s.schemaname = 'public'
|
|
1928
|
+
AND s.idx_scan = 0
|
|
1929
|
+
AND i.indexdef NOT LIKE '%UNIQUE%'
|
|
1930
|
+
AND s.indexrelname NOT LIKE '%_pkey'
|
|
1931
|
+
ORDER BY pg_relation_size(s.indexrelid) DESC
|
|
1932
|
+
`),
|
|
1933
|
+
pool2.query(`SELECT stats_reset FROM pg_stat_bgwriter`)
|
|
1934
|
+
]);
|
|
1935
|
+
const statsReset = bgwriterResult.rows[0]?.stats_reset ? new Date(bgwriterResult.rows[0].stats_reset).toISOString() : null;
|
|
1936
|
+
const indexes = indexResult.rows.map((row) => {
|
|
1937
|
+
const sizeBytes = parseInt(row.index_size_bytes, 10) || 0;
|
|
1938
|
+
const index = row.index_name;
|
|
1939
|
+
const table = row.table_name;
|
|
1940
|
+
return {
|
|
1941
|
+
schema: row.schemaname,
|
|
1942
|
+
table,
|
|
1943
|
+
index,
|
|
1944
|
+
indexSize: formatBytes(sizeBytes),
|
|
1945
|
+
indexSizeBytes: sizeBytes,
|
|
1946
|
+
scans: parseInt(row.idx_scan, 10) || 0,
|
|
1947
|
+
lastUsed: statsReset,
|
|
1948
|
+
suggestion: `Index ${index} on ${table} has never been used (0 scans). Consider dropping it: DROP INDEX CONCURRENTLY "${index.replace(/"/g, '""')}"`
|
|
1949
|
+
};
|
|
1950
|
+
});
|
|
1951
|
+
const totalWastedBytes = indexes.reduce((sum, idx) => sum + idx.indexSizeBytes, 0);
|
|
1952
|
+
return {
|
|
1953
|
+
indexes,
|
|
1954
|
+
totalWastedBytes,
|
|
1955
|
+
totalWasted: formatBytes(totalWastedBytes),
|
|
1956
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// src/server/bloat.ts
|
|
1961
|
+
function getSuggestion(table, bloatPercent) {
|
|
1962
|
+
if (bloatPercent >= 50) {
|
|
1963
|
+
return `HIGH bloat on ${table} (${bloatPercent}% dead rows). Run: VACUUM ANALYZE ${table}`;
|
|
1964
|
+
} else if (bloatPercent >= 20) {
|
|
1965
|
+
return `Moderate bloat on ${table} (${bloatPercent}% dead rows). Consider VACUUM ANALYZE ${table}`;
|
|
1966
|
+
} else {
|
|
1967
|
+
return `Minor bloat on ${table} (${bloatPercent}% dead rows). Autovacuum should handle this.`;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
async function getBloatReport(pool2) {
|
|
1971
|
+
const result = await pool2.query(`
|
|
1972
|
+
SELECT
|
|
1973
|
+
schemaname,
|
|
1974
|
+
relname AS table_name,
|
|
1975
|
+
n_live_tup,
|
|
1976
|
+
n_dead_tup,
|
|
1977
|
+
last_autovacuum,
|
|
1978
|
+
last_vacuum
|
|
1979
|
+
FROM pg_stat_user_tables
|
|
1980
|
+
WHERE schemaname = 'public'
|
|
1981
|
+
AND (n_live_tup + n_dead_tup) > 0
|
|
1982
|
+
ORDER BY (n_dead_tup::float / (n_live_tup + n_dead_tup)) DESC
|
|
1983
|
+
`);
|
|
1984
|
+
const tables = [];
|
|
1985
|
+
for (const row of result.rows) {
|
|
1986
|
+
const live = parseInt(row.n_live_tup, 10) || 0;
|
|
1987
|
+
const dead = parseInt(row.n_dead_tup, 10) || 0;
|
|
1988
|
+
const total = live + dead;
|
|
1989
|
+
if (total === 0) continue;
|
|
1990
|
+
const bloatPercent = Math.round(dead / total * 1e3) / 10;
|
|
1991
|
+
if (bloatPercent < 10) continue;
|
|
1992
|
+
const table = row.table_name;
|
|
1993
|
+
tables.push({
|
|
1994
|
+
schema: row.schemaname,
|
|
1995
|
+
table,
|
|
1996
|
+
liveRows: live,
|
|
1997
|
+
deadRows: dead,
|
|
1998
|
+
bloatPercent,
|
|
1999
|
+
lastAutoVacuum: row.last_autovacuum ? new Date(row.last_autovacuum).toISOString() : null,
|
|
2000
|
+
lastVacuum: row.last_vacuum ? new Date(row.last_vacuum).toISOString() : null,
|
|
2001
|
+
suggestion: getSuggestion(table, bloatPercent)
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
tables.sort((a, b) => b.bloatPercent - a.bloatPercent);
|
|
2005
|
+
return {
|
|
2006
|
+
tables,
|
|
2007
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// src/server/autovacuum.ts
|
|
2012
|
+
function classifyStatus(lastAutoVacuum, deadTuples, vacuumCount) {
|
|
2013
|
+
if (lastAutoVacuum === null) return "never";
|
|
2014
|
+
const daysSince = (Date.now() - lastAutoVacuum.getTime()) / (1e3 * 60 * 60 * 24);
|
|
2015
|
+
if (daysSince > 7 && deadTuples > 1e4) return "overdue";
|
|
2016
|
+
if (daysSince > 3) return "stale";
|
|
2017
|
+
return "ok";
|
|
2018
|
+
}
|
|
2019
|
+
function getSuggestion2(status, table) {
|
|
2020
|
+
switch (status) {
|
|
2021
|
+
case "never":
|
|
2022
|
+
return `Table ${table} has never been autovacuumed. Check if autovacuum is enabled and the table has enough churn.`;
|
|
2023
|
+
case "overdue":
|
|
2024
|
+
return `Table ${table} is overdue for vacuum and has many dead tuples. Run: VACUUM ANALYZE ${table}`;
|
|
2025
|
+
case "stale":
|
|
2026
|
+
return `Table ${table} hasn't been vacuumed in over 3 days. Monitor for bloat.`;
|
|
2027
|
+
case "ok":
|
|
2028
|
+
return null;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
async function getAutovacuumReport(pool2) {
|
|
2032
|
+
const [tableResult, settingsResult] = await Promise.all([
|
|
2033
|
+
pool2.query(`
|
|
2034
|
+
SELECT
|
|
2035
|
+
schemaname, relname,
|
|
2036
|
+
last_autovacuum, last_autoanalyze,
|
|
2037
|
+
n_dead_tup, n_live_tup,
|
|
2038
|
+
autovacuum_count, autoanalyze_count
|
|
2039
|
+
FROM pg_stat_user_tables
|
|
2040
|
+
WHERE schemaname = 'public'
|
|
2041
|
+
ORDER BY n_dead_tup DESC
|
|
2042
|
+
`),
|
|
2043
|
+
pool2.query(`
|
|
2044
|
+
SELECT name, setting
|
|
2045
|
+
FROM pg_settings
|
|
2046
|
+
WHERE name IN ('autovacuum', 'autovacuum_vacuum_cost_delay', 'autovacuum_max_workers', 'autovacuum_naptime')
|
|
2047
|
+
`)
|
|
2048
|
+
]);
|
|
2049
|
+
const tables = tableResult.rows.map((row) => {
|
|
2050
|
+
const lastAutoVacuumDate = row.last_autovacuum ? new Date(row.last_autovacuum) : null;
|
|
2051
|
+
const deadTuples = parseInt(row.n_dead_tup, 10) || 0;
|
|
2052
|
+
const liveTuples = parseInt(row.n_live_tup, 10) || 0;
|
|
2053
|
+
const vacuumCount = parseInt(row.autovacuum_count, 10) || 0;
|
|
2054
|
+
const analyzeCount = parseInt(row.autoanalyze_count, 10) || 0;
|
|
2055
|
+
const status = classifyStatus(lastAutoVacuumDate, deadTuples, vacuumCount);
|
|
2056
|
+
const table = row.relname;
|
|
2057
|
+
return {
|
|
2058
|
+
schema: row.schemaname,
|
|
2059
|
+
table,
|
|
2060
|
+
lastAutoVacuum: lastAutoVacuumDate ? lastAutoVacuumDate.toISOString() : null,
|
|
2061
|
+
lastAutoAnalyze: row.last_autoanalyze ? new Date(row.last_autoanalyze).toISOString() : null,
|
|
2062
|
+
deadTuples,
|
|
2063
|
+
liveTuples,
|
|
2064
|
+
vacuumCount,
|
|
2065
|
+
analyzeCount,
|
|
2066
|
+
status,
|
|
2067
|
+
suggestion: getSuggestion2(status, table)
|
|
2068
|
+
};
|
|
2069
|
+
});
|
|
2070
|
+
const settingsMap = /* @__PURE__ */ new Map();
|
|
2071
|
+
for (const row of settingsResult.rows) {
|
|
2072
|
+
settingsMap.set(row.name, row.setting);
|
|
2073
|
+
}
|
|
2074
|
+
return {
|
|
2075
|
+
tables,
|
|
2076
|
+
settings: {
|
|
2077
|
+
autovacuumEnabled: settingsMap.get("autovacuum") !== "off",
|
|
2078
|
+
vacuumCostDelay: `${settingsMap.get("autovacuum_vacuum_cost_delay") ?? "2"}ms`,
|
|
2079
|
+
autovacuumMaxWorkers: parseInt(settingsMap.get("autovacuum_max_workers") ?? "3", 10),
|
|
2080
|
+
autovacuumNaptime: `${settingsMap.get("autovacuum_naptime") ?? "60"}s`
|
|
2081
|
+
},
|
|
2082
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// src/server/locks.ts
|
|
2087
|
+
function formatDurationSecs(secs) {
|
|
2088
|
+
const h = Math.floor(secs / 3600);
|
|
2089
|
+
const m = Math.floor(secs % 3600 / 60);
|
|
2090
|
+
const s = secs % 60;
|
|
2091
|
+
return [
|
|
2092
|
+
String(h).padStart(2, "0"),
|
|
2093
|
+
String(m).padStart(2, "0"),
|
|
2094
|
+
String(s).padStart(2, "0")
|
|
2095
|
+
].join(":");
|
|
2096
|
+
}
|
|
2097
|
+
async function getLockReport(pool2) {
|
|
2098
|
+
const [locksResult, longResult] = await Promise.all([
|
|
2099
|
+
pool2.query(`
|
|
2100
|
+
SELECT
|
|
2101
|
+
blocked.pid AS blocked_pid,
|
|
2102
|
+
blocked.query AS blocked_query,
|
|
2103
|
+
EXTRACT(EPOCH FROM (NOW() - blocked.query_start))::int AS blocked_secs,
|
|
2104
|
+
blocking.pid AS blocking_pid,
|
|
2105
|
+
blocking.query AS blocking_query,
|
|
2106
|
+
EXTRACT(EPOCH FROM (NOW() - blocking.query_start))::int AS blocking_secs,
|
|
2107
|
+
blocked_locks.relation::regclass::text AS table_name,
|
|
2108
|
+
blocked_locks.locktype
|
|
2109
|
+
FROM pg_catalog.pg_locks blocked_locks
|
|
2110
|
+
JOIN pg_catalog.pg_stat_activity blocked ON blocked.pid = blocked_locks.pid
|
|
2111
|
+
JOIN pg_catalog.pg_locks blocking_locks
|
|
2112
|
+
ON blocking_locks.locktype = blocked_locks.locktype
|
|
2113
|
+
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
|
|
2114
|
+
AND blocking_locks.pid != blocked_locks.pid
|
|
2115
|
+
AND blocking_locks.granted = true
|
|
2116
|
+
JOIN pg_catalog.pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
|
|
2117
|
+
WHERE NOT blocked_locks.granted
|
|
2118
|
+
`),
|
|
2119
|
+
pool2.query(`
|
|
2120
|
+
SELECT
|
|
2121
|
+
pid,
|
|
2122
|
+
EXTRACT(EPOCH FROM (NOW() - query_start))::int AS duration_secs,
|
|
2123
|
+
query,
|
|
2124
|
+
state,
|
|
2125
|
+
wait_event_type
|
|
2126
|
+
FROM pg_stat_activity
|
|
2127
|
+
WHERE state != 'idle'
|
|
2128
|
+
AND query_start IS NOT NULL
|
|
2129
|
+
AND EXTRACT(EPOCH FROM (NOW() - query_start)) > 5
|
|
2130
|
+
AND query NOT LIKE '%pg_stat_activity%'
|
|
2131
|
+
ORDER BY duration_secs DESC
|
|
2132
|
+
LIMIT 20
|
|
2133
|
+
`)
|
|
2134
|
+
]);
|
|
2135
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2136
|
+
const waitingLocks = [];
|
|
2137
|
+
for (const row of locksResult.rows) {
|
|
2138
|
+
const key = `${row.blocked_pid}:${row.blocking_pid}`;
|
|
2139
|
+
if (!seen.has(key)) {
|
|
2140
|
+
seen.add(key);
|
|
2141
|
+
waitingLocks.push({
|
|
2142
|
+
blockedPid: parseInt(row.blocked_pid, 10),
|
|
2143
|
+
blockedQuery: row.blocked_query,
|
|
2144
|
+
blockedDuration: formatDurationSecs(parseInt(row.blocked_secs, 10) || 0),
|
|
2145
|
+
blockingPid: parseInt(row.blocking_pid, 10),
|
|
2146
|
+
blockingQuery: row.blocking_query,
|
|
2147
|
+
blockingDuration: formatDurationSecs(parseInt(row.blocking_secs, 10) || 0),
|
|
2148
|
+
table: row.table_name ?? null,
|
|
2149
|
+
lockType: row.locktype
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
const longRunningQueries = longResult.rows.map((row) => ({
|
|
2154
|
+
pid: parseInt(row.pid, 10),
|
|
2155
|
+
duration: formatDurationSecs(parseInt(row.duration_secs, 10) || 0),
|
|
2156
|
+
query: row.query,
|
|
2157
|
+
state: row.state,
|
|
2158
|
+
waitEventType: row.wait_event_type ?? null
|
|
2159
|
+
}));
|
|
2160
|
+
return {
|
|
2161
|
+
waitingLocks,
|
|
2162
|
+
longRunningQueries,
|
|
2163
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// src/server/config-checker.ts
|
|
2168
|
+
function settingToBytes(value, unit) {
|
|
2169
|
+
const v = parseFloat(value);
|
|
2170
|
+
if (!unit) return v;
|
|
2171
|
+
switch (unit.toLowerCase()) {
|
|
2172
|
+
case "b":
|
|
2173
|
+
return v;
|
|
2174
|
+
case "kb":
|
|
2175
|
+
return v * 1024;
|
|
2176
|
+
case "8kb":
|
|
2177
|
+
return v * 8 * 1024;
|
|
2178
|
+
// shared_buffers, effective_cache_size
|
|
2179
|
+
case "mb":
|
|
2180
|
+
return v * 1024 * 1024;
|
|
2181
|
+
case "gb":
|
|
2182
|
+
return v * 1024 * 1024 * 1024;
|
|
2183
|
+
default:
|
|
2184
|
+
return v;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
function settingToMb(value, unit) {
|
|
2188
|
+
return settingToBytes(value, unit) / (1024 * 1024);
|
|
2189
|
+
}
|
|
2190
|
+
function formatMemSetting(rawValue, unit) {
|
|
2191
|
+
if (!rawValue) return "unknown";
|
|
2192
|
+
const bytes = settingToBytes(rawValue, unit ?? "");
|
|
2193
|
+
if (bytes <= 0 || isNaN(bytes)) return rawValue;
|
|
2194
|
+
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)}GB`;
|
|
2195
|
+
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)}MB`;
|
|
2196
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`;
|
|
2197
|
+
return `${bytes}B`;
|
|
2198
|
+
}
|
|
2199
|
+
async function getConfigReport(pool2) {
|
|
2200
|
+
const result = await pool2.query(`
|
|
2201
|
+
SELECT name, setting, unit
|
|
2202
|
+
FROM pg_settings
|
|
2203
|
+
WHERE name IN (
|
|
2204
|
+
'max_connections', 'shared_buffers', 'work_mem',
|
|
2205
|
+
'effective_cache_size', 'maintenance_work_mem', 'wal_buffers',
|
|
2206
|
+
'checkpoint_completion_target', 'random_page_cost',
|
|
2207
|
+
'autovacuum_vacuum_scale_factor', 'autovacuum_analyze_scale_factor',
|
|
2208
|
+
'log_min_duration_statement', 'idle_in_transaction_session_timeout',
|
|
2209
|
+
'effective_io_concurrency'
|
|
2210
|
+
)
|
|
2211
|
+
`);
|
|
2212
|
+
const settings = {};
|
|
2213
|
+
for (const row of result.rows) {
|
|
2214
|
+
settings[row.name] = { setting: row.setting, unit: row.unit ?? void 0 };
|
|
2215
|
+
}
|
|
2216
|
+
const recommendations = [];
|
|
2217
|
+
const get = (name) => settings[name]?.setting ?? null;
|
|
2218
|
+
const getUnit = (name) => settings[name]?.unit;
|
|
2219
|
+
const sharedBuffersSetting = get("shared_buffers");
|
|
2220
|
+
if (sharedBuffersSetting !== null) {
|
|
2221
|
+
const mb = settingToMb(sharedBuffersSetting, getUnit("shared_buffers"));
|
|
2222
|
+
if (mb < 128) {
|
|
2223
|
+
recommendations.push({
|
|
2224
|
+
setting: "shared_buffers",
|
|
2225
|
+
currentValue: `${Math.round(mb)}MB`,
|
|
2226
|
+
recommendedValue: "256MB",
|
|
2227
|
+
reason: "shared_buffers should be at least 25% of RAM; typical starting point is 256MB\u20131GB",
|
|
2228
|
+
severity: "warning",
|
|
2229
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS"
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
const workMemSetting = get("work_mem");
|
|
2234
|
+
if (workMemSetting !== null) {
|
|
2235
|
+
const mb = settingToMb(workMemSetting, getUnit("work_mem"));
|
|
2236
|
+
if (mb <= 4) {
|
|
2237
|
+
recommendations.push({
|
|
2238
|
+
setting: "work_mem",
|
|
2239
|
+
currentValue: "4MB",
|
|
2240
|
+
recommendedValue: "16MB",
|
|
2241
|
+
reason: "work_mem of 4MB is conservative; consider 16MB\u201364MB for analytical queries (but multiply by max_connections for total)",
|
|
2242
|
+
severity: "info",
|
|
2243
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM"
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
const cctSetting = get("checkpoint_completion_target");
|
|
2248
|
+
if (cctSetting !== null) {
|
|
2249
|
+
const v = parseFloat(cctSetting);
|
|
2250
|
+
if (v < 0.9) {
|
|
2251
|
+
recommendations.push({
|
|
2252
|
+
setting: "checkpoint_completion_target",
|
|
2253
|
+
currentValue: cctSetting,
|
|
2254
|
+
recommendedValue: "0.9",
|
|
2255
|
+
reason: "Set to 0.9 to spread checkpoint I/O over 90% of checkpoint interval",
|
|
2256
|
+
severity: "warning",
|
|
2257
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-wal.html#GUC-CHECKPOINT-COMPLETION-TARGET"
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
const rpcSetting = get("random_page_cost");
|
|
2262
|
+
if (rpcSetting !== null) {
|
|
2263
|
+
const v = parseFloat(rpcSetting);
|
|
2264
|
+
if (v > 2) {
|
|
2265
|
+
recommendations.push({
|
|
2266
|
+
setting: "random_page_cost",
|
|
2267
|
+
currentValue: rpcSetting,
|
|
2268
|
+
recommendedValue: "1.1",
|
|
2269
|
+
reason: "If using SSDs, set random_page_cost=1.1 (default 4.0 is tuned for spinning disks)",
|
|
2270
|
+
severity: "info",
|
|
2271
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-RANDOM-PAGE-COST"
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
const avsfSetting = get("autovacuum_vacuum_scale_factor");
|
|
2276
|
+
if (avsfSetting !== null) {
|
|
2277
|
+
const v = parseFloat(avsfSetting);
|
|
2278
|
+
if (v >= 0.2) {
|
|
2279
|
+
recommendations.push({
|
|
2280
|
+
setting: "autovacuum_vacuum_scale_factor",
|
|
2281
|
+
currentValue: avsfSetting,
|
|
2282
|
+
recommendedValue: "0.05",
|
|
2283
|
+
reason: "Consider lowering to 0.05\u20130.1 for large tables to vacuum more frequently",
|
|
2284
|
+
severity: "info",
|
|
2285
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-autovacuum.html#GUC-AUTOVACUUM-VACUUM-SCALE-FACTOR"
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
const lmdsSetting = get("log_min_duration_statement");
|
|
2290
|
+
if (lmdsSetting !== null && parseInt(lmdsSetting, 10) === -1) {
|
|
2291
|
+
recommendations.push({
|
|
2292
|
+
setting: "log_min_duration_statement",
|
|
2293
|
+
currentValue: "-1",
|
|
2294
|
+
recommendedValue: "1000",
|
|
2295
|
+
reason: "Consider setting to 1000 (log queries > 1s) for performance monitoring",
|
|
2296
|
+
severity: "info",
|
|
2297
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-DURATION-STATEMENT"
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
const iitsSetting = get("idle_in_transaction_session_timeout");
|
|
2301
|
+
if (iitsSetting !== null && parseInt(iitsSetting, 10) === 0) {
|
|
2302
|
+
recommendations.push({
|
|
2303
|
+
setting: "idle_in_transaction_session_timeout",
|
|
2304
|
+
currentValue: "0",
|
|
2305
|
+
recommendedValue: "60000",
|
|
2306
|
+
reason: "Set idle_in_transaction_session_timeout=60000 (60s) to prevent stuck transactions from holding locks",
|
|
2307
|
+
severity: "warning",
|
|
2308
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT"
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
const eicSetting = get("effective_io_concurrency");
|
|
2312
|
+
if (eicSetting !== null && parseInt(eicSetting, 10) === 1) {
|
|
2313
|
+
recommendations.push({
|
|
2314
|
+
setting: "effective_io_concurrency",
|
|
2315
|
+
currentValue: "1",
|
|
2316
|
+
recommendedValue: "200",
|
|
2317
|
+
reason: "If using SSDs, set effective_io_concurrency=200 for better parallel I/O",
|
|
2318
|
+
severity: "info",
|
|
2319
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-EFFECTIVE-IO-CONCURRENCY"
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
const mwmSetting = get("maintenance_work_mem");
|
|
2323
|
+
if (mwmSetting !== null) {
|
|
2324
|
+
const mb = settingToMb(mwmSetting, getUnit("maintenance_work_mem"));
|
|
2325
|
+
if (mb <= 64) {
|
|
2326
|
+
recommendations.push({
|
|
2327
|
+
setting: "maintenance_work_mem",
|
|
2328
|
+
currentValue: "64MB",
|
|
2329
|
+
recommendedValue: "256MB",
|
|
2330
|
+
reason: "Consider 256MB for faster VACUUM and index builds",
|
|
2331
|
+
severity: "info",
|
|
2332
|
+
docs: "https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM"
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
const maxConnSetting = get("max_connections");
|
|
2337
|
+
const serverInfo = {
|
|
2338
|
+
maxConnections: maxConnSetting !== null ? parseInt(maxConnSetting, 10) : 0,
|
|
2339
|
+
sharedBuffers: formatMemSetting(sharedBuffersSetting, getUnit("shared_buffers")),
|
|
2340
|
+
workMem: formatMemSetting(workMemSetting, getUnit("work_mem")),
|
|
2341
|
+
effectiveCacheSize: formatMemSetting(get("effective_cache_size"), getUnit("effective_cache_size")),
|
|
2342
|
+
maintenanceWorkMem: formatMemSetting(mwmSetting, getUnit("maintenance_work_mem")),
|
|
2343
|
+
walBuffers: get("wal_buffers") ?? "",
|
|
2344
|
+
checkpointCompletionTarget: cctSetting ?? "",
|
|
2345
|
+
randomPageCost: rpcSetting ?? "",
|
|
2346
|
+
autovacuumVacuumScaleFactor: avsfSetting ?? ""
|
|
2347
|
+
};
|
|
2348
|
+
return {
|
|
2349
|
+
recommendations,
|
|
2350
|
+
serverInfo,
|
|
2351
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
|
|
1850
2355
|
// src/mcp.ts
|
|
1851
2356
|
import Database2 from "better-sqlite3";
|
|
1852
2357
|
import path3 from "path";
|
|
@@ -2198,6 +2703,46 @@ server.tool(
|
|
|
2198
2703
|
}
|
|
2199
2704
|
}
|
|
2200
2705
|
);
|
|
2706
|
+
server.tool("pg_dash_unused_indexes", "Find unused indexes that waste space and slow down writes", {}, async () => {
|
|
2707
|
+
try {
|
|
2708
|
+
const report = await getUnusedIndexes(pool);
|
|
2709
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2710
|
+
} catch (err) {
|
|
2711
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2712
|
+
}
|
|
2713
|
+
});
|
|
2714
|
+
server.tool("pg_dash_bloat", "Detect table bloat (dead tuples) that slow down queries", {}, async () => {
|
|
2715
|
+
try {
|
|
2716
|
+
const report = await getBloatReport(pool);
|
|
2717
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
server.tool("pg_dash_autovacuum", "Check autovacuum health \u2014 which tables are stale or never vacuumed", {}, async () => {
|
|
2723
|
+
try {
|
|
2724
|
+
const report = await getAutovacuumReport(pool);
|
|
2725
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2726
|
+
} catch (err) {
|
|
2727
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
server.tool("pg_dash_locks", "Show active lock waits and long-running queries blocking the database", {}, async () => {
|
|
2731
|
+
try {
|
|
2732
|
+
const report = await getLockReport(pool);
|
|
2733
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2734
|
+
} catch (err) {
|
|
2735
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2736
|
+
}
|
|
2737
|
+
});
|
|
2738
|
+
server.tool("pg_dash_config_check", "Audit PostgreSQL configuration settings and get tuning recommendations", {}, async () => {
|
|
2739
|
+
try {
|
|
2740
|
+
const report = await getConfigReport(pool);
|
|
2741
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
2744
|
+
}
|
|
2745
|
+
});
|
|
2201
2746
|
var transport = new StdioServerTransport();
|
|
2202
2747
|
await server.connect(transport);
|
|
2203
2748
|
//# sourceMappingURL=mcp.js.map
|