@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/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 Set());
1256
- map.get(idx.tablename).add(idx.indexname);
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 srcSet = srcByTable.get(table) ?? /* @__PURE__ */ new Set();
1271
- const tgtSet = tgtByTable.get(table) ?? /* @__PURE__ */ new Set();
1272
- const missingIndexes = [...srcSet].filter((i) => !tgtSet.has(i));
1273
- const extraIndexes = [...tgtSet].filter((i) => !srcSet.has(i));
1274
- if (missingIndexes.length > 0 || extraIndexes.length > 0) {
1275
- diffs.push({ table, missingIndexes, extraIndexes });
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
- for (const col of cols) {
1484
- const alreadyCovered = existingIndexCols.some(
1485
- (idxCols) => idxCols.length > 0 && idxCols[0] === col
1486
- );
1487
- if (alreadyCovered) continue;
1488
- const benefit = rateBenefit(scan.rowCount);
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