@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/cli.js
CHANGED
|
@@ -763,26 +763,28 @@ __export(snapshot_exports, {
|
|
|
763
763
|
});
|
|
764
764
|
import fs4 from "fs";
|
|
765
765
|
import path4 from "path";
|
|
766
|
-
function
|
|
767
|
-
|
|
766
|
+
function normalizeIssueId(id) {
|
|
767
|
+
return id.replace(/-\d+$/, "");
|
|
768
|
+
}
|
|
769
|
+
function saveSnapshot(snapshotPath, result) {
|
|
770
|
+
fs4.mkdirSync(path4.dirname(snapshotPath), { recursive: true });
|
|
768
771
|
const snapshot = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), result };
|
|
769
|
-
fs4.writeFileSync(
|
|
772
|
+
fs4.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
|
|
770
773
|
}
|
|
771
|
-
function loadSnapshot(
|
|
772
|
-
|
|
773
|
-
if (!fs4.existsSync(filePath)) return null;
|
|
774
|
+
function loadSnapshot(snapshotPath) {
|
|
775
|
+
if (!fs4.existsSync(snapshotPath)) return null;
|
|
774
776
|
try {
|
|
775
|
-
return JSON.parse(fs4.readFileSync(
|
|
777
|
+
return JSON.parse(fs4.readFileSync(snapshotPath, "utf-8"));
|
|
776
778
|
} catch {
|
|
777
779
|
return null;
|
|
778
780
|
}
|
|
779
781
|
}
|
|
780
782
|
function diffSnapshots2(prev, current) {
|
|
781
|
-
const
|
|
782
|
-
const
|
|
783
|
-
const newIssues = current.issues.filter((i) => !
|
|
784
|
-
const resolvedIssues = prev.issues.filter((i) => !
|
|
785
|
-
const unchanged = current.issues.filter((i) =>
|
|
783
|
+
const prevNormIds = new Set(prev.issues.map((i) => normalizeIssueId(i.id)));
|
|
784
|
+
const currNormIds = new Set(current.issues.map((i) => normalizeIssueId(i.id)));
|
|
785
|
+
const newIssues = current.issues.filter((i) => !prevNormIds.has(normalizeIssueId(i.id)));
|
|
786
|
+
const resolvedIssues = prev.issues.filter((i) => !currNormIds.has(normalizeIssueId(i.id)));
|
|
787
|
+
const unchanged = current.issues.filter((i) => prevNormIds.has(normalizeIssueId(i.id)));
|
|
786
788
|
return {
|
|
787
789
|
scoreDelta: current.score - prev.score,
|
|
788
790
|
previousScore: prev.score,
|
|
@@ -794,11 +796,593 @@ function diffSnapshots2(prev, current) {
|
|
|
794
796
|
unchanged
|
|
795
797
|
};
|
|
796
798
|
}
|
|
797
|
-
var SNAPSHOT_FILE;
|
|
798
799
|
var init_snapshot = __esm({
|
|
799
800
|
"src/server/snapshot.ts"() {
|
|
800
801
|
"use strict";
|
|
801
|
-
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// src/server/migration-checker.ts
|
|
806
|
+
var migration_checker_exports = {};
|
|
807
|
+
__export(migration_checker_exports, {
|
|
808
|
+
analyzeMigration: () => analyzeMigration
|
|
809
|
+
});
|
|
810
|
+
function stripComments(sql) {
|
|
811
|
+
let stripped = sql.replace(
|
|
812
|
+
/\/\*[\s\S]*?\*\//g,
|
|
813
|
+
(match) => match.replace(/[^\n]/g, " ")
|
|
814
|
+
);
|
|
815
|
+
stripped = stripped.replace(/--[^\n]*/g, (match) => " ".repeat(match.length));
|
|
816
|
+
return stripped;
|
|
817
|
+
}
|
|
818
|
+
function findLineNumber(sql, matchIndex) {
|
|
819
|
+
const before = sql.slice(0, matchIndex);
|
|
820
|
+
return before.split("\n").length;
|
|
821
|
+
}
|
|
822
|
+
function bareTable(name) {
|
|
823
|
+
return name.replace(/^public\./i, "").replace(/"/g, "").toLowerCase().trim();
|
|
824
|
+
}
|
|
825
|
+
function extractOperatedTables(sql) {
|
|
826
|
+
sql = stripComments(sql);
|
|
827
|
+
const indexTables = [];
|
|
828
|
+
const alterTables = [];
|
|
829
|
+
const dropTables = [];
|
|
830
|
+
const refTables = [];
|
|
831
|
+
const idxRe = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(?:\w+\s+)?ON\s+([\w."]+)/gi;
|
|
832
|
+
let m;
|
|
833
|
+
while ((m = idxRe.exec(sql)) !== null) indexTables.push(bareTable(m[1]));
|
|
834
|
+
const altRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)/gi;
|
|
835
|
+
while ((m = altRe.exec(sql)) !== null) alterTables.push(bareTable(m[1]));
|
|
836
|
+
const dropRe = /\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)/gi;
|
|
837
|
+
while ((m = dropRe.exec(sql)) !== null) dropTables.push(bareTable(m[1]));
|
|
838
|
+
const refRe = /\bREFERENCES\s+([\w."]+)/gi;
|
|
839
|
+
while ((m = refRe.exec(sql)) !== null) refTables.push(bareTable(m[1]));
|
|
840
|
+
return { indexTables, alterTables, dropTables, refTables };
|
|
841
|
+
}
|
|
842
|
+
function staticCheck(sql) {
|
|
843
|
+
const issues = [];
|
|
844
|
+
sql = stripComments(sql);
|
|
845
|
+
const createdTablesRe = /\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."]+)/gi;
|
|
846
|
+
const createdTables = /* @__PURE__ */ new Set();
|
|
847
|
+
let m;
|
|
848
|
+
while ((m = createdTablesRe.exec(sql)) !== null) createdTables.add(bareTable(m[1]));
|
|
849
|
+
const idxRe = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?!CONCURRENTLY)((?:IF\s+NOT\s+EXISTS\s+)?(?:\w+\s+)?ON\s+([\w."]+))/gi;
|
|
850
|
+
while ((m = idxRe.exec(sql)) !== null) {
|
|
851
|
+
const table = bareTable(m[2]);
|
|
852
|
+
const lineNumber = findLineNumber(sql, m.index);
|
|
853
|
+
if (!createdTables.has(table)) {
|
|
854
|
+
issues.push({
|
|
855
|
+
severity: "warning",
|
|
856
|
+
code: "INDEX_WITHOUT_CONCURRENTLY",
|
|
857
|
+
message: `CREATE INDEX on existing table will lock writes. Use CREATE INDEX CONCURRENTLY to avoid downtime.`,
|
|
858
|
+
suggestion: "Replace CREATE INDEX with CREATE INDEX CONCURRENTLY",
|
|
859
|
+
lineNumber,
|
|
860
|
+
tableName: table
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const idxConcRe = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\b/gi;
|
|
865
|
+
while ((m = idxConcRe.exec(sql)) !== null) {
|
|
866
|
+
issues.push({
|
|
867
|
+
severity: "info",
|
|
868
|
+
code: "INDEX_CONCURRENTLY_OK",
|
|
869
|
+
message: "CREATE INDEX CONCURRENTLY \u2014 safe, no write lock",
|
|
870
|
+
lineNumber: findLineNumber(sql, m.index)
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
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;
|
|
874
|
+
while ((m = addColRe.exec(sql)) !== null) {
|
|
875
|
+
const fragment = m[0];
|
|
876
|
+
const table = bareTable(m[1]);
|
|
877
|
+
const lineNumber = findLineNumber(sql, m.index);
|
|
878
|
+
const fragUpper = fragment.toUpperCase();
|
|
879
|
+
const hasNotNull = /\bNOT\s+NULL\b/.test(fragUpper);
|
|
880
|
+
const hasDefault = /\bDEFAULT\b/.test(fragUpper);
|
|
881
|
+
if (hasNotNull && !hasDefault) {
|
|
882
|
+
issues.push({
|
|
883
|
+
severity: "error",
|
|
884
|
+
code: "ADD_COLUMN_NOT_NULL_NO_DEFAULT",
|
|
885
|
+
message: "ADD COLUMN NOT NULL without DEFAULT will fail if table has existing rows",
|
|
886
|
+
suggestion: "Add a DEFAULT value, then remove it after migration",
|
|
887
|
+
lineNumber,
|
|
888
|
+
tableName: table
|
|
889
|
+
});
|
|
890
|
+
} else if (hasNotNull && hasDefault) {
|
|
891
|
+
issues.push({
|
|
892
|
+
severity: "warning",
|
|
893
|
+
code: "ADD_COLUMN_REWRITES_TABLE",
|
|
894
|
+
message: "ADD COLUMN with NOT NULL DEFAULT may rewrite table on PostgreSQL < 11",
|
|
895
|
+
suggestion: "On PostgreSQL 11+ with a constant default this is safe. For older versions, add column nullable first.",
|
|
896
|
+
lineNumber,
|
|
897
|
+
tableName: table
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const dropRe = /\bDROP\s+TABLE\b/gi;
|
|
902
|
+
while ((m = dropRe.exec(sql)) !== null) {
|
|
903
|
+
issues.push({
|
|
904
|
+
severity: "warning",
|
|
905
|
+
code: "DROP_TABLE",
|
|
906
|
+
message: "DROP TABLE is destructive. Ensure this is intentional and data is backed up.",
|
|
907
|
+
lineNumber: findLineNumber(sql, m.index)
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
const truncRe = /\bTRUNCATE\b/gi;
|
|
911
|
+
while ((m = truncRe.exec(sql)) !== null) {
|
|
912
|
+
issues.push({
|
|
913
|
+
severity: "warning",
|
|
914
|
+
code: "TRUNCATE_TABLE",
|
|
915
|
+
message: "TRUNCATE will delete all rows. Ensure this is intentional.",
|
|
916
|
+
lineNumber: findLineNumber(sql, m.index)
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const delRe = /\bDELETE\s+FROM\s+[\w."]+\s*(?:;|$)/gi;
|
|
920
|
+
while ((m = delRe.exec(sql)) !== null) {
|
|
921
|
+
const stmt = m[0];
|
|
922
|
+
if (!/\bWHERE\b/i.test(stmt)) {
|
|
923
|
+
issues.push({
|
|
924
|
+
severity: "warning",
|
|
925
|
+
code: "DELETE_WITHOUT_WHERE",
|
|
926
|
+
message: "DELETE without WHERE clause will remove all rows.",
|
|
927
|
+
lineNumber: findLineNumber(sql, m.index)
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
const updRe = /\bUPDATE\s+[\w."]+\s+SET\b[^;]*(;|$)/gi;
|
|
932
|
+
while ((m = updRe.exec(sql)) !== null) {
|
|
933
|
+
const stmt = m[0];
|
|
934
|
+
if (!/\bWHERE\b/i.test(stmt)) {
|
|
935
|
+
issues.push({
|
|
936
|
+
severity: "warning",
|
|
937
|
+
code: "UPDATE_WITHOUT_WHERE",
|
|
938
|
+
message: "UPDATE without WHERE clause will modify all rows.",
|
|
939
|
+
lineNumber: findLineNumber(sql, m.index)
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return issues;
|
|
944
|
+
}
|
|
945
|
+
async function dynamicCheck(sql, pool, staticIssues) {
|
|
946
|
+
const issues = [];
|
|
947
|
+
const { indexTables, alterTables, dropTables, refTables } = extractOperatedTables(sql);
|
|
948
|
+
const allTables = [.../* @__PURE__ */ new Set([...indexTables, ...alterTables, ...dropTables])];
|
|
949
|
+
const tableStats = /* @__PURE__ */ new Map();
|
|
950
|
+
if (allTables.length > 0) {
|
|
951
|
+
try {
|
|
952
|
+
const res = await pool.query(
|
|
953
|
+
`SELECT tablename,
|
|
954
|
+
n_live_tup,
|
|
955
|
+
pg_total_relation_size(schemaname||'.'||tablename) AS total_size
|
|
956
|
+
FROM pg_stat_user_tables
|
|
957
|
+
WHERE tablename = ANY($1)`,
|
|
958
|
+
[allTables]
|
|
959
|
+
);
|
|
960
|
+
for (const row of res.rows) {
|
|
961
|
+
tableStats.set(row.tablename, {
|
|
962
|
+
rowCount: parseInt(row.n_live_tup ?? "0", 10),
|
|
963
|
+
totalSize: parseInt(row.total_size ?? "0", 10)
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
} catch (_) {
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
for (const issue of staticIssues) {
|
|
970
|
+
if (issue.code === "INDEX_WITHOUT_CONCURRENTLY" && issue.tableName) {
|
|
971
|
+
const stats = tableStats.get(issue.tableName);
|
|
972
|
+
if (stats) {
|
|
973
|
+
const { rowCount } = stats;
|
|
974
|
+
const lockSecs = Math.round(rowCount / 5e4);
|
|
975
|
+
issue.estimatedRows = rowCount;
|
|
976
|
+
issue.estimatedLockSeconds = lockSecs;
|
|
977
|
+
if (rowCount > 1e6) {
|
|
978
|
+
issue.severity = "error";
|
|
979
|
+
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.`;
|
|
980
|
+
} else if (rowCount > 1e5) {
|
|
981
|
+
issue.message = `CREATE INDEX on '${issue.tableName}' will lock writes for ~${lockSecs}s (${(rowCount / 1e3).toFixed(0)}k rows).`;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const uniqueRefTables = [...new Set(refTables)];
|
|
987
|
+
for (const table of uniqueRefTables) {
|
|
988
|
+
try {
|
|
989
|
+
const res = await pool.query(
|
|
990
|
+
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = $1`,
|
|
991
|
+
[table]
|
|
992
|
+
);
|
|
993
|
+
if (res.rows.length === 0) {
|
|
994
|
+
issues.push({
|
|
995
|
+
severity: "error",
|
|
996
|
+
code: "MISSING_TABLE",
|
|
997
|
+
message: `Table '${table}' referenced in migration does not exist`,
|
|
998
|
+
tableName: table
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
} catch (_) {
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return issues;
|
|
1005
|
+
}
|
|
1006
|
+
async function analyzeMigration(sql, pool) {
|
|
1007
|
+
const trimmed = sql.trim();
|
|
1008
|
+
if (!trimmed) {
|
|
1009
|
+
return {
|
|
1010
|
+
safe: true,
|
|
1011
|
+
issues: [],
|
|
1012
|
+
summary: { errors: 0, warnings: 0, infos: 0 },
|
|
1013
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
const issues = staticCheck(trimmed);
|
|
1017
|
+
if (pool) {
|
|
1018
|
+
const dynamicIssues = await dynamicCheck(trimmed, pool, issues);
|
|
1019
|
+
issues.push(...dynamicIssues);
|
|
1020
|
+
}
|
|
1021
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
1022
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
1023
|
+
const infos = issues.filter((i) => i.severity === "info").length;
|
|
1024
|
+
return {
|
|
1025
|
+
safe: errors === 0,
|
|
1026
|
+
issues,
|
|
1027
|
+
summary: { errors, warnings, infos },
|
|
1028
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
var init_migration_checker = __esm({
|
|
1032
|
+
"src/server/migration-checker.ts"() {
|
|
1033
|
+
"use strict";
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
// src/server/env-differ.ts
|
|
1038
|
+
var env_differ_exports = {};
|
|
1039
|
+
__export(env_differ_exports, {
|
|
1040
|
+
diffEnvironments: () => diffEnvironments,
|
|
1041
|
+
formatMdDiff: () => formatMdDiff,
|
|
1042
|
+
formatTextDiff: () => formatTextDiff
|
|
1043
|
+
});
|
|
1044
|
+
import { Pool as Pool2 } from "pg";
|
|
1045
|
+
async function fetchTables(pool) {
|
|
1046
|
+
const res = await pool.query(`
|
|
1047
|
+
SELECT table_name
|
|
1048
|
+
FROM information_schema.tables
|
|
1049
|
+
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
1050
|
+
ORDER BY table_name
|
|
1051
|
+
`);
|
|
1052
|
+
return res.rows.map((r) => r.table_name);
|
|
1053
|
+
}
|
|
1054
|
+
async function fetchColumns(pool) {
|
|
1055
|
+
const res = await pool.query(`
|
|
1056
|
+
SELECT table_name, column_name, data_type, is_nullable, column_default
|
|
1057
|
+
FROM information_schema.columns
|
|
1058
|
+
WHERE table_schema = 'public'
|
|
1059
|
+
ORDER BY table_name, ordinal_position
|
|
1060
|
+
`);
|
|
1061
|
+
return res.rows;
|
|
1062
|
+
}
|
|
1063
|
+
async function fetchIndexes(pool) {
|
|
1064
|
+
const res = await pool.query(`
|
|
1065
|
+
SELECT tablename, indexname
|
|
1066
|
+
FROM pg_indexes
|
|
1067
|
+
WHERE schemaname = 'public' AND indexname NOT LIKE '%_pkey'
|
|
1068
|
+
ORDER BY tablename, indexname
|
|
1069
|
+
`);
|
|
1070
|
+
return res.rows;
|
|
1071
|
+
}
|
|
1072
|
+
function diffTables(sourceTables, targetTables) {
|
|
1073
|
+
const sourceSet = new Set(sourceTables);
|
|
1074
|
+
const targetSet = new Set(targetTables);
|
|
1075
|
+
return {
|
|
1076
|
+
missingTables: sourceTables.filter((t) => !targetSet.has(t)),
|
|
1077
|
+
extraTables: targetTables.filter((t) => !sourceSet.has(t))
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function groupColumnsByTable(columns) {
|
|
1081
|
+
const map = /* @__PURE__ */ new Map();
|
|
1082
|
+
for (const col of columns) {
|
|
1083
|
+
if (!map.has(col.table_name)) map.set(col.table_name, /* @__PURE__ */ new Map());
|
|
1084
|
+
const info = {
|
|
1085
|
+
name: col.column_name,
|
|
1086
|
+
type: col.data_type,
|
|
1087
|
+
nullable: col.is_nullable === "YES"
|
|
1088
|
+
};
|
|
1089
|
+
if (col.column_default !== null && col.column_default !== void 0) {
|
|
1090
|
+
info.default = col.column_default;
|
|
1091
|
+
}
|
|
1092
|
+
map.get(col.table_name).set(col.column_name, info);
|
|
1093
|
+
}
|
|
1094
|
+
return map;
|
|
1095
|
+
}
|
|
1096
|
+
function diffColumns(sourceCols, targetCols, commonTables) {
|
|
1097
|
+
const sourceByTable = groupColumnsByTable(sourceCols);
|
|
1098
|
+
const targetByTable = groupColumnsByTable(targetCols);
|
|
1099
|
+
const diffs = [];
|
|
1100
|
+
for (const table of commonTables) {
|
|
1101
|
+
const srcMap = sourceByTable.get(table) ?? /* @__PURE__ */ new Map();
|
|
1102
|
+
const tgtMap = targetByTable.get(table) ?? /* @__PURE__ */ new Map();
|
|
1103
|
+
const missingColumns = [];
|
|
1104
|
+
const extraColumns = [];
|
|
1105
|
+
const typeDiffs = [];
|
|
1106
|
+
for (const [colName, srcInfo] of srcMap) {
|
|
1107
|
+
if (!tgtMap.has(colName)) {
|
|
1108
|
+
missingColumns.push(srcInfo);
|
|
1109
|
+
} else {
|
|
1110
|
+
const tgtInfo = tgtMap.get(colName);
|
|
1111
|
+
if (srcInfo.type !== tgtInfo.type) {
|
|
1112
|
+
typeDiffs.push({ column: colName, sourceType: srcInfo.type, targetType: tgtInfo.type });
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
for (const [colName, tgtInfo] of tgtMap) {
|
|
1117
|
+
if (!srcMap.has(colName)) {
|
|
1118
|
+
extraColumns.push(tgtInfo);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0) {
|
|
1122
|
+
diffs.push({ table, missingColumns, extraColumns, typeDiffs });
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return diffs;
|
|
1126
|
+
}
|
|
1127
|
+
function groupIndexesByTable(indexes) {
|
|
1128
|
+
const map = /* @__PURE__ */ new Map();
|
|
1129
|
+
for (const idx of indexes) {
|
|
1130
|
+
if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new Set());
|
|
1131
|
+
map.get(idx.tablename).add(idx.indexname);
|
|
1132
|
+
}
|
|
1133
|
+
return map;
|
|
1134
|
+
}
|
|
1135
|
+
function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
|
|
1136
|
+
const srcByTable = groupIndexesByTable(sourceIdxs);
|
|
1137
|
+
const tgtByTable = groupIndexesByTable(targetIdxs);
|
|
1138
|
+
const diffs = [];
|
|
1139
|
+
const allTables = /* @__PURE__ */ new Set([
|
|
1140
|
+
...sourceIdxs.map((i) => i.tablename),
|
|
1141
|
+
...targetIdxs.map((i) => i.tablename)
|
|
1142
|
+
]);
|
|
1143
|
+
for (const table of allTables) {
|
|
1144
|
+
if (!commonTables.includes(table)) continue;
|
|
1145
|
+
const srcSet = srcByTable.get(table) ?? /* @__PURE__ */ new Set();
|
|
1146
|
+
const tgtSet = tgtByTable.get(table) ?? /* @__PURE__ */ new Set();
|
|
1147
|
+
const missingIndexes = [...srcSet].filter((i) => !tgtSet.has(i));
|
|
1148
|
+
const extraIndexes = [...tgtSet].filter((i) => !srcSet.has(i));
|
|
1149
|
+
if (missingIndexes.length > 0 || extraIndexes.length > 0) {
|
|
1150
|
+
diffs.push({ table, missingIndexes, extraIndexes });
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return diffs;
|
|
1154
|
+
}
|
|
1155
|
+
function countSchemaDrifts(schema) {
|
|
1156
|
+
let n = schema.missingTables.length + schema.extraTables.length;
|
|
1157
|
+
for (const cd of schema.columnDiffs) {
|
|
1158
|
+
n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length;
|
|
1159
|
+
}
|
|
1160
|
+
for (const id of schema.indexDiffs) {
|
|
1161
|
+
n += id.missingIndexes.length + id.extraIndexes.length;
|
|
1162
|
+
}
|
|
1163
|
+
return n;
|
|
1164
|
+
}
|
|
1165
|
+
async function diffEnvironments(sourceConn, targetConn, options) {
|
|
1166
|
+
const sourcePool = new Pool2({ connectionString: sourceConn, connectionTimeoutMillis: 1e4 });
|
|
1167
|
+
const targetPool = new Pool2({ connectionString: targetConn, connectionTimeoutMillis: 1e4 });
|
|
1168
|
+
try {
|
|
1169
|
+
const [
|
|
1170
|
+
sourceTables,
|
|
1171
|
+
targetTables,
|
|
1172
|
+
sourceCols,
|
|
1173
|
+
targetCols,
|
|
1174
|
+
sourceIdxs,
|
|
1175
|
+
targetIdxs
|
|
1176
|
+
] = await Promise.all([
|
|
1177
|
+
fetchTables(sourcePool),
|
|
1178
|
+
fetchTables(targetPool),
|
|
1179
|
+
fetchColumns(sourcePool),
|
|
1180
|
+
fetchColumns(targetPool),
|
|
1181
|
+
fetchIndexes(sourcePool),
|
|
1182
|
+
fetchIndexes(targetPool)
|
|
1183
|
+
]);
|
|
1184
|
+
const { missingTables, extraTables } = diffTables(sourceTables, targetTables);
|
|
1185
|
+
const sourceSet = new Set(sourceTables);
|
|
1186
|
+
const targetSet = new Set(targetTables);
|
|
1187
|
+
const commonTables = sourceTables.filter((t) => targetSet.has(t));
|
|
1188
|
+
const columnDiffs = diffColumns(sourceCols, targetCols, commonTables);
|
|
1189
|
+
const indexDiffs = diffIndexes(sourceIdxs, targetIdxs, commonTables);
|
|
1190
|
+
const schema = { missingTables, extraTables, columnDiffs, indexDiffs };
|
|
1191
|
+
const schemaDrifts = countSchemaDrifts(schema);
|
|
1192
|
+
let health;
|
|
1193
|
+
if (options?.includeHealth) {
|
|
1194
|
+
const longQueryThreshold = 5;
|
|
1195
|
+
const [srcReport, tgtReport] = await Promise.all([
|
|
1196
|
+
getAdvisorReport(sourcePool, longQueryThreshold),
|
|
1197
|
+
getAdvisorReport(targetPool, longQueryThreshold)
|
|
1198
|
+
]);
|
|
1199
|
+
const srcIssueKeys = new Set(srcReport.issues.map((i) => i.title));
|
|
1200
|
+
const tgtIssueKeys = new Set(tgtReport.issues.map((i) => i.title));
|
|
1201
|
+
const sourceOnlyIssues = srcReport.issues.filter((i) => !tgtIssueKeys.has(i.title)).map((i) => `${i.severity}: ${i.title}`);
|
|
1202
|
+
const targetOnlyIssues = tgtReport.issues.filter((i) => !srcIssueKeys.has(i.title)).map((i) => `${i.severity}: ${i.title}`);
|
|
1203
|
+
health = {
|
|
1204
|
+
source: { score: srcReport.score, grade: srcReport.grade, url: maskConnectionString(sourceConn) },
|
|
1205
|
+
target: { score: tgtReport.score, grade: tgtReport.grade, url: maskConnectionString(targetConn) },
|
|
1206
|
+
sourceOnlyIssues,
|
|
1207
|
+
targetOnlyIssues
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
schema,
|
|
1212
|
+
health,
|
|
1213
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1214
|
+
summary: {
|
|
1215
|
+
schemaDrifts,
|
|
1216
|
+
identical: schemaDrifts === 0
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
} finally {
|
|
1220
|
+
await Promise.allSettled([sourcePool.end(), targetPool.end()]);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
function maskConnectionString(connStr) {
|
|
1224
|
+
try {
|
|
1225
|
+
const url = new URL(connStr);
|
|
1226
|
+
if (url.password) url.password = "***";
|
|
1227
|
+
return url.toString();
|
|
1228
|
+
} catch {
|
|
1229
|
+
return "<redacted>";
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
function formatTextDiff(result) {
|
|
1233
|
+
const lines = [];
|
|
1234
|
+
const sep = "\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550";
|
|
1235
|
+
lines.push(`Environment Diff`);
|
|
1236
|
+
lines.push(sep);
|
|
1237
|
+
lines.push(``);
|
|
1238
|
+
lines.push(`Schema Drift:`);
|
|
1239
|
+
const { schema } = result;
|
|
1240
|
+
if (schema.missingTables.length > 0) {
|
|
1241
|
+
lines.push(` \u2717 target missing tables: ${schema.missingTables.join(", ")}`);
|
|
1242
|
+
}
|
|
1243
|
+
if (schema.extraTables.length > 0) {
|
|
1244
|
+
lines.push(` \u26A0 target has extra tables: ${schema.extraTables.join(", ")}`);
|
|
1245
|
+
}
|
|
1246
|
+
const missingCols = [];
|
|
1247
|
+
const extraCols = [];
|
|
1248
|
+
const typeChanges = [];
|
|
1249
|
+
for (const cd of schema.columnDiffs) {
|
|
1250
|
+
for (const col of cd.missingColumns) {
|
|
1251
|
+
missingCols.push(` ${cd.table}: ${col.name} (${col.type})`);
|
|
1252
|
+
}
|
|
1253
|
+
for (const col of cd.extraColumns) {
|
|
1254
|
+
extraCols.push(` ${cd.table}: ${col.name} (${col.type})`);
|
|
1255
|
+
}
|
|
1256
|
+
for (const td of cd.typeDiffs) {
|
|
1257
|
+
typeChanges.push(` ${cd.table}.${td.column}: ${td.sourceType} \u2192 ${td.targetType}`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
if (missingCols.length > 0) {
|
|
1261
|
+
lines.push(` \u2717 target missing columns:`);
|
|
1262
|
+
lines.push(...missingCols);
|
|
1263
|
+
}
|
|
1264
|
+
if (extraCols.length > 0) {
|
|
1265
|
+
lines.push(` \u26A0 target has extra columns:`);
|
|
1266
|
+
lines.push(...extraCols);
|
|
1267
|
+
}
|
|
1268
|
+
if (typeChanges.length > 0) {
|
|
1269
|
+
lines.push(` ~ column type differences:`);
|
|
1270
|
+
lines.push(...typeChanges);
|
|
1271
|
+
}
|
|
1272
|
+
const missingIdxs = [];
|
|
1273
|
+
const extraIdxs = [];
|
|
1274
|
+
for (const id of schema.indexDiffs) {
|
|
1275
|
+
for (const idx of id.missingIndexes) {
|
|
1276
|
+
missingIdxs.push(` ${id.table}: ${idx}`);
|
|
1277
|
+
}
|
|
1278
|
+
for (const idx of id.extraIndexes) {
|
|
1279
|
+
extraIdxs.push(` ${id.table}: ${idx}`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (missingIdxs.length > 0) {
|
|
1283
|
+
lines.push(` \u2717 target missing indexes:`);
|
|
1284
|
+
lines.push(...missingIdxs);
|
|
1285
|
+
}
|
|
1286
|
+
if (extraIdxs.length > 0) {
|
|
1287
|
+
lines.push(` \u26A0 target has extra indexes:`);
|
|
1288
|
+
lines.push(...extraIdxs);
|
|
1289
|
+
}
|
|
1290
|
+
if (schema.missingTables.length === 0 && schema.extraTables.length === 0 && schema.columnDiffs.length === 0 && schema.indexDiffs.length === 0) {
|
|
1291
|
+
lines.push(` \u2713 Schemas are identical`);
|
|
1292
|
+
}
|
|
1293
|
+
if (result.health) {
|
|
1294
|
+
const h = result.health;
|
|
1295
|
+
lines.push(``);
|
|
1296
|
+
lines.push(`Health Comparison:`);
|
|
1297
|
+
lines.push(` Source: ${h.source.score}/100 (${h.source.grade}) | Target: ${h.target.score}/100 (${h.target.grade})`);
|
|
1298
|
+
lines.push(` Source-only issues: ${h.sourceOnlyIssues.length === 0 ? "(none)" : ""}`);
|
|
1299
|
+
for (const iss of h.sourceOnlyIssues) lines.push(` - ${iss}`);
|
|
1300
|
+
lines.push(` Target-only issues: ${h.targetOnlyIssues.length === 0 ? "(none)" : ""}`);
|
|
1301
|
+
for (const iss of h.targetOnlyIssues) lines.push(` - ${iss}`);
|
|
1302
|
+
}
|
|
1303
|
+
lines.push(``);
|
|
1304
|
+
lines.push(sep);
|
|
1305
|
+
const { schemaDrifts, identical } = result.summary;
|
|
1306
|
+
lines.push(`Total: ${schemaDrifts} schema drift${schemaDrifts !== 1 ? "s" : ""} | Environments are ${identical ? "in sync \u2713" : "NOT in sync \u2717"}`);
|
|
1307
|
+
return lines.join("\n");
|
|
1308
|
+
}
|
|
1309
|
+
function formatMdDiff(result) {
|
|
1310
|
+
const lines = [];
|
|
1311
|
+
lines.push(`## \u{1F504} Environment Diff`);
|
|
1312
|
+
lines.push(``);
|
|
1313
|
+
lines.push(`### Schema Drift`);
|
|
1314
|
+
lines.push(``);
|
|
1315
|
+
const { schema } = result;
|
|
1316
|
+
const rows = [];
|
|
1317
|
+
if (schema.missingTables.length > 0) {
|
|
1318
|
+
rows.push([`\u274C Missing tables`, schema.missingTables.map((t) => `\`${t}\``).join(", ")]);
|
|
1319
|
+
}
|
|
1320
|
+
if (schema.extraTables.length > 0) {
|
|
1321
|
+
rows.push([`\u26A0\uFE0F Extra tables`, schema.extraTables.map((t) => `\`${t}\``).join(", ")]);
|
|
1322
|
+
}
|
|
1323
|
+
const missingColItems = [];
|
|
1324
|
+
const extraColItems = [];
|
|
1325
|
+
const typeItems = [];
|
|
1326
|
+
for (const cd of schema.columnDiffs) {
|
|
1327
|
+
for (const col of cd.missingColumns) {
|
|
1328
|
+
missingColItems.push(`\`${cd.table}.${col.name}\``);
|
|
1329
|
+
}
|
|
1330
|
+
for (const col of cd.extraColumns) {
|
|
1331
|
+
extraColItems.push(`\`${cd.table}.${col.name}\``);
|
|
1332
|
+
}
|
|
1333
|
+
for (const td of cd.typeDiffs) {
|
|
1334
|
+
typeItems.push(`\`${cd.table}.${td.column}\` (${td.sourceType}\u2192${td.targetType})`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
if (missingColItems.length > 0) rows.push([`\u274C Missing columns`, missingColItems.join(", ")]);
|
|
1338
|
+
if (extraColItems.length > 0) rows.push([`\u26A0\uFE0F Extra columns`, extraColItems.join(", ")]);
|
|
1339
|
+
if (typeItems.length > 0) rows.push([`~ Type differences`, typeItems.join(", ")]);
|
|
1340
|
+
const missingIdxItems = [];
|
|
1341
|
+
const extraIdxItems = [];
|
|
1342
|
+
for (const id of schema.indexDiffs) {
|
|
1343
|
+
for (const idx of id.missingIndexes) missingIdxItems.push(`\`${id.table}.${idx}\``);
|
|
1344
|
+
for (const idx of id.extraIndexes) extraIdxItems.push(`\`${id.table}.${idx}\``);
|
|
1345
|
+
}
|
|
1346
|
+
if (missingIdxItems.length > 0) rows.push([`\u274C Missing indexes`, missingIdxItems.join(", ")]);
|
|
1347
|
+
if (extraIdxItems.length > 0) rows.push([`\u26A0\uFE0F Extra indexes`, extraIdxItems.join(", ")]);
|
|
1348
|
+
if (rows.length > 0) {
|
|
1349
|
+
lines.push(`| Type | Details |`);
|
|
1350
|
+
lines.push(`|------|---------|`);
|
|
1351
|
+
for (const [type, details] of rows) {
|
|
1352
|
+
lines.push(`| ${type} | ${details} |`);
|
|
1353
|
+
}
|
|
1354
|
+
} else {
|
|
1355
|
+
lines.push(`\u2705 Schemas are identical`);
|
|
1356
|
+
}
|
|
1357
|
+
if (result.health) {
|
|
1358
|
+
const h = result.health;
|
|
1359
|
+
lines.push(``);
|
|
1360
|
+
lines.push(`### Health Comparison`);
|
|
1361
|
+
lines.push(``);
|
|
1362
|
+
lines.push(`| | Score | Grade |`);
|
|
1363
|
+
lines.push(`|--|-------|-------|`);
|
|
1364
|
+
lines.push(`| Source | ${h.source.score}/100 | ${h.source.grade} |`);
|
|
1365
|
+
lines.push(`| Target | ${h.target.score}/100 | ${h.target.grade} |`);
|
|
1366
|
+
if (h.targetOnlyIssues.length > 0) {
|
|
1367
|
+
lines.push(``);
|
|
1368
|
+
lines.push(`**Target-only issues:**`);
|
|
1369
|
+
for (const iss of h.targetOnlyIssues) lines.push(`- ${iss}`);
|
|
1370
|
+
}
|
|
1371
|
+
if (h.sourceOnlyIssues.length > 0) {
|
|
1372
|
+
lines.push(``);
|
|
1373
|
+
lines.push(`**Source-only issues:**`);
|
|
1374
|
+
for (const iss of h.sourceOnlyIssues) lines.push(`- ${iss}`);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
lines.push(``);
|
|
1378
|
+
const { schemaDrifts, identical } = result.summary;
|
|
1379
|
+
lines.push(`**Result: ${schemaDrifts} drift${schemaDrifts !== 1 ? "s" : ""} \u2014 environments are ${identical ? "in sync \u2713" : "NOT in sync"}**`);
|
|
1380
|
+
return lines.join("\n");
|
|
1381
|
+
}
|
|
1382
|
+
var init_env_differ = __esm({
|
|
1383
|
+
"src/server/env-differ.ts"() {
|
|
1384
|
+
"use strict";
|
|
1385
|
+
init_advisor();
|
|
802
1386
|
}
|
|
803
1387
|
});
|
|
804
1388
|
|
|
@@ -2222,6 +2806,140 @@ function registerAlertsRoutes(app, alertManager) {
|
|
|
2222
2806
|
});
|
|
2223
2807
|
}
|
|
2224
2808
|
|
|
2809
|
+
// src/server/query-analyzer.ts
|
|
2810
|
+
function collectNodes(node, acc = []) {
|
|
2811
|
+
if (!node || typeof node !== "object") return acc;
|
|
2812
|
+
acc.push(node);
|
|
2813
|
+
const plans = node["Plans"] ?? node["plans"];
|
|
2814
|
+
if (Array.isArray(plans)) {
|
|
2815
|
+
for (const child of plans) collectNodes(child, acc);
|
|
2816
|
+
}
|
|
2817
|
+
return acc;
|
|
2818
|
+
}
|
|
2819
|
+
function extractColumnsFromFilter(filter) {
|
|
2820
|
+
const colPattern = /\(?"?([a-z_][a-z0-9_]*)"?\s*(?:=|<|>|<=|>=|<>|!=|IS\s+(?:NOT\s+)?NULL|~~|!~~)/gi;
|
|
2821
|
+
const found = /* @__PURE__ */ new Set();
|
|
2822
|
+
let m;
|
|
2823
|
+
while ((m = colPattern.exec(filter)) !== null) {
|
|
2824
|
+
const col = m[1].toLowerCase();
|
|
2825
|
+
if (!["and", "or", "not", "true", "false", "null"].includes(col)) {
|
|
2826
|
+
found.add(col);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
return Array.from(found);
|
|
2830
|
+
}
|
|
2831
|
+
async function getExistingIndexColumns(pool, tableName) {
|
|
2832
|
+
try {
|
|
2833
|
+
const r = await pool.query(
|
|
2834
|
+
`SELECT indexdef FROM pg_indexes WHERE tablename = $1`,
|
|
2835
|
+
[tableName]
|
|
2836
|
+
);
|
|
2837
|
+
return r.rows.map((row) => {
|
|
2838
|
+
const m = /\(([^)]+)\)/.exec(row.indexdef);
|
|
2839
|
+
if (!m) return [];
|
|
2840
|
+
return m[1].split(",").map((c) => c.trim().replace(/^"|"$/g, "").toLowerCase());
|
|
2841
|
+
});
|
|
2842
|
+
} catch {
|
|
2843
|
+
return [];
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
function rateBenefit(rowCount) {
|
|
2847
|
+
if (rowCount > 1e5) return "high";
|
|
2848
|
+
if (rowCount >= 1e4) return "medium";
|
|
2849
|
+
return "low";
|
|
2850
|
+
}
|
|
2851
|
+
function fmtRows(n) {
|
|
2852
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
2853
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
|
|
2854
|
+
return String(n);
|
|
2855
|
+
}
|
|
2856
|
+
async function analyzeExplainPlan(explainJson, pool) {
|
|
2857
|
+
const result = {
|
|
2858
|
+
planNodes: [],
|
|
2859
|
+
seqScans: [],
|
|
2860
|
+
missingIndexes: [],
|
|
2861
|
+
costEstimate: { totalCost: 0 },
|
|
2862
|
+
recommendations: []
|
|
2863
|
+
};
|
|
2864
|
+
if (!explainJson || !Array.isArray(explainJson) || explainJson.length === 0) {
|
|
2865
|
+
return result;
|
|
2866
|
+
}
|
|
2867
|
+
const topLevel = explainJson[0];
|
|
2868
|
+
const planRoot = topLevel?.["Plan"] ?? topLevel?.["plan"];
|
|
2869
|
+
const planningTime = topLevel?.["Planning Time"] ?? void 0;
|
|
2870
|
+
const executionTime = topLevel?.["Execution Time"] ?? void 0;
|
|
2871
|
+
if (!planRoot) return result;
|
|
2872
|
+
const allNodes = collectNodes(planRoot);
|
|
2873
|
+
result.planNodes = allNodes.map((n) => {
|
|
2874
|
+
const s = {
|
|
2875
|
+
nodeType: n["Node Type"] ?? "Unknown",
|
|
2876
|
+
totalCost: n["Total Cost"] ?? 0
|
|
2877
|
+
};
|
|
2878
|
+
if (n["Relation Name"]) s.table = n["Relation Name"];
|
|
2879
|
+
if (n["Actual Rows"] !== void 0) s.actualRows = n["Actual Rows"];
|
|
2880
|
+
if (n["Actual Total Time"] !== void 0) s.actualTime = n["Actual Total Time"];
|
|
2881
|
+
if (n["Filter"]) s.filter = n["Filter"];
|
|
2882
|
+
return s;
|
|
2883
|
+
});
|
|
2884
|
+
result.costEstimate = {
|
|
2885
|
+
totalCost: planRoot["Total Cost"] ?? 0,
|
|
2886
|
+
actualTime: executionTime,
|
|
2887
|
+
planningTime
|
|
2888
|
+
};
|
|
2889
|
+
const seqScanNodes = allNodes.filter((n) => n["Node Type"] === "Seq Scan");
|
|
2890
|
+
for (const node of seqScanNodes) {
|
|
2891
|
+
const table = node["Relation Name"] ?? "unknown";
|
|
2892
|
+
const rowCount = node["Plan Rows"] ?? node["Actual Rows"] ?? 0;
|
|
2893
|
+
const filter = node["Filter"];
|
|
2894
|
+
const info = { table, rowCount, filter };
|
|
2895
|
+
if (rowCount > 1e4) {
|
|
2896
|
+
info.suggestion = filter ? `Consider adding an index to support the filter on ${table}` : `Full table scan on large table ${table} \u2014 review query`;
|
|
2897
|
+
}
|
|
2898
|
+
result.seqScans.push(info);
|
|
2899
|
+
}
|
|
2900
|
+
for (const scan of result.seqScans) {
|
|
2901
|
+
if (!scan.filter) continue;
|
|
2902
|
+
const cols = extractColumnsFromFilter(scan.filter);
|
|
2903
|
+
if (cols.length === 0) continue;
|
|
2904
|
+
let existingIndexCols = [];
|
|
2905
|
+
if (pool) {
|
|
2906
|
+
existingIndexCols = await getExistingIndexColumns(pool, scan.table);
|
|
2907
|
+
}
|
|
2908
|
+
for (const col of cols) {
|
|
2909
|
+
const alreadyCovered = existingIndexCols.some(
|
|
2910
|
+
(idxCols) => idxCols.length > 0 && idxCols[0] === col
|
|
2911
|
+
);
|
|
2912
|
+
if (alreadyCovered) continue;
|
|
2913
|
+
const benefit = rateBenefit(scan.rowCount);
|
|
2914
|
+
const idxName = `idx_${scan.table}_${col}`;
|
|
2915
|
+
const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${col})`;
|
|
2916
|
+
result.missingIndexes.push({
|
|
2917
|
+
table: scan.table,
|
|
2918
|
+
columns: [col],
|
|
2919
|
+
reason: `Seq Scan with Filter on ${col} (${fmtRows(scan.rowCount)} rows)`,
|
|
2920
|
+
sql,
|
|
2921
|
+
estimatedBenefit: benefit
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
for (const scan of result.seqScans) {
|
|
2926
|
+
if (scan.rowCount > 1e4) {
|
|
2927
|
+
const filterPart = scan.filter ? ` \u2014 consider adding index on ${extractColumnsFromFilter(scan.filter).join(", ") || "filter columns"}` : " \u2014 no filter; full scan may be intentional";
|
|
2928
|
+
result.recommendations.push(
|
|
2929
|
+
`Seq Scan on ${scan.table} (${fmtRows(scan.rowCount)} rows)${filterPart}`
|
|
2930
|
+
);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
if (planningTime !== void 0) {
|
|
2934
|
+
const label = planningTime > 10 ? "high \u2014 check statistics" : "normal";
|
|
2935
|
+
result.recommendations.push(`Planning time ${planningTime.toFixed(1)}ms \u2014 ${label}`);
|
|
2936
|
+
}
|
|
2937
|
+
if (result.missingIndexes.length === 0 && result.seqScans.length === 0) {
|
|
2938
|
+
result.recommendations.push("No obvious sequential scans detected \u2014 query looks efficient");
|
|
2939
|
+
}
|
|
2940
|
+
return result;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2225
2943
|
// src/server/routes/explain.ts
|
|
2226
2944
|
var DDL_PATTERN = /\b(CREATE|DROP|ALTER|TRUNCATE|GRANT|REVOKE)\b/i;
|
|
2227
2945
|
function registerExplainRoutes(app, pool) {
|
|
@@ -2240,7 +2958,13 @@ function registerExplainRoutes(app, pool) {
|
|
|
2240
2958
|
const r = await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`);
|
|
2241
2959
|
await client.query("ROLLBACK");
|
|
2242
2960
|
await client.query("RESET statement_timeout");
|
|
2243
|
-
|
|
2961
|
+
const plan = r.rows[0]["QUERY PLAN"];
|
|
2962
|
+
let analysis = null;
|
|
2963
|
+
try {
|
|
2964
|
+
analysis = await analyzeExplainPlan(plan, pool);
|
|
2965
|
+
} catch {
|
|
2966
|
+
}
|
|
2967
|
+
return c.json({ plan, analysis });
|
|
2244
2968
|
} catch (err) {
|
|
2245
2969
|
await client.query("ROLLBACK").catch(() => {
|
|
2246
2970
|
});
|
|
@@ -2707,7 +3431,7 @@ import { WebSocketServer, WebSocket } from "ws";
|
|
|
2707
3431
|
import http from "http";
|
|
2708
3432
|
var __dirname = path3.dirname(fileURLToPath(import.meta.url));
|
|
2709
3433
|
async function startServer(opts) {
|
|
2710
|
-
const pool = new Pool({ connectionString: opts.connectionString });
|
|
3434
|
+
const pool = new Pool({ connectionString: opts.connectionString, connectionTimeoutMillis: 1e4 });
|
|
2711
3435
|
try {
|
|
2712
3436
|
const client = await pool.connect();
|
|
2713
3437
|
client.release();
|
|
@@ -3068,7 +3792,11 @@ var { values, positionals } = parseArgs({
|
|
|
3068
3792
|
threshold: { type: "string" },
|
|
3069
3793
|
format: { type: "string", short: "f" },
|
|
3070
3794
|
ci: { type: "boolean", default: false },
|
|
3071
|
-
diff: { type: "boolean", default: false }
|
|
3795
|
+
diff: { type: "boolean", default: false },
|
|
3796
|
+
"snapshot-path": { type: "string" },
|
|
3797
|
+
source: { type: "string" },
|
|
3798
|
+
target: { type: "string" },
|
|
3799
|
+
health: { type: "boolean", default: false }
|
|
3072
3800
|
}
|
|
3073
3801
|
});
|
|
3074
3802
|
if (values.version) {
|
|
@@ -3089,6 +3817,7 @@ Usage:
|
|
|
3089
3817
|
pg-dash <connection-string>
|
|
3090
3818
|
pg-dash check <connection-string> Run health check and exit
|
|
3091
3819
|
pg-dash schema-diff <connection-string> Show latest schema changes
|
|
3820
|
+
pg-dash diff-env --source <url> --target <url> Compare two environments
|
|
3092
3821
|
pg-dash --host localhost --user postgres --db mydb
|
|
3093
3822
|
|
|
3094
3823
|
Options:
|
|
@@ -3115,7 +3844,11 @@ Options:
|
|
|
3115
3844
|
--threshold <score> Health score threshold for check command (default: 70)
|
|
3116
3845
|
-f, --format <fmt> Output format: text|json|md (default: text)
|
|
3117
3846
|
--ci Output GitHub Actions compatible annotations
|
|
3118
|
-
--diff Compare with previous run (saves
|
|
3847
|
+
--diff Compare with previous run (saves snapshot for next run)
|
|
3848
|
+
--snapshot-path <path> Path to snapshot file for --diff (default: ~/.pg-dash/last-check.json)
|
|
3849
|
+
--source <url> Source database connection string (diff-env)
|
|
3850
|
+
--target <url> Target database connection string (diff-env)
|
|
3851
|
+
--health Also compare health scores and issues (diff-env)
|
|
3119
3852
|
-v, --version Show version
|
|
3120
3853
|
-h, --help Show this help
|
|
3121
3854
|
|
|
@@ -3148,22 +3881,23 @@ if (subcommand === "check") {
|
|
|
3148
3881
|
const format = values.format || "text";
|
|
3149
3882
|
const ci = values.ci || false;
|
|
3150
3883
|
const useDiff = values.diff || false;
|
|
3151
|
-
const { Pool:
|
|
3884
|
+
const { Pool: Pool3 } = await import("pg");
|
|
3152
3885
|
const { getAdvisorReport: getAdvisorReport2 } = await Promise.resolve().then(() => (init_advisor(), advisor_exports));
|
|
3153
3886
|
const { saveSnapshot: saveSnapshot2, loadSnapshot: loadSnapshot2, diffSnapshots: diffSnapshots3 } = await Promise.resolve().then(() => (init_snapshot(), snapshot_exports));
|
|
3154
3887
|
const os4 = await import("os");
|
|
3155
|
-
const pool = new
|
|
3888
|
+
const pool = new Pool3({ connectionString, connectionTimeoutMillis: 1e4 });
|
|
3156
3889
|
const checkDataDir = values["data-dir"] || path5.join(os4.homedir(), ".pg-dash");
|
|
3890
|
+
const snapshotPath = values["snapshot-path"] || path5.join(checkDataDir, "last-check.json");
|
|
3157
3891
|
try {
|
|
3158
3892
|
const lqt = parseInt(values["long-query-threshold"] || process.env.PG_DASH_LONG_QUERY_THRESHOLD || "5", 10);
|
|
3159
3893
|
const report = await getAdvisorReport2(pool, lqt);
|
|
3160
3894
|
let diff = null;
|
|
3161
3895
|
if (useDiff) {
|
|
3162
|
-
const prev = loadSnapshot2(
|
|
3896
|
+
const prev = loadSnapshot2(snapshotPath);
|
|
3163
3897
|
if (prev) {
|
|
3164
3898
|
diff = diffSnapshots3(prev.result, report);
|
|
3165
3899
|
}
|
|
3166
|
-
saveSnapshot2(
|
|
3900
|
+
saveSnapshot2(snapshotPath, report);
|
|
3167
3901
|
}
|
|
3168
3902
|
if (format === "json") {
|
|
3169
3903
|
const output = { ...report };
|
|
@@ -3281,6 +4015,91 @@ Score: ${diff.previousScore} \u2192 ${report.score} (${sign}${diff.scoreDelta})`
|
|
|
3281
4015
|
await pool.end();
|
|
3282
4016
|
process.exit(1);
|
|
3283
4017
|
}
|
|
4018
|
+
} else if (subcommand === "check-migration") {
|
|
4019
|
+
const filePath = positionals[1];
|
|
4020
|
+
if (!filePath) {
|
|
4021
|
+
console.error("Error: provide a migration SQL file path.\n\nUsage: pg-dash check-migration <file> [connection]");
|
|
4022
|
+
process.exit(1);
|
|
4023
|
+
}
|
|
4024
|
+
if (!fs5.existsSync(filePath)) {
|
|
4025
|
+
console.error(`Error: File not found: ${filePath}`);
|
|
4026
|
+
process.exit(1);
|
|
4027
|
+
}
|
|
4028
|
+
const sql = fs5.readFileSync(filePath, "utf-8");
|
|
4029
|
+
const migrationConn = positionals[2];
|
|
4030
|
+
const format = values.format || "text";
|
|
4031
|
+
const ci = values.ci || false;
|
|
4032
|
+
const { analyzeMigration: analyzeMigration2 } = await Promise.resolve().then(() => (init_migration_checker(), migration_checker_exports));
|
|
4033
|
+
let pool;
|
|
4034
|
+
if (migrationConn) {
|
|
4035
|
+
const { Pool: Pool3 } = await import("pg");
|
|
4036
|
+
pool = new Pool3({ connectionString: migrationConn, connectionTimeoutMillis: 1e4 });
|
|
4037
|
+
}
|
|
4038
|
+
try {
|
|
4039
|
+
const result = await analyzeMigration2(sql, pool);
|
|
4040
|
+
if (pool) await pool.end();
|
|
4041
|
+
const sep = "\u2500".repeat(48);
|
|
4042
|
+
if (format === "json") {
|
|
4043
|
+
console.log(JSON.stringify(result, null, 2));
|
|
4044
|
+
} else if (format === "md") {
|
|
4045
|
+
console.log("## \u{1F50D} Migration Safety Check\n");
|
|
4046
|
+
console.log("| Severity | Code | Message |");
|
|
4047
|
+
console.log("|----------|------|---------|");
|
|
4048
|
+
for (const issue of result.issues) {
|
|
4049
|
+
const sev = issue.severity === "error" ? "\u{1F534} ERROR" : issue.severity === "warning" ? "\u26A0\uFE0F WARNING" : "\u2139\uFE0F INFO";
|
|
4050
|
+
console.log(`| ${sev} | ${issue.code} | ${issue.message} |`);
|
|
4051
|
+
}
|
|
4052
|
+
const { errors, warnings, infos } = result.summary;
|
|
4053
|
+
const safeLabel = result.safe ? "\u2705 SAFE" : "\u274C UNSAFE";
|
|
4054
|
+
console.log(`
|
|
4055
|
+
**Result: ${safeLabel} \u2014 ${errors} error${errors !== 1 ? "s" : ""}, ${warnings} warning${warnings !== 1 ? "s" : ""}, ${infos} info${infos !== 1 ? "s" : ""}**`);
|
|
4056
|
+
} else {
|
|
4057
|
+
console.log(`
|
|
4058
|
+
Migration check: ${filePath}`);
|
|
4059
|
+
console.log(sep);
|
|
4060
|
+
if (result.issues.length === 0) {
|
|
4061
|
+
console.log("\n \u2705 No issues found!\n");
|
|
4062
|
+
} else {
|
|
4063
|
+
for (const issue of result.issues) {
|
|
4064
|
+
const icon = issue.severity === "error" ? "\u2717" : issue.severity === "warning" ? "\u26A0" : "\u2713";
|
|
4065
|
+
const indent = " ";
|
|
4066
|
+
const parts = [`${indent}${icon} ${issue.message}`];
|
|
4067
|
+
if (issue.suggestion) parts.push(`${indent} Suggestion: ${issue.suggestion}`);
|
|
4068
|
+
if (issue.estimatedRows !== void 0) {
|
|
4069
|
+
parts.push(
|
|
4070
|
+
`${indent} Est. rows: ${issue.estimatedRows.toLocaleString()}` + (issue.estimatedLockSeconds !== void 0 ? `, lock ~${issue.estimatedLockSeconds}s` : "")
|
|
4071
|
+
);
|
|
4072
|
+
}
|
|
4073
|
+
if (issue.lineNumber !== void 0) parts.push(`${indent} Line ${issue.lineNumber}`);
|
|
4074
|
+
console.log(parts.join("\n") + "\n");
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
console.log(sep);
|
|
4078
|
+
const { errors, warnings, infos } = result.summary;
|
|
4079
|
+
const safeLabel = result.safe ? "SAFE" : "UNSAFE";
|
|
4080
|
+
console.log(
|
|
4081
|
+
`Result: ${safeLabel} \u2014 ${errors} error${errors !== 1 ? "s" : ""}, ${warnings} warning${warnings !== 1 ? "s" : ""}, ${infos} info${infos !== 1 ? "s" : ""}
|
|
4082
|
+
`
|
|
4083
|
+
);
|
|
4084
|
+
if (!migrationConn) {
|
|
4085
|
+
console.log("Run with a connection string for more accurate row count estimates.\n");
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
if (ci) {
|
|
4089
|
+
for (const issue of result.issues) {
|
|
4090
|
+
const level = issue.severity === "error" ? "error" : issue.severity === "warning" ? "warning" : "notice";
|
|
4091
|
+
const loc = issue.lineNumber ? `,line=${issue.lineNumber}` : "";
|
|
4092
|
+
const file = `file=${filePath}${loc}`;
|
|
4093
|
+
console.log(`::${level} ${file}::${issue.message}`);
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
process.exit(result.safe ? 0 : 1);
|
|
4097
|
+
} catch (err) {
|
|
4098
|
+
if (pool) await pool.end().catch(() => {
|
|
4099
|
+
});
|
|
4100
|
+
console.error(`Error: ${err.message}`);
|
|
4101
|
+
process.exit(1);
|
|
4102
|
+
}
|
|
3284
4103
|
} else if (subcommand === "schema-diff") {
|
|
3285
4104
|
const connectionString = resolveConnectionString(1);
|
|
3286
4105
|
const dataDir = values["data-dir"] || path5.join((await import("os")).homedir(), ".pg-dash");
|
|
@@ -3307,6 +4126,59 @@ Score: ${diff.previousScore} \u2192 ${report.score} (${sign}${diff.scoreDelta})`
|
|
|
3307
4126
|
console.log();
|
|
3308
4127
|
}
|
|
3309
4128
|
process.exit(0);
|
|
4129
|
+
} else if (subcommand === "diff-env") {
|
|
4130
|
+
const sourceUrl = values.source;
|
|
4131
|
+
const targetUrl = values.target;
|
|
4132
|
+
if (!sourceUrl || !targetUrl) {
|
|
4133
|
+
console.error("Error: diff-env requires --source <url> and --target <url>");
|
|
4134
|
+
process.exit(1);
|
|
4135
|
+
}
|
|
4136
|
+
const format = values.format || "text";
|
|
4137
|
+
const includeHealth = values.health || false;
|
|
4138
|
+
const ci = values.ci || false;
|
|
4139
|
+
const { diffEnvironments: diffEnvironments2, formatTextDiff: formatTextDiff2, formatMdDiff: formatMdDiff2 } = await Promise.resolve().then(() => (init_env_differ(), env_differ_exports));
|
|
4140
|
+
try {
|
|
4141
|
+
const result = await diffEnvironments2(sourceUrl, targetUrl, { includeHealth });
|
|
4142
|
+
if (format === "json") {
|
|
4143
|
+
console.log(JSON.stringify(result, null, 2));
|
|
4144
|
+
} else if (format === "md") {
|
|
4145
|
+
console.log(formatMdDiff2(result));
|
|
4146
|
+
} else {
|
|
4147
|
+
const text = formatTextDiff2(result);
|
|
4148
|
+
console.log(text);
|
|
4149
|
+
if (ci) {
|
|
4150
|
+
for (const t of result.schema.missingTables) {
|
|
4151
|
+
console.log(`::error::diff-env: target missing table: ${t}`);
|
|
4152
|
+
}
|
|
4153
|
+
for (const t of result.schema.extraTables) {
|
|
4154
|
+
console.log(`::notice::diff-env: target has extra table: ${t}`);
|
|
4155
|
+
}
|
|
4156
|
+
for (const cd of result.schema.columnDiffs) {
|
|
4157
|
+
for (const col of cd.missingColumns) {
|
|
4158
|
+
console.log(`::error::diff-env: target missing column: ${cd.table}.${col.name} (${col.type})`);
|
|
4159
|
+
}
|
|
4160
|
+
for (const col of cd.extraColumns) {
|
|
4161
|
+
console.log(`::notice::diff-env: target has extra column: ${cd.table}.${col.name} (${col.type})`);
|
|
4162
|
+
}
|
|
4163
|
+
for (const td of cd.typeDiffs) {
|
|
4164
|
+
console.log(`::error::diff-env: type mismatch: ${cd.table}.${td.column} ${td.sourceType}\u2192${td.targetType}`);
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
for (const id of result.schema.indexDiffs) {
|
|
4168
|
+
for (const idx of id.missingIndexes) {
|
|
4169
|
+
console.log(`::warning::diff-env: target missing index: ${id.table}.${idx}`);
|
|
4170
|
+
}
|
|
4171
|
+
for (const idx of id.extraIndexes) {
|
|
4172
|
+
console.log(`::notice::diff-env: target has extra index: ${id.table}.${idx}`);
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
process.exit(result.summary.identical ? 0 : 1);
|
|
4178
|
+
} catch (err) {
|
|
4179
|
+
console.error(`Error: ${err.message}`);
|
|
4180
|
+
process.exit(1);
|
|
4181
|
+
}
|
|
3310
4182
|
} else {
|
|
3311
4183
|
const connectionString = resolveConnectionString(0);
|
|
3312
4184
|
const port = parseInt(values.port, 10);
|