@indiekitai/pg-dash 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 saveSnapshot(dataDir, result) {
767
- fs4.mkdirSync(dataDir, { recursive: true });
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(path4.join(dataDir, SNAPSHOT_FILE), JSON.stringify(snapshot, null, 2));
772
+ fs4.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
770
773
  }
771
- function loadSnapshot(dataDir) {
772
- const filePath = path4.join(dataDir, SNAPSHOT_FILE);
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(filePath, "utf-8"));
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 prevIds = new Set(prev.issues.map((i) => i.id));
782
- const currIds = new Set(current.issues.map((i) => i.id));
783
- const newIssues = current.issues.filter((i) => !prevIds.has(i.id));
784
- const resolvedIssues = prev.issues.filter((i) => !currIds.has(i.id));
785
- const unchanged = current.issues.filter((i) => prevIds.has(i.id));
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
- SNAPSHOT_FILE = "last-check.json";
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
- return c.json({ plan: r.rows[0]["QUERY PLAN"] });
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 to ~/.pg-dash/last-check.json)
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: Pool2 } = await import("pg");
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 Pool2({ connectionString });
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(checkDataDir);
3896
+ const prev = loadSnapshot2(snapshotPath);
3163
3897
  if (prev) {
3164
3898
  diff = diffSnapshots3(prev.result, report);
3165
3899
  }
3166
- saveSnapshot2(checkDataDir, report);
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);