@indiekitai/pg-dash 0.3.7 → 0.3.8

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
@@ -754,69 +754,519 @@ var init_advisor = __esm({
754
754
  }
755
755
  });
756
756
 
757
- // src/server/snapshot.ts
758
- var snapshot_exports = {};
759
- __export(snapshot_exports, {
760
- diffSnapshots: () => diffSnapshots2,
761
- loadSnapshot: () => loadSnapshot,
762
- saveSnapshot: () => saveSnapshot
763
- });
764
- import fs4 from "fs";
765
- import path4 from "path";
766
- function normalizeIssueId(id) {
767
- return id.replace(/-\d+$/, "");
757
+ // src/server/queries/schema.ts
758
+ async function getSchemaTables(pool) {
759
+ const client = await pool.connect();
760
+ try {
761
+ const r = await client.query(`
762
+ SELECT
763
+ c.relname AS name,
764
+ n.nspname AS schema,
765
+ pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
766
+ pg_total_relation_size(c.oid) AS total_size_bytes,
767
+ pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
768
+ pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) AS index_size,
769
+ s.n_live_tup AS row_count,
770
+ obj_description(c.oid) AS description
771
+ FROM pg_class c
772
+ JOIN pg_namespace n ON c.relnamespace = n.oid
773
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
774
+ WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
775
+ ORDER BY pg_total_relation_size(c.oid) DESC
776
+ `);
777
+ return r.rows;
778
+ } finally {
779
+ client.release();
780
+ }
768
781
  }
769
- function saveSnapshot(snapshotPath, result) {
770
- fs4.mkdirSync(path4.dirname(snapshotPath), { recursive: true });
771
- const snapshot = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), result };
772
- fs4.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
782
+ async function getSchemaTableDetail(pool, tableName) {
783
+ const client = await pool.connect();
784
+ try {
785
+ const parts = tableName.split(".");
786
+ const schema = parts.length > 1 ? parts[0] : "public";
787
+ const name = parts.length > 1 ? parts[1] : parts[0];
788
+ const tableInfo = await client.query(`
789
+ SELECT
790
+ c.relname AS name, n.nspname AS schema,
791
+ pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
792
+ pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
793
+ pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) AS index_size,
794
+ pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_size,
795
+ s.n_live_tup AS row_count, s.n_dead_tup AS dead_tuples,
796
+ s.last_vacuum, s.last_autovacuum, s.last_analyze, s.last_autoanalyze,
797
+ s.seq_scan, s.idx_scan
798
+ FROM pg_class c
799
+ JOIN pg_namespace n ON c.relnamespace = n.oid
800
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
801
+ WHERE c.relname = $1 AND n.nspname = $2 AND c.relkind = 'r'
802
+ `, [name, schema]);
803
+ if (tableInfo.rows.length === 0) return null;
804
+ const columns = await client.query(`
805
+ SELECT
806
+ a.attname AS name,
807
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
808
+ NOT a.attnotnull AS nullable,
809
+ pg_get_expr(d.adbin, d.adrelid) AS default_value,
810
+ col_description(a.attrelid, a.attnum) AS description
811
+ FROM pg_attribute a
812
+ LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
813
+ WHERE a.attrelid = (SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid WHERE c.relname = $1 AND n.nspname = $2)
814
+ AND a.attnum > 0 AND NOT a.attisdropped
815
+ ORDER BY a.attnum
816
+ `, [name, schema]);
817
+ const indexes = await client.query(`
818
+ SELECT
819
+ i.relname AS name,
820
+ am.amname AS type,
821
+ pg_size_pretty(pg_relation_size(i.oid)) AS size,
822
+ pg_get_indexdef(idx.indexrelid) AS definition,
823
+ idx.indisunique AS is_unique,
824
+ idx.indisprimary AS is_primary,
825
+ s.idx_scan, s.idx_tup_read, s.idx_tup_fetch
826
+ FROM pg_index idx
827
+ JOIN pg_class i ON idx.indexrelid = i.oid
828
+ JOIN pg_class t ON idx.indrelid = t.oid
829
+ JOIN pg_namespace n ON t.relnamespace = n.oid
830
+ JOIN pg_am am ON i.relam = am.oid
831
+ LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
832
+ WHERE t.relname = $1 AND n.nspname = $2
833
+ ORDER BY i.relname
834
+ `, [name, schema]);
835
+ const constraints = await client.query(`
836
+ SELECT
837
+ conname AS name,
838
+ CASE contype WHEN 'p' THEN 'PRIMARY KEY' WHEN 'f' THEN 'FOREIGN KEY'
839
+ WHEN 'u' THEN 'UNIQUE' WHEN 'c' THEN 'CHECK' WHEN 'x' THEN 'EXCLUDE' END AS type,
840
+ pg_get_constraintdef(oid) AS definition
841
+ FROM pg_constraint
842
+ WHERE conrelid = (SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid WHERE c.relname = $1 AND n.nspname = $2)
843
+ ORDER BY
844
+ CASE contype WHEN 'p' THEN 1 WHEN 'u' THEN 2 WHEN 'f' THEN 3 WHEN 'c' THEN 4 ELSE 5 END
845
+ `, [name, schema]);
846
+ const foreignKeys = await client.query(`
847
+ SELECT
848
+ conname AS name,
849
+ a.attname AS column_name,
850
+ confrelid::regclass::text AS referenced_table,
851
+ af.attname AS referenced_column
852
+ FROM pg_constraint c
853
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
854
+ JOIN pg_attribute af ON af.attrelid = c.confrelid AND af.attnum = ANY(c.confkey)
855
+ WHERE c.contype = 'f'
856
+ AND c.conrelid = (SELECT cl.oid FROM pg_class cl JOIN pg_namespace n ON cl.relnamespace = n.oid WHERE cl.relname = $1 AND n.nspname = $2)
857
+ `, [name, schema]);
858
+ let sampleData = [];
859
+ try {
860
+ const sample = await client.query(
861
+ `SELECT * FROM ${client.escapeIdentifier(schema)}.${client.escapeIdentifier(name)} LIMIT 10`
862
+ );
863
+ sampleData = sample.rows;
864
+ } catch (err) {
865
+ console.error("[schema] Error:", err.message);
866
+ }
867
+ return {
868
+ ...tableInfo.rows[0],
869
+ columns: columns.rows,
870
+ indexes: indexes.rows,
871
+ constraints: constraints.rows,
872
+ foreignKeys: foreignKeys.rows,
873
+ sampleData
874
+ };
875
+ } finally {
876
+ client.release();
877
+ }
773
878
  }
774
- function loadSnapshot(snapshotPath) {
775
- if (!fs4.existsSync(snapshotPath)) return null;
879
+ async function getSchemaIndexes(pool) {
880
+ const client = await pool.connect();
776
881
  try {
777
- return JSON.parse(fs4.readFileSync(snapshotPath, "utf-8"));
778
- } catch {
779
- return null;
882
+ const r = await client.query(`
883
+ SELECT
884
+ n.nspname AS schema,
885
+ t.relname AS table_name,
886
+ i.relname AS name,
887
+ am.amname AS type,
888
+ pg_size_pretty(pg_relation_size(i.oid)) AS size,
889
+ pg_relation_size(i.oid) AS size_bytes,
890
+ pg_get_indexdef(idx.indexrelid) AS definition,
891
+ idx.indisunique AS is_unique,
892
+ idx.indisprimary AS is_primary,
893
+ s.idx_scan, s.idx_tup_read, s.idx_tup_fetch
894
+ FROM pg_index idx
895
+ JOIN pg_class i ON idx.indexrelid = i.oid
896
+ JOIN pg_class t ON idx.indrelid = t.oid
897
+ JOIN pg_namespace n ON t.relnamespace = n.oid
898
+ JOIN pg_am am ON i.relam = am.oid
899
+ LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
900
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
901
+ ORDER BY pg_relation_size(i.oid) DESC
902
+ `);
903
+ return r.rows;
904
+ } finally {
905
+ client.release();
780
906
  }
781
907
  }
782
- function diffSnapshots2(prev, current) {
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)));
788
- return {
789
- scoreDelta: current.score - prev.score,
790
- previousScore: prev.score,
791
- currentScore: current.score,
792
- previousGrade: prev.grade,
793
- currentGrade: current.grade,
794
- newIssues,
795
- resolvedIssues,
796
- unchanged
797
- };
908
+ async function getSchemaFunctions(pool) {
909
+ const client = await pool.connect();
910
+ try {
911
+ const r = await client.query(`
912
+ SELECT
913
+ n.nspname AS schema,
914
+ p.proname AS name,
915
+ pg_get_function_result(p.oid) AS return_type,
916
+ pg_get_function_arguments(p.oid) AS arguments,
917
+ l.lanname AS language,
918
+ p.prosrc AS source,
919
+ CASE p.prokind WHEN 'f' THEN 'function' WHEN 'p' THEN 'procedure' WHEN 'a' THEN 'aggregate' WHEN 'w' THEN 'window' END AS kind
920
+ FROM pg_proc p
921
+ JOIN pg_namespace n ON p.pronamespace = n.oid
922
+ JOIN pg_language l ON p.prolang = l.oid
923
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
924
+ ORDER BY n.nspname, p.proname
925
+ `);
926
+ return r.rows;
927
+ } finally {
928
+ client.release();
929
+ }
798
930
  }
799
- var init_snapshot = __esm({
800
- "src/server/snapshot.ts"() {
931
+ async function getSchemaExtensions(pool) {
932
+ const client = await pool.connect();
933
+ try {
934
+ const r = await client.query(`
935
+ SELECT extname AS name, extversion AS installed_version,
936
+ n.nspname AS schema, obj_description(e.oid) AS description
937
+ FROM pg_extension e
938
+ JOIN pg_namespace n ON e.extnamespace = n.oid
939
+ ORDER BY extname
940
+ `);
941
+ return r.rows;
942
+ } finally {
943
+ client.release();
944
+ }
945
+ }
946
+ async function getSchemaEnums(pool) {
947
+ const client = await pool.connect();
948
+ try {
949
+ const r = await client.query(`
950
+ SELECT
951
+ t.typname AS name,
952
+ n.nspname AS schema,
953
+ array_agg(e.enumlabel ORDER BY e.enumsortorder) AS values
954
+ FROM pg_type t
955
+ JOIN pg_namespace n ON t.typnamespace = n.oid
956
+ JOIN pg_enum e ON t.oid = e.enumtypid
957
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
958
+ GROUP BY t.typname, n.nspname
959
+ ORDER BY t.typname
960
+ `);
961
+ return r.rows;
962
+ } finally {
963
+ client.release();
964
+ }
965
+ }
966
+ var init_schema = __esm({
967
+ "src/server/queries/schema.ts"() {
801
968
  "use strict";
802
969
  }
803
970
  });
804
971
 
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);
972
+ // src/server/schema-diff.ts
973
+ function diffSnapshots(oldSnap, newSnap) {
974
+ const changes = [];
975
+ const oldTableMap = new Map(oldSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
976
+ const newTableMap = new Map(newSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
977
+ for (const [key, t] of newTableMap) {
978
+ if (!oldTableMap.has(key)) {
979
+ changes.push({ change_type: "added", object_type: "table", table_name: key, detail: `Table ${key} added` });
980
+ }
981
+ }
982
+ for (const [key] of oldTableMap) {
983
+ if (!newTableMap.has(key)) {
984
+ changes.push({ change_type: "removed", object_type: "table", table_name: key, detail: `Table ${key} removed` });
985
+ }
986
+ }
987
+ for (const [key, newTable] of newTableMap) {
988
+ const oldTable = oldTableMap.get(key);
989
+ if (!oldTable) continue;
990
+ const oldCols = new Map(oldTable.columns.map((c) => [c.name, c]));
991
+ const newCols = new Map(newTable.columns.map((c) => [c.name, c]));
992
+ for (const [name, col] of newCols) {
993
+ const oldCol = oldCols.get(name);
994
+ if (!oldCol) {
995
+ changes.push({ change_type: "added", object_type: "column", table_name: key, detail: `Column ${name} added (${col.type})` });
996
+ } else {
997
+ if (oldCol.type !== col.type) {
998
+ changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} type changed: ${oldCol.type} \u2192 ${col.type}` });
999
+ }
1000
+ if (oldCol.nullable !== col.nullable) {
1001
+ changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} nullable changed: ${oldCol.nullable} \u2192 ${col.nullable}` });
1002
+ }
1003
+ if (oldCol.default_value !== col.default_value) {
1004
+ changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} default changed: ${oldCol.default_value ?? "NULL"} \u2192 ${col.default_value ?? "NULL"}` });
1005
+ }
1006
+ }
1007
+ }
1008
+ for (const name of oldCols.keys()) {
1009
+ if (!newCols.has(name)) {
1010
+ changes.push({ change_type: "removed", object_type: "column", table_name: key, detail: `Column ${name} removed` });
1011
+ }
1012
+ }
1013
+ const oldIdx = new Map(oldTable.indexes.map((i) => [i.name, i]));
1014
+ const newIdx = new Map(newTable.indexes.map((i) => [i.name, i]));
1015
+ for (const [name, idx] of newIdx) {
1016
+ if (!oldIdx.has(name)) {
1017
+ changes.push({ change_type: "added", object_type: "index", table_name: key, detail: `Index ${name} added` });
1018
+ } else if (oldIdx.get(name).definition !== idx.definition) {
1019
+ changes.push({ change_type: "modified", object_type: "index", table_name: key, detail: `Index ${name} definition changed` });
1020
+ }
1021
+ }
1022
+ for (const name of oldIdx.keys()) {
1023
+ if (!newIdx.has(name)) {
1024
+ changes.push({ change_type: "removed", object_type: "index", table_name: key, detail: `Index ${name} removed` });
1025
+ }
1026
+ }
1027
+ const oldCon = new Map(oldTable.constraints.map((c) => [c.name, c]));
1028
+ const newCon = new Map(newTable.constraints.map((c) => [c.name, c]));
1029
+ for (const [name, con] of newCon) {
1030
+ if (!oldCon.has(name)) {
1031
+ changes.push({ change_type: "added", object_type: "constraint", table_name: key, detail: `Constraint ${name} added (${con.type})` });
1032
+ } else if (oldCon.get(name).definition !== con.definition) {
1033
+ changes.push({ change_type: "modified", object_type: "constraint", table_name: key, detail: `Constraint ${name} definition changed` });
1034
+ }
1035
+ }
1036
+ for (const name of oldCon.keys()) {
1037
+ if (!newCon.has(name)) {
1038
+ changes.push({ change_type: "removed", object_type: "constraint", table_name: key, detail: `Constraint ${name} removed` });
1039
+ }
1040
+ }
1041
+ }
1042
+ const oldEnums = new Map((oldSnap.enums || []).map((e) => [`${e.schema}.${e.name}`, e]));
1043
+ const newEnums = new Map((newSnap.enums || []).map((e) => [`${e.schema}.${e.name}`, e]));
1044
+ for (const [key, en] of newEnums) {
1045
+ const oldEn = oldEnums.get(key);
1046
+ if (!oldEn) {
1047
+ changes.push({ change_type: "added", object_type: "enum", table_name: null, detail: `Enum ${key} added (${en.values.join(", ")})` });
1048
+ } else {
1049
+ const added = en.values.filter((v) => !oldEn.values.includes(v));
1050
+ const removed = oldEn.values.filter((v) => !en.values.includes(v));
1051
+ for (const v of added) {
1052
+ changes.push({ change_type: "modified", object_type: "enum", table_name: null, detail: `Enum ${key}: value '${v}' added` });
1053
+ }
1054
+ for (const v of removed) {
1055
+ changes.push({ change_type: "modified", object_type: "enum", table_name: null, detail: `Enum ${key}: value '${v}' removed` });
1056
+ }
1057
+ }
1058
+ }
1059
+ for (const key of oldEnums.keys()) {
1060
+ if (!newEnums.has(key)) {
1061
+ changes.push({ change_type: "removed", object_type: "enum", table_name: null, detail: `Enum ${key} removed` });
1062
+ }
1063
+ }
1064
+ return changes;
1065
+ }
1066
+ var init_schema_diff = __esm({
1067
+ "src/server/schema-diff.ts"() {
1068
+ "use strict";
1069
+ }
1070
+ });
1071
+
1072
+ // src/server/schema-tracker.ts
1073
+ async function buildLiveSnapshot(pool) {
1074
+ const tables = await getSchemaTables(pool);
1075
+ const enums = await getSchemaEnums(pool);
1076
+ const detailedTables = await Promise.all(
1077
+ tables.map(async (t) => {
1078
+ const detail = await getSchemaTableDetail(pool, `${t.schema}.${t.name}`);
1079
+ if (!detail) return null;
1080
+ return {
1081
+ name: detail.name,
1082
+ schema: detail.schema,
1083
+ columns: detail.columns.map((c) => ({
1084
+ name: c.name,
1085
+ type: c.type,
1086
+ nullable: c.nullable,
1087
+ default_value: c.default_value
1088
+ })),
1089
+ indexes: detail.indexes.map((i) => ({
1090
+ name: i.name,
1091
+ definition: i.definition,
1092
+ is_unique: i.is_unique,
1093
+ is_primary: i.is_primary
1094
+ })),
1095
+ constraints: detail.constraints.map((c) => ({
1096
+ name: c.name,
1097
+ type: c.type,
1098
+ definition: c.definition
1099
+ }))
1100
+ };
1101
+ })
1102
+ );
1103
+ return {
1104
+ tables: detailedTables.filter(Boolean),
1105
+ enums: enums.map((e) => ({ name: e.name, schema: e.schema, values: e.values }))
1106
+ };
1107
+ }
1108
+ var SchemaTracker;
1109
+ var init_schema_tracker = __esm({
1110
+ "src/server/schema-tracker.ts"() {
1111
+ "use strict";
1112
+ init_schema();
1113
+ init_schema_diff();
1114
+ SchemaTracker = class {
1115
+ db;
1116
+ pool;
1117
+ intervalMs;
1118
+ timer = null;
1119
+ constructor(db, pool, intervalMs = 6 * 60 * 60 * 1e3) {
1120
+ this.db = db;
1121
+ this.pool = pool;
1122
+ this.intervalMs = intervalMs;
1123
+ this.initTables();
1124
+ }
1125
+ initTables() {
1126
+ this.db.exec(`
1127
+ CREATE TABLE IF NOT EXISTS schema_snapshots (
1128
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1129
+ timestamp INTEGER NOT NULL,
1130
+ snapshot TEXT NOT NULL
1131
+ );
1132
+ CREATE TABLE IF NOT EXISTS schema_changes (
1133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1134
+ snapshot_id INTEGER NOT NULL,
1135
+ timestamp INTEGER NOT NULL,
1136
+ change_type TEXT NOT NULL,
1137
+ object_type TEXT NOT NULL,
1138
+ table_name TEXT,
1139
+ detail TEXT NOT NULL,
1140
+ FOREIGN KEY (snapshot_id) REFERENCES schema_snapshots(id)
1141
+ );
1142
+ `);
1143
+ }
1144
+ async takeSnapshot() {
1145
+ const snapshot = await this.buildSnapshot();
1146
+ const now = Date.now();
1147
+ const json = JSON.stringify(snapshot);
1148
+ const info = this.db.prepare("INSERT INTO schema_snapshots (timestamp, snapshot) VALUES (?, ?)").run(now, json);
1149
+ const snapshotId = Number(info.lastInsertRowid);
1150
+ const prev = this.db.prepare("SELECT snapshot FROM schema_snapshots WHERE id < ? ORDER BY id DESC LIMIT 1").get(snapshotId);
1151
+ let changes = [];
1152
+ if (prev) {
1153
+ const oldSnap = JSON.parse(prev.snapshot);
1154
+ changes = diffSnapshots(oldSnap, snapshot);
1155
+ if (changes.length > 0) {
1156
+ const insert = this.db.prepare("INSERT INTO schema_changes (snapshot_id, timestamp, change_type, object_type, table_name, detail) VALUES (?, ?, ?, ?, ?, ?)");
1157
+ const tx = this.db.transaction((chs) => {
1158
+ for (const c of chs) {
1159
+ insert.run(snapshotId, now, c.change_type, c.object_type, c.table_name, c.detail);
1160
+ }
1161
+ });
1162
+ tx(changes);
1163
+ }
1164
+ }
1165
+ return { snapshotId, changes };
1166
+ }
1167
+ async buildSnapshot() {
1168
+ return buildLiveSnapshot(this.pool);
1169
+ }
1170
+ start() {
1171
+ this.takeSnapshot().catch((err) => console.error("Schema snapshot error:", err.message));
1172
+ this.timer = setInterval(() => {
1173
+ this.takeSnapshot().catch((err) => console.error("Schema snapshot error:", err.message));
1174
+ }, this.intervalMs);
1175
+ }
1176
+ stop() {
1177
+ if (this.timer) {
1178
+ clearInterval(this.timer);
1179
+ this.timer = null;
1180
+ }
1181
+ }
1182
+ // API helpers
1183
+ getHistory(limit = 30) {
1184
+ return this.db.prepare("SELECT id, timestamp FROM schema_snapshots ORDER BY id DESC LIMIT ?").all(limit);
1185
+ }
1186
+ getChanges(since) {
1187
+ if (since) {
1188
+ return this.db.prepare("SELECT * FROM schema_changes WHERE timestamp >= ? ORDER BY timestamp DESC").all(since);
1189
+ }
1190
+ return this.db.prepare("SELECT * FROM schema_changes ORDER BY timestamp DESC LIMIT 100").all();
1191
+ }
1192
+ getLatestChanges() {
1193
+ const latest = this.db.prepare("SELECT id FROM schema_snapshots ORDER BY id DESC LIMIT 1").get();
1194
+ if (!latest) return [];
1195
+ return this.db.prepare("SELECT * FROM schema_changes WHERE snapshot_id = ? ORDER BY id").all(latest.id);
1196
+ }
1197
+ getDiff(fromId, toId) {
1198
+ const from = this.db.prepare("SELECT snapshot FROM schema_snapshots WHERE id = ?").get(fromId);
1199
+ const to = this.db.prepare("SELECT snapshot FROM schema_snapshots WHERE id = ?").get(toId);
1200
+ if (!from || !to) return null;
1201
+ return diffSnapshots(JSON.parse(from.snapshot), JSON.parse(to.snapshot));
1202
+ }
1203
+ };
1204
+ }
1205
+ });
1206
+
1207
+ // src/server/snapshot.ts
1208
+ var snapshot_exports = {};
1209
+ __export(snapshot_exports, {
1210
+ diffSnapshots: () => diffSnapshots2,
1211
+ loadSnapshot: () => loadSnapshot,
1212
+ saveSnapshot: () => saveSnapshot
1213
+ });
1214
+ import fs4 from "fs";
1215
+ import path4 from "path";
1216
+ function normalizeIssueId(id) {
1217
+ return id.replace(/-\d+$/, "");
1218
+ }
1219
+ function saveSnapshot(snapshotPath, result) {
1220
+ fs4.mkdirSync(path4.dirname(snapshotPath), { recursive: true });
1221
+ const snapshot = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), result };
1222
+ fs4.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
1223
+ }
1224
+ function loadSnapshot(snapshotPath) {
1225
+ if (!fs4.existsSync(snapshotPath)) return null;
1226
+ try {
1227
+ return JSON.parse(fs4.readFileSync(snapshotPath, "utf-8"));
1228
+ } catch {
1229
+ return null;
1230
+ }
1231
+ }
1232
+ function diffSnapshots2(prev, current) {
1233
+ const prevNormIds = new Set(prev.issues.map((i) => normalizeIssueId(i.id)));
1234
+ const currNormIds = new Set(current.issues.map((i) => normalizeIssueId(i.id)));
1235
+ const newIssues = current.issues.filter((i) => !prevNormIds.has(normalizeIssueId(i.id)));
1236
+ const resolvedIssues = prev.issues.filter((i) => !currNormIds.has(normalizeIssueId(i.id)));
1237
+ const unchanged = current.issues.filter((i) => prevNormIds.has(normalizeIssueId(i.id)));
1238
+ return {
1239
+ scoreDelta: current.score - prev.score,
1240
+ previousScore: prev.score,
1241
+ currentScore: current.score,
1242
+ previousGrade: prev.grade,
1243
+ currentGrade: current.grade,
1244
+ newIssues,
1245
+ resolvedIssues,
1246
+ unchanged
1247
+ };
1248
+ }
1249
+ var init_snapshot = __esm({
1250
+ "src/server/snapshot.ts"() {
1251
+ "use strict";
1252
+ }
1253
+ });
1254
+
1255
+ // src/server/migration-checker.ts
1256
+ var migration_checker_exports = {};
1257
+ __export(migration_checker_exports, {
1258
+ analyzeMigration: () => analyzeMigration
1259
+ });
1260
+ function stripComments(sql) {
1261
+ let stripped = sql.replace(
1262
+ /\/\*[\s\S]*?\*\//g,
1263
+ (match) => match.replace(/[^\n]/g, " ")
1264
+ );
1265
+ stripped = stripped.replace(/--[^\n]*/g, (match) => " ".repeat(match.length));
1266
+ return stripped;
1267
+ }
1268
+ function findLineNumber(sql, matchIndex) {
1269
+ const before = sql.slice(0, matchIndex);
820
1270
  return before.split("\n").length;
821
1271
  }
822
1272
  function bareTable(name) {
@@ -907,6 +1357,56 @@ function staticCheck(sql) {
907
1357
  lineNumber: findLineNumber(sql, m.index)
908
1358
  });
909
1359
  }
1360
+ const alterTypeRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ALTER\s+(?:COLUMN\s+)?[\w"]+\s+TYPE\b/gi;
1361
+ while ((m = alterTypeRe.exec(sql)) !== null) {
1362
+ const table = bareTable(m[1]);
1363
+ issues.push({
1364
+ severity: "warning",
1365
+ code: "ALTER_COLUMN_TYPE",
1366
+ message: "ALTER COLUMN TYPE rewrites the entire table and acquires an exclusive lock.",
1367
+ suggestion: "Consider using a new column + backfill + rename strategy to avoid downtime.",
1368
+ lineNumber: findLineNumber(sql, m.index),
1369
+ tableName: table
1370
+ });
1371
+ }
1372
+ const dropColRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+DROP\s+(?:COLUMN\s+)(?:IF\s+EXISTS\s+)?[\w"]+\b/gi;
1373
+ while ((m = dropColRe.exec(sql)) !== null) {
1374
+ const table = bareTable(m[1]);
1375
+ issues.push({
1376
+ severity: "info",
1377
+ code: "DROP_COLUMN",
1378
+ message: "DROP COLUMN is safe in PostgreSQL (no table rewrite), but may break application code referencing that column.",
1379
+ suggestion: "Ensure no application code references this column before dropping it.",
1380
+ lineNumber: findLineNumber(sql, m.index),
1381
+ tableName: table
1382
+ });
1383
+ }
1384
+ const addConRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ADD\s+CONSTRAINT\b[^;]*(;|$)/gi;
1385
+ while ((m = addConRe.exec(sql)) !== null) {
1386
+ const fragment = m[0];
1387
+ const table = bareTable(m[1]);
1388
+ const fragUpper = fragment.toUpperCase();
1389
+ if (!/\bNOT\s+VALID\b/.test(fragUpper)) {
1390
+ issues.push({
1391
+ severity: "warning",
1392
+ code: "ADD_CONSTRAINT_SCANS_TABLE",
1393
+ message: "ADD CONSTRAINT validates all existing rows and holds an exclusive lock during the scan.",
1394
+ suggestion: "Use ADD CONSTRAINT ... NOT VALID to skip validation, then VALIDATE CONSTRAINT in a separate transaction.",
1395
+ lineNumber: findLineNumber(sql, m.index),
1396
+ tableName: table
1397
+ });
1398
+ }
1399
+ }
1400
+ const hasTransaction = /\bBEGIN\b/i.test(sql) || /\bSTART\s+TRANSACTION\b/i.test(sql);
1401
+ const hasConcurrently = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\b/i.test(sql);
1402
+ if (hasTransaction && hasConcurrently) {
1403
+ issues.push({
1404
+ severity: "error",
1405
+ code: "CONCURRENTLY_IN_TRANSACTION",
1406
+ message: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block. It will fail at runtime.",
1407
+ suggestion: "Remove the BEGIN/COMMIT wrapper, or use a migration tool that runs CONCURRENTLY outside transactions."
1408
+ });
1409
+ }
910
1410
  const truncRe = /\bTRUNCATE\b/gi;
911
1411
  while ((m = truncRe.exec(sql)) !== null) {
912
1412
  issues.push({
@@ -1160,6 +1660,8 @@ function countSchemaDrifts(schema) {
1160
1660
  for (const id of schema.indexDiffs) {
1161
1661
  n += id.missingIndexes.length + id.extraIndexes.length;
1162
1662
  }
1663
+ n += (schema.constraintDiffs ?? []).length;
1664
+ n += (schema.enumDiffs ?? []).length;
1163
1665
  return n;
1164
1666
  }
1165
1667
  async function diffEnvironments(sourceConn, targetConn, options) {
@@ -1172,22 +1674,46 @@ async function diffEnvironments(sourceConn, targetConn, options) {
1172
1674
  sourceCols,
1173
1675
  targetCols,
1174
1676
  sourceIdxs,
1175
- targetIdxs
1677
+ targetIdxs,
1678
+ sourceSnap,
1679
+ targetSnap
1176
1680
  ] = await Promise.all([
1177
1681
  fetchTables(sourcePool),
1178
1682
  fetchTables(targetPool),
1179
1683
  fetchColumns(sourcePool),
1180
1684
  fetchColumns(targetPool),
1181
1685
  fetchIndexes(sourcePool),
1182
- fetchIndexes(targetPool)
1686
+ fetchIndexes(targetPool),
1687
+ buildLiveSnapshot(sourcePool).catch(() => null),
1688
+ buildLiveSnapshot(targetPool).catch(() => null)
1183
1689
  ]);
1184
1690
  const { missingTables, extraTables } = diffTables(sourceTables, targetTables);
1185
- const sourceSet = new Set(sourceTables);
1186
1691
  const targetSet = new Set(targetTables);
1187
1692
  const commonTables = sourceTables.filter((t) => targetSet.has(t));
1188
1693
  const columnDiffs = diffColumns(sourceCols, targetCols, commonTables);
1189
1694
  const indexDiffs = diffIndexes(sourceIdxs, targetIdxs, commonTables);
1190
- const schema = { missingTables, extraTables, columnDiffs, indexDiffs };
1695
+ const constraintDiffs = [];
1696
+ const enumDiffs = [];
1697
+ if (sourceSnap && targetSnap) {
1698
+ const snapChanges = diffSnapshots(sourceSnap, targetSnap);
1699
+ for (const c of snapChanges) {
1700
+ if (c.object_type === "constraint") {
1701
+ constraintDiffs.push({
1702
+ table: c.table_name ?? null,
1703
+ type: c.change_type === "added" ? "extra" : c.change_type === "removed" ? "missing" : "modified",
1704
+ name: c.detail.split(" ")[1] ?? c.detail,
1705
+ detail: c.detail
1706
+ });
1707
+ } else if (c.object_type === "enum") {
1708
+ enumDiffs.push({
1709
+ type: c.change_type === "added" ? "extra" : c.change_type === "removed" ? "missing" : "modified",
1710
+ name: c.detail.split(" ")[1] ?? c.detail,
1711
+ detail: c.detail
1712
+ });
1713
+ }
1714
+ }
1715
+ }
1716
+ const schema = { missingTables, extraTables, columnDiffs, indexDiffs, constraintDiffs, enumDiffs };
1191
1717
  const schemaDrifts = countSchemaDrifts(schema);
1192
1718
  let health;
1193
1719
  if (options?.includeHealth) {
@@ -1287,7 +1813,44 @@ function formatTextDiff(result) {
1287
1813
  lines.push(` \u26A0 target has extra indexes:`);
1288
1814
  lines.push(...extraIdxs);
1289
1815
  }
1290
- if (schema.missingTables.length === 0 && schema.extraTables.length === 0 && schema.columnDiffs.length === 0 && schema.indexDiffs.length === 0) {
1816
+ const missingConstraints = (schema.constraintDiffs ?? []).filter((c) => c.type === "missing");
1817
+ const extraConstraints = (schema.constraintDiffs ?? []).filter((c) => c.type === "extra");
1818
+ const modifiedConstraints = (schema.constraintDiffs ?? []).filter((c) => c.type === "modified");
1819
+ if (missingConstraints.length > 0) {
1820
+ lines.push(` \u2717 target missing constraints:`);
1821
+ for (const c of missingConstraints) {
1822
+ lines.push(` ${c.table ? c.table + ": " : ""}${c.detail}`);
1823
+ }
1824
+ }
1825
+ if (extraConstraints.length > 0) {
1826
+ lines.push(` \u26A0 target has extra constraints:`);
1827
+ for (const c of extraConstraints) {
1828
+ lines.push(` ${c.table ? c.table + ": " : ""}${c.detail}`);
1829
+ }
1830
+ }
1831
+ if (modifiedConstraints.length > 0) {
1832
+ lines.push(` ~ constraint differences:`);
1833
+ for (const c of modifiedConstraints) {
1834
+ lines.push(` ${c.table ? c.table + ": " : ""}${c.detail}`);
1835
+ }
1836
+ }
1837
+ const missingEnums = (schema.enumDiffs ?? []).filter((e) => e.type === "missing");
1838
+ const extraEnums = (schema.enumDiffs ?? []).filter((e) => e.type === "extra");
1839
+ const modifiedEnums = (schema.enumDiffs ?? []).filter((e) => e.type === "modified");
1840
+ if (missingEnums.length > 0) {
1841
+ lines.push(` \u2717 target missing enums:`);
1842
+ for (const e of missingEnums) lines.push(` ${e.detail}`);
1843
+ }
1844
+ if (extraEnums.length > 0) {
1845
+ lines.push(` \u26A0 target has extra enums:`);
1846
+ for (const e of extraEnums) lines.push(` ${e.detail}`);
1847
+ }
1848
+ if (modifiedEnums.length > 0) {
1849
+ lines.push(` ~ enum differences:`);
1850
+ for (const e of modifiedEnums) lines.push(` ${e.detail}`);
1851
+ }
1852
+ const noSchemaChanges = schema.missingTables.length === 0 && schema.extraTables.length === 0 && schema.columnDiffs.length === 0 && schema.indexDiffs.length === 0 && (schema.constraintDiffs ?? []).length === 0 && (schema.enumDiffs ?? []).length === 0;
1853
+ if (noSchemaChanges) {
1291
1854
  lines.push(` \u2713 Schemas are identical`);
1292
1855
  }
1293
1856
  if (result.health) {
@@ -1345,6 +1908,18 @@ function formatMdDiff(result) {
1345
1908
  }
1346
1909
  if (missingIdxItems.length > 0) rows.push([`\u274C Missing indexes`, missingIdxItems.join(", ")]);
1347
1910
  if (extraIdxItems.length > 0) rows.push([`\u26A0\uFE0F Extra indexes`, extraIdxItems.join(", ")]);
1911
+ const missingConItems = (schema.constraintDiffs ?? []).filter((c) => c.type === "missing").map((c) => c.detail);
1912
+ const extraConItems = (schema.constraintDiffs ?? []).filter((c) => c.type === "extra").map((c) => c.detail);
1913
+ const modConItems = (schema.constraintDiffs ?? []).filter((c) => c.type === "modified").map((c) => c.detail);
1914
+ if (missingConItems.length > 0) rows.push([`\u274C Missing constraints`, missingConItems.join("; ")]);
1915
+ if (extraConItems.length > 0) rows.push([`\u26A0\uFE0F Extra constraints`, extraConItems.join("; ")]);
1916
+ if (modConItems.length > 0) rows.push([`~ Modified constraints`, modConItems.join("; ")]);
1917
+ const missingEnumItems = (schema.enumDiffs ?? []).filter((e) => e.type === "missing").map((e) => e.detail);
1918
+ const extraEnumItems = (schema.enumDiffs ?? []).filter((e) => e.type === "extra").map((e) => e.detail);
1919
+ const modEnumItems = (schema.enumDiffs ?? []).filter((e) => e.type === "modified").map((e) => e.detail);
1920
+ if (missingEnumItems.length > 0) rows.push([`\u274C Missing enums`, missingEnumItems.join("; ")]);
1921
+ if (extraEnumItems.length > 0) rows.push([`\u26A0\uFE0F Extra enums`, extraEnumItems.join("; ")]);
1922
+ if (modEnumItems.length > 0) rows.push([`~ Modified enums`, modEnumItems.join("; ")]);
1348
1923
  if (rows.length > 0) {
1349
1924
  lines.push(`| Type | Details |`);
1350
1925
  lines.push(`|------|---------|`);
@@ -1358,814 +1933,390 @@ function formatMdDiff(result) {
1358
1933
  const h = result.health;
1359
1934
  lines.push(``);
1360
1935
  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();
1386
- }
1387
- });
1388
-
1389
- // src/cli.ts
1390
- import { parseArgs } from "util";
1391
-
1392
- // src/server/index.ts
1393
- import { Hono } from "hono";
1394
- import path3 from "path";
1395
- import fs3 from "fs";
1396
- import os3 from "os";
1397
- import { fileURLToPath } from "url";
1398
- import { Pool } from "pg";
1399
-
1400
- // src/server/queries/overview.ts
1401
- async function getOverview(pool) {
1402
- const client = await pool.connect();
1403
- try {
1404
- const version = await client.query("SHOW server_version");
1405
- const uptime = await client.query(
1406
- `SELECT to_char(now() - pg_postmaster_start_time(), 'DD "d" HH24 "h" MI "m"') AS uptime`
1407
- );
1408
- const dbSize = await client.query(
1409
- "SELECT pg_size_pretty(pg_database_size(current_database())) AS size"
1410
- );
1411
- const dbCount = await client.query(
1412
- "SELECT count(*)::int AS count FROM pg_database WHERE NOT datistemplate"
1413
- );
1414
- const connections = await client.query(`
1415
- SELECT
1416
- (SELECT count(*)::int FROM pg_stat_activity WHERE state = 'active') AS active,
1417
- (SELECT count(*)::int FROM pg_stat_activity WHERE state = 'idle') AS idle,
1418
- (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max
1419
- `);
1420
- return {
1421
- version: version.rows[0].server_version,
1422
- uptime: uptime.rows[0].uptime,
1423
- dbSize: dbSize.rows[0].size,
1424
- databaseCount: dbCount.rows[0].count,
1425
- connections: connections.rows[0]
1426
- };
1427
- } finally {
1428
- client.release();
1429
- }
1430
- }
1431
-
1432
- // src/server/queries/databases.ts
1433
- async function getDatabases(pool) {
1434
- const client = await pool.connect();
1435
- try {
1436
- const r = await client.query(`
1437
- SELECT datname AS name,
1438
- pg_size_pretty(pg_database_size(datname)) AS size,
1439
- pg_database_size(datname) AS size_bytes
1440
- FROM pg_database
1441
- WHERE NOT datistemplate
1442
- ORDER BY pg_database_size(datname) DESC
1443
- `);
1444
- return r.rows;
1445
- } finally {
1446
- client.release();
1447
- }
1448
- }
1449
-
1450
- // src/server/queries/tables.ts
1451
- async function getTables(pool) {
1452
- const client = await pool.connect();
1453
- try {
1454
- const r = await client.query(`
1455
- SELECT
1456
- schemaname AS schema,
1457
- relname AS name,
1458
- pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
1459
- pg_total_relation_size(relid) AS size_bytes,
1460
- n_live_tup AS rows,
1461
- n_dead_tup AS dead_tuples,
1462
- CASE WHEN n_live_tup > 0
1463
- THEN round(n_dead_tup::numeric / n_live_tup * 100, 1)
1464
- ELSE 0 END AS dead_pct
1465
- FROM pg_stat_user_tables
1466
- ORDER BY pg_total_relation_size(relid) DESC
1467
- `);
1468
- return r.rows;
1469
- } finally {
1470
- client.release();
1471
- }
1472
- }
1473
-
1474
- // src/server/queries/activity.ts
1475
- async function getActivity(pool) {
1476
- const client = await pool.connect();
1477
- try {
1478
- const r = await client.query(`
1479
- SELECT
1480
- pid,
1481
- COALESCE(query, '') AS query,
1482
- COALESCE(state, 'unknown') AS state,
1483
- wait_event,
1484
- wait_event_type,
1485
- CASE WHEN state = 'active' THEN (now() - query_start)::text
1486
- WHEN state = 'idle in transaction' THEN (now() - state_change)::text
1487
- ELSE NULL END AS duration,
1488
- client_addr::text,
1489
- COALESCE(application_name, '') AS application_name,
1490
- backend_start::text
1491
- FROM pg_stat_activity
1492
- WHERE pid != pg_backend_pid()
1493
- AND state IS NOT NULL
1494
- ORDER BY
1495
- CASE state
1496
- WHEN 'active' THEN 1
1497
- WHEN 'idle in transaction' THEN 2
1498
- ELSE 3
1499
- END,
1500
- query_start ASC NULLS LAST
1501
- `);
1502
- return r.rows;
1503
- } finally {
1504
- client.release();
1505
- }
1506
- }
1507
-
1508
- // src/server/index.ts
1509
- init_advisor();
1510
-
1511
- // src/server/timeseries.ts
1512
- import Database2 from "better-sqlite3";
1513
- import path2 from "path";
1514
- import os2 from "os";
1515
- import fs2 from "fs";
1516
- var DEFAULT_DIR = path2.join(os2.homedir(), ".pg-dash");
1517
- var DEFAULT_RETENTION_DAYS = 7;
1518
- var TimeseriesStore = class {
1519
- db;
1520
- insertStmt;
1521
- retentionMs;
1522
- constructor(dbOrDir, retentionDays = DEFAULT_RETENTION_DAYS) {
1523
- if (dbOrDir instanceof Database2) {
1524
- this.db = dbOrDir;
1525
- } else {
1526
- const dir = dbOrDir || DEFAULT_DIR;
1527
- fs2.mkdirSync(dir, { recursive: true });
1528
- const dbPath = path2.join(dir, "metrics.db");
1529
- this.db = new Database2(dbPath);
1530
- }
1531
- this.retentionMs = retentionDays * 24 * 60 * 60 * 1e3;
1532
- this.db.pragma("journal_mode = WAL");
1533
- this.db.exec(`
1534
- CREATE TABLE IF NOT EXISTS metrics (
1535
- timestamp INTEGER NOT NULL,
1536
- metric TEXT NOT NULL,
1537
- value REAL NOT NULL
1538
- );
1539
- CREATE INDEX IF NOT EXISTS idx_metrics_metric_ts ON metrics(metric, timestamp);
1540
- `);
1541
- this.insertStmt = this.db.prepare(
1542
- "INSERT INTO metrics (timestamp, metric, value) VALUES (?, ?, ?)"
1543
- );
1544
- }
1545
- insert(metric, value, timestamp) {
1546
- this.insertStmt.run(timestamp ?? Date.now(), metric, value);
1547
- }
1548
- insertMany(points) {
1549
- const tx = this.db.transaction((pts) => {
1550
- for (const p of pts) {
1551
- this.insertStmt.run(p.timestamp, p.metric, p.value);
1552
- }
1553
- });
1554
- tx(points);
1555
- }
1556
- query(metric, startMs, endMs) {
1557
- const end = endMs ?? Date.now();
1558
- return this.db.prepare(
1559
- "SELECT timestamp, value FROM metrics WHERE metric = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp"
1560
- ).all(metric, startMs, end);
1561
- }
1562
- latest(metrics) {
1563
- const result = {};
1564
- if (metrics && metrics.length > 0) {
1565
- const placeholders = metrics.map(() => "?").join(",");
1566
- const rows = this.db.prepare(
1567
- `SELECT m.metric, m.timestamp, m.value FROM metrics m INNER JOIN (SELECT metric, MAX(timestamp) as max_ts FROM metrics WHERE metric IN (${placeholders}) GROUP BY metric) g ON m.metric = g.metric AND m.timestamp = g.max_ts`
1568
- ).all(...metrics);
1569
- for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
1570
- } else {
1571
- const rows = this.db.prepare(
1572
- "SELECT m.metric, m.timestamp, m.value FROM metrics m INNER JOIN (SELECT metric, MAX(timestamp) as max_ts FROM metrics GROUP BY metric) g ON m.metric = g.metric AND m.timestamp = g.max_ts"
1573
- ).all();
1574
- for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
1575
- }
1576
- return result;
1577
- }
1578
- prune() {
1579
- const cutoff = Date.now() - this.retentionMs;
1580
- const info = this.db.prepare("DELETE FROM metrics WHERE timestamp < ?").run(cutoff);
1581
- return info.changes;
1582
- }
1583
- close() {
1584
- this.db.close();
1585
- }
1586
- };
1587
-
1588
- // src/server/collector.ts
1589
- import { EventEmitter } from "events";
1590
- var Collector = class extends EventEmitter {
1591
- constructor(pool, store, intervalMs = 3e4) {
1592
- super();
1593
- this.pool = pool;
1594
- this.store = store;
1595
- this.intervalMs = intervalMs;
1596
- }
1597
- timer = null;
1598
- pruneTimer = null;
1599
- prev = null;
1600
- lastSnapshot = {};
1601
- collectCount = 0;
1602
- start() {
1603
- this.collect().catch((err) => console.error("[collector] Initial collection failed:", err));
1604
- this.timer = setInterval(() => {
1605
- this.collect().catch((err) => console.error("[collector] Collection failed:", err));
1606
- }, this.intervalMs);
1607
- this.pruneTimer = setInterval(() => this.store.prune(), 60 * 60 * 1e3);
1608
- }
1609
- stop() {
1610
- if (this.timer) {
1611
- clearInterval(this.timer);
1612
- this.timer = null;
1613
- }
1614
- if (this.pruneTimer) {
1615
- clearInterval(this.pruneTimer);
1616
- this.pruneTimer = null;
1617
- }
1618
- }
1619
- getLastSnapshot() {
1620
- return { ...this.lastSnapshot };
1621
- }
1622
- async collect() {
1623
- const now = Date.now();
1624
- const snapshot = {};
1625
- try {
1626
- const client = await this.pool.connect();
1627
- try {
1628
- const connRes = await client.query(`
1629
- SELECT
1630
- count(*) FILTER (WHERE state = 'active')::int AS active,
1631
- count(*) FILTER (WHERE state = 'idle')::int AS idle,
1632
- count(*)::int AS total
1633
- FROM pg_stat_activity
1634
- `);
1635
- const conn = connRes.rows[0];
1636
- snapshot.connections_active = conn.active;
1637
- snapshot.connections_idle = conn.idle;
1638
- snapshot.connections_total = conn.total;
1639
- const dbRes = await client.query(`
1640
- SELECT
1641
- xact_commit, xact_rollback, deadlocks, temp_bytes,
1642
- tup_inserted, tup_updated, tup_deleted,
1643
- CASE WHEN (blks_hit + blks_read) = 0 THEN 1
1644
- ELSE blks_hit::float / (blks_hit + blks_read) END AS cache_ratio,
1645
- pg_database_size(current_database()) AS db_size
1646
- FROM pg_stat_database WHERE datname = current_database()
1647
- `);
1648
- const db = dbRes.rows[0];
1649
- if (db) {
1650
- snapshot.cache_hit_ratio = parseFloat(db.cache_ratio);
1651
- snapshot.db_size_bytes = parseInt(db.db_size);
1652
- const cur = {
1653
- timestamp: now,
1654
- xact_commit: parseInt(db.xact_commit),
1655
- xact_rollback: parseInt(db.xact_rollback),
1656
- deadlocks: parseInt(db.deadlocks),
1657
- temp_bytes: parseInt(db.temp_bytes),
1658
- tup_inserted: parseInt(db.tup_inserted),
1659
- tup_updated: parseInt(db.tup_updated),
1660
- tup_deleted: parseInt(db.tup_deleted)
1661
- };
1662
- if (this.prev) {
1663
- const dtSec = (now - this.prev.timestamp) / 1e3;
1664
- if (dtSec > 0) {
1665
- snapshot.tps_commit = Math.max(0, (cur.xact_commit - this.prev.xact_commit) / dtSec);
1666
- snapshot.tps_rollback = Math.max(0, (cur.xact_rollback - this.prev.xact_rollback) / dtSec);
1667
- snapshot.deadlocks = Math.max(0, cur.deadlocks - this.prev.deadlocks);
1668
- snapshot.temp_bytes = Math.max(0, cur.temp_bytes - this.prev.temp_bytes);
1669
- snapshot.tuple_inserted = Math.max(0, (cur.tup_inserted - this.prev.tup_inserted) / dtSec);
1670
- snapshot.tuple_updated = Math.max(0, (cur.tup_updated - this.prev.tup_updated) / dtSec);
1671
- snapshot.tuple_deleted = Math.max(0, (cur.tup_deleted - this.prev.tup_deleted) / dtSec);
1672
- }
1673
- }
1674
- this.prev = cur;
1675
- }
1676
- try {
1677
- const tsRes = await client.query(`SELECT spcname, pg_tablespace_size(oid) AS size FROM pg_tablespace`);
1678
- let totalTablespaceSize = 0;
1679
- for (const row of tsRes.rows) {
1680
- totalTablespaceSize += parseInt(row.size);
1681
- }
1682
- if (totalTablespaceSize > 0) {
1683
- snapshot.disk_used_bytes = totalTablespaceSize;
1684
- }
1685
- } catch {
1686
- }
1687
- try {
1688
- const repRes = await client.query(`
1689
- SELECT CASE WHEN pg_is_in_recovery()
1690
- THEN pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())
1691
- ELSE 0 END AS lag_bytes
1692
- `);
1693
- snapshot.replication_lag_bytes = parseInt(repRes.rows[0]?.lag_bytes ?? "0");
1694
- } catch {
1695
- snapshot.replication_lag_bytes = 0;
1696
- }
1697
- } finally {
1698
- client.release();
1699
- }
1700
- } catch (err) {
1701
- console.error("[collector] Error collecting metrics:", err.message);
1702
- return snapshot;
1703
- }
1704
- this.collectCount++;
1705
- if (this.collectCount % 10 === 0) {
1706
- try {
1707
- const client = await this.pool.connect();
1708
- try {
1709
- const tableRes = await client.query(`
1710
- SELECT schemaname, relname,
1711
- pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as total_size
1712
- FROM pg_stat_user_tables
1713
- ORDER BY pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) DESC
1714
- LIMIT 20
1715
- `);
1716
- for (const row of tableRes.rows) {
1717
- this.store.insert(`table_size:${row.schemaname}.${row.relname}`, parseInt(row.total_size), now);
1718
- }
1719
- } finally {
1720
- client.release();
1721
- }
1722
- } catch (err) {
1723
- console.error("[collector] Error collecting table sizes:", err.message);
1724
- }
1725
- }
1726
- const points = Object.entries(snapshot).map(([metric, value]) => ({
1727
- timestamp: now,
1728
- metric,
1729
- value
1730
- }));
1731
- if (points.length > 0) {
1732
- this.store.insertMany(points);
1733
- }
1734
- this.lastSnapshot = snapshot;
1735
- this.emit("collected", snapshot);
1736
- return snapshot;
1737
- }
1738
- };
1739
-
1740
- // src/server/queries/schema.ts
1741
- async function getSchemaTables(pool) {
1742
- const client = await pool.connect();
1743
- try {
1744
- const r = await client.query(`
1745
- SELECT
1746
- c.relname AS name,
1747
- n.nspname AS schema,
1748
- pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
1749
- pg_total_relation_size(c.oid) AS total_size_bytes,
1750
- pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
1751
- pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) AS index_size,
1752
- s.n_live_tup AS row_count,
1753
- obj_description(c.oid) AS description
1754
- FROM pg_class c
1755
- JOIN pg_namespace n ON c.relnamespace = n.oid
1756
- LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
1757
- WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
1758
- ORDER BY pg_total_relation_size(c.oid) DESC
1759
- `);
1760
- return r.rows;
1761
- } finally {
1762
- client.release();
1763
- }
1764
- }
1765
- async function getSchemaTableDetail(pool, tableName) {
1766
- const client = await pool.connect();
1767
- try {
1768
- const parts = tableName.split(".");
1769
- const schema = parts.length > 1 ? parts[0] : "public";
1770
- const name = parts.length > 1 ? parts[1] : parts[0];
1771
- const tableInfo = await client.query(`
1772
- SELECT
1773
- c.relname AS name, n.nspname AS schema,
1774
- pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
1775
- pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
1776
- pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) AS index_size,
1777
- pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_size,
1778
- s.n_live_tup AS row_count, s.n_dead_tup AS dead_tuples,
1779
- s.last_vacuum, s.last_autovacuum, s.last_analyze, s.last_autoanalyze,
1780
- s.seq_scan, s.idx_scan
1781
- FROM pg_class c
1782
- JOIN pg_namespace n ON c.relnamespace = n.oid
1783
- LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
1784
- WHERE c.relname = $1 AND n.nspname = $2 AND c.relkind = 'r'
1785
- `, [name, schema]);
1786
- if (tableInfo.rows.length === 0) return null;
1787
- const columns = await client.query(`
1788
- SELECT
1789
- a.attname AS name,
1790
- pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
1791
- NOT a.attnotnull AS nullable,
1792
- pg_get_expr(d.adbin, d.adrelid) AS default_value,
1793
- col_description(a.attrelid, a.attnum) AS description
1794
- FROM pg_attribute a
1795
- LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
1796
- WHERE a.attrelid = (SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid WHERE c.relname = $1 AND n.nspname = $2)
1797
- AND a.attnum > 0 AND NOT a.attisdropped
1798
- ORDER BY a.attnum
1799
- `, [name, schema]);
1800
- const indexes = await client.query(`
1801
- SELECT
1802
- i.relname AS name,
1803
- am.amname AS type,
1804
- pg_size_pretty(pg_relation_size(i.oid)) AS size,
1805
- pg_get_indexdef(idx.indexrelid) AS definition,
1806
- idx.indisunique AS is_unique,
1807
- idx.indisprimary AS is_primary,
1808
- s.idx_scan, s.idx_tup_read, s.idx_tup_fetch
1809
- FROM pg_index idx
1810
- JOIN pg_class i ON idx.indexrelid = i.oid
1811
- JOIN pg_class t ON idx.indrelid = t.oid
1812
- JOIN pg_namespace n ON t.relnamespace = n.oid
1813
- JOIN pg_am am ON i.relam = am.oid
1814
- LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
1815
- WHERE t.relname = $1 AND n.nspname = $2
1816
- ORDER BY i.relname
1817
- `, [name, schema]);
1818
- const constraints = await client.query(`
1819
- SELECT
1820
- conname AS name,
1821
- CASE contype WHEN 'p' THEN 'PRIMARY KEY' WHEN 'f' THEN 'FOREIGN KEY'
1822
- WHEN 'u' THEN 'UNIQUE' WHEN 'c' THEN 'CHECK' WHEN 'x' THEN 'EXCLUDE' END AS type,
1823
- pg_get_constraintdef(oid) AS definition
1824
- FROM pg_constraint
1825
- WHERE conrelid = (SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid WHERE c.relname = $1 AND n.nspname = $2)
1826
- ORDER BY
1827
- CASE contype WHEN 'p' THEN 1 WHEN 'u' THEN 2 WHEN 'f' THEN 3 WHEN 'c' THEN 4 ELSE 5 END
1828
- `, [name, schema]);
1829
- const foreignKeys = await client.query(`
1830
- SELECT
1831
- conname AS name,
1832
- a.attname AS column_name,
1833
- confrelid::regclass::text AS referenced_table,
1834
- af.attname AS referenced_column
1835
- FROM pg_constraint c
1836
- JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
1837
- JOIN pg_attribute af ON af.attrelid = c.confrelid AND af.attnum = ANY(c.confkey)
1838
- WHERE c.contype = 'f'
1839
- AND c.conrelid = (SELECT cl.oid FROM pg_class cl JOIN pg_namespace n ON cl.relnamespace = n.oid WHERE cl.relname = $1 AND n.nspname = $2)
1840
- `, [name, schema]);
1841
- let sampleData = [];
1842
- try {
1843
- const sample = await client.query(
1844
- `SELECT * FROM ${client.escapeIdentifier(schema)}.${client.escapeIdentifier(name)} LIMIT 10`
1845
- );
1846
- sampleData = sample.rows;
1847
- } catch (err) {
1848
- console.error("[schema] Error:", err.message);
1936
+ lines.push(``);
1937
+ lines.push(`| | Score | Grade |`);
1938
+ lines.push(`|--|-------|-------|`);
1939
+ lines.push(`| Source | ${h.source.score}/100 | ${h.source.grade} |`);
1940
+ lines.push(`| Target | ${h.target.score}/100 | ${h.target.grade} |`);
1941
+ if (h.targetOnlyIssues.length > 0) {
1942
+ lines.push(``);
1943
+ lines.push(`**Target-only issues:**`);
1944
+ for (const iss of h.targetOnlyIssues) lines.push(`- ${iss}`);
1945
+ }
1946
+ if (h.sourceOnlyIssues.length > 0) {
1947
+ lines.push(``);
1948
+ lines.push(`**Source-only issues:**`);
1949
+ for (const iss of h.sourceOnlyIssues) lines.push(`- ${iss}`);
1849
1950
  }
1850
- return {
1851
- ...tableInfo.rows[0],
1852
- columns: columns.rows,
1853
- indexes: indexes.rows,
1854
- constraints: constraints.rows,
1855
- foreignKeys: foreignKeys.rows,
1856
- sampleData
1857
- };
1858
- } finally {
1859
- client.release();
1860
1951
  }
1952
+ lines.push(``);
1953
+ const { schemaDrifts, identical } = result.summary;
1954
+ lines.push(`**Result: ${schemaDrifts} drift${schemaDrifts !== 1 ? "s" : ""} \u2014 environments are ${identical ? "in sync \u2713" : "NOT in sync"}**`);
1955
+ return lines.join("\n");
1861
1956
  }
1862
- async function getSchemaIndexes(pool) {
1957
+ var init_env_differ = __esm({
1958
+ "src/server/env-differ.ts"() {
1959
+ "use strict";
1960
+ init_advisor();
1961
+ init_schema_tracker();
1962
+ init_schema_diff();
1963
+ }
1964
+ });
1965
+
1966
+ // src/cli.ts
1967
+ import { parseArgs } from "util";
1968
+
1969
+ // src/server/index.ts
1970
+ import { Hono } from "hono";
1971
+ import path3 from "path";
1972
+ import fs3 from "fs";
1973
+ import os3 from "os";
1974
+ import { fileURLToPath } from "url";
1975
+ import { Pool } from "pg";
1976
+
1977
+ // src/server/queries/overview.ts
1978
+ async function getOverview(pool) {
1863
1979
  const client = await pool.connect();
1864
1980
  try {
1865
- const r = await client.query(`
1981
+ const version = await client.query("SHOW server_version");
1982
+ const uptime = await client.query(
1983
+ `SELECT to_char(now() - pg_postmaster_start_time(), 'DD "d" HH24 "h" MI "m"') AS uptime`
1984
+ );
1985
+ const dbSize = await client.query(
1986
+ "SELECT pg_size_pretty(pg_database_size(current_database())) AS size"
1987
+ );
1988
+ const dbCount = await client.query(
1989
+ "SELECT count(*)::int AS count FROM pg_database WHERE NOT datistemplate"
1990
+ );
1991
+ const connections = await client.query(`
1866
1992
  SELECT
1867
- n.nspname AS schema,
1868
- t.relname AS table_name,
1869
- i.relname AS name,
1870
- am.amname AS type,
1871
- pg_size_pretty(pg_relation_size(i.oid)) AS size,
1872
- pg_relation_size(i.oid) AS size_bytes,
1873
- pg_get_indexdef(idx.indexrelid) AS definition,
1874
- idx.indisunique AS is_unique,
1875
- idx.indisprimary AS is_primary,
1876
- s.idx_scan, s.idx_tup_read, s.idx_tup_fetch
1877
- FROM pg_index idx
1878
- JOIN pg_class i ON idx.indexrelid = i.oid
1879
- JOIN pg_class t ON idx.indrelid = t.oid
1880
- JOIN pg_namespace n ON t.relnamespace = n.oid
1881
- JOIN pg_am am ON i.relam = am.oid
1882
- LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
1883
- WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
1884
- ORDER BY pg_relation_size(i.oid) DESC
1993
+ (SELECT count(*)::int FROM pg_stat_activity WHERE state = 'active') AS active,
1994
+ (SELECT count(*)::int FROM pg_stat_activity WHERE state = 'idle') AS idle,
1995
+ (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max
1885
1996
  `);
1886
- return r.rows;
1997
+ return {
1998
+ version: version.rows[0].server_version,
1999
+ uptime: uptime.rows[0].uptime,
2000
+ dbSize: dbSize.rows[0].size,
2001
+ databaseCount: dbCount.rows[0].count,
2002
+ connections: connections.rows[0]
2003
+ };
1887
2004
  } finally {
1888
2005
  client.release();
1889
2006
  }
1890
2007
  }
1891
- async function getSchemaFunctions(pool) {
2008
+
2009
+ // src/server/queries/databases.ts
2010
+ async function getDatabases(pool) {
1892
2011
  const client = await pool.connect();
1893
2012
  try {
1894
2013
  const r = await client.query(`
1895
- SELECT
1896
- n.nspname AS schema,
1897
- p.proname AS name,
1898
- pg_get_function_result(p.oid) AS return_type,
1899
- pg_get_function_arguments(p.oid) AS arguments,
1900
- l.lanname AS language,
1901
- p.prosrc AS source,
1902
- CASE p.prokind WHEN 'f' THEN 'function' WHEN 'p' THEN 'procedure' WHEN 'a' THEN 'aggregate' WHEN 'w' THEN 'window' END AS kind
1903
- FROM pg_proc p
1904
- JOIN pg_namespace n ON p.pronamespace = n.oid
1905
- JOIN pg_language l ON p.prolang = l.oid
1906
- WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
1907
- ORDER BY n.nspname, p.proname
2014
+ SELECT datname AS name,
2015
+ pg_size_pretty(pg_database_size(datname)) AS size,
2016
+ pg_database_size(datname) AS size_bytes
2017
+ FROM pg_database
2018
+ WHERE NOT datistemplate
2019
+ ORDER BY pg_database_size(datname) DESC
1908
2020
  `);
1909
2021
  return r.rows;
1910
2022
  } finally {
1911
2023
  client.release();
1912
2024
  }
1913
2025
  }
1914
- async function getSchemaExtensions(pool) {
2026
+
2027
+ // src/server/queries/tables.ts
2028
+ async function getTables(pool) {
1915
2029
  const client = await pool.connect();
1916
2030
  try {
1917
2031
  const r = await client.query(`
1918
- SELECT extname AS name, extversion AS installed_version,
1919
- n.nspname AS schema, obj_description(e.oid) AS description
1920
- FROM pg_extension e
1921
- JOIN pg_namespace n ON e.extnamespace = n.oid
1922
- ORDER BY extname
2032
+ SELECT
2033
+ schemaname AS schema,
2034
+ relname AS name,
2035
+ pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
2036
+ pg_total_relation_size(relid) AS size_bytes,
2037
+ n_live_tup AS rows,
2038
+ n_dead_tup AS dead_tuples,
2039
+ CASE WHEN n_live_tup > 0
2040
+ THEN round(n_dead_tup::numeric / n_live_tup * 100, 1)
2041
+ ELSE 0 END AS dead_pct
2042
+ FROM pg_stat_user_tables
2043
+ ORDER BY pg_total_relation_size(relid) DESC
1923
2044
  `);
1924
2045
  return r.rows;
1925
2046
  } finally {
1926
2047
  client.release();
1927
2048
  }
1928
2049
  }
1929
- async function getSchemaEnums(pool) {
2050
+
2051
+ // src/server/queries/activity.ts
2052
+ async function getActivity(pool) {
1930
2053
  const client = await pool.connect();
1931
2054
  try {
1932
2055
  const r = await client.query(`
1933
2056
  SELECT
1934
- t.typname AS name,
1935
- n.nspname AS schema,
1936
- array_agg(e.enumlabel ORDER BY e.enumsortorder) AS values
1937
- FROM pg_type t
1938
- JOIN pg_namespace n ON t.typnamespace = n.oid
1939
- JOIN pg_enum e ON t.oid = e.enumtypid
1940
- WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
1941
- GROUP BY t.typname, n.nspname
1942
- ORDER BY t.typname
1943
- `);
1944
- return r.rows;
1945
- } finally {
1946
- client.release();
1947
- }
1948
- }
1949
-
1950
- // src/server/schema-diff.ts
1951
- function diffSnapshots(oldSnap, newSnap) {
1952
- const changes = [];
1953
- const oldTableMap = new Map(oldSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
1954
- const newTableMap = new Map(newSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
1955
- for (const [key, t] of newTableMap) {
1956
- if (!oldTableMap.has(key)) {
1957
- changes.push({ change_type: "added", object_type: "table", table_name: key, detail: `Table ${key} added` });
1958
- }
1959
- }
1960
- for (const [key] of oldTableMap) {
1961
- if (!newTableMap.has(key)) {
1962
- changes.push({ change_type: "removed", object_type: "table", table_name: key, detail: `Table ${key} removed` });
1963
- }
1964
- }
1965
- for (const [key, newTable] of newTableMap) {
1966
- const oldTable = oldTableMap.get(key);
1967
- if (!oldTable) continue;
1968
- const oldCols = new Map(oldTable.columns.map((c) => [c.name, c]));
1969
- const newCols = new Map(newTable.columns.map((c) => [c.name, c]));
1970
- for (const [name, col] of newCols) {
1971
- const oldCol = oldCols.get(name);
1972
- if (!oldCol) {
1973
- changes.push({ change_type: "added", object_type: "column", table_name: key, detail: `Column ${name} added (${col.type})` });
1974
- } else {
1975
- if (oldCol.type !== col.type) {
1976
- changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} type changed: ${oldCol.type} \u2192 ${col.type}` });
1977
- }
1978
- if (oldCol.nullable !== col.nullable) {
1979
- changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} nullable changed: ${oldCol.nullable} \u2192 ${col.nullable}` });
1980
- }
1981
- if (oldCol.default_value !== col.default_value) {
1982
- changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} default changed: ${oldCol.default_value ?? "NULL"} \u2192 ${col.default_value ?? "NULL"}` });
1983
- }
1984
- }
1985
- }
1986
- for (const name of oldCols.keys()) {
1987
- if (!newCols.has(name)) {
1988
- changes.push({ change_type: "removed", object_type: "column", table_name: key, detail: `Column ${name} removed` });
1989
- }
1990
- }
1991
- const oldIdx = new Map(oldTable.indexes.map((i) => [i.name, i]));
1992
- const newIdx = new Map(newTable.indexes.map((i) => [i.name, i]));
1993
- for (const [name, idx] of newIdx) {
1994
- if (!oldIdx.has(name)) {
1995
- changes.push({ change_type: "added", object_type: "index", table_name: key, detail: `Index ${name} added` });
1996
- } else if (oldIdx.get(name).definition !== idx.definition) {
1997
- changes.push({ change_type: "modified", object_type: "index", table_name: key, detail: `Index ${name} definition changed` });
1998
- }
1999
- }
2000
- for (const name of oldIdx.keys()) {
2001
- if (!newIdx.has(name)) {
2002
- changes.push({ change_type: "removed", object_type: "index", table_name: key, detail: `Index ${name} removed` });
2003
- }
2004
- }
2005
- const oldCon = new Map(oldTable.constraints.map((c) => [c.name, c]));
2006
- const newCon = new Map(newTable.constraints.map((c) => [c.name, c]));
2007
- for (const [name, con] of newCon) {
2008
- if (!oldCon.has(name)) {
2009
- changes.push({ change_type: "added", object_type: "constraint", table_name: key, detail: `Constraint ${name} added (${con.type})` });
2010
- } else if (oldCon.get(name).definition !== con.definition) {
2011
- changes.push({ change_type: "modified", object_type: "constraint", table_name: key, detail: `Constraint ${name} definition changed` });
2012
- }
2013
- }
2014
- for (const name of oldCon.keys()) {
2015
- if (!newCon.has(name)) {
2016
- changes.push({ change_type: "removed", object_type: "constraint", table_name: key, detail: `Constraint ${name} removed` });
2017
- }
2018
- }
2019
- }
2020
- const oldEnums = new Map((oldSnap.enums || []).map((e) => [`${e.schema}.${e.name}`, e]));
2021
- const newEnums = new Map((newSnap.enums || []).map((e) => [`${e.schema}.${e.name}`, e]));
2022
- for (const [key, en] of newEnums) {
2023
- const oldEn = oldEnums.get(key);
2024
- if (!oldEn) {
2025
- changes.push({ change_type: "added", object_type: "enum", table_name: null, detail: `Enum ${key} added (${en.values.join(", ")})` });
2026
- } else {
2027
- const added = en.values.filter((v) => !oldEn.values.includes(v));
2028
- const removed = oldEn.values.filter((v) => !en.values.includes(v));
2029
- for (const v of added) {
2030
- changes.push({ change_type: "modified", object_type: "enum", table_name: null, detail: `Enum ${key}: value '${v}' added` });
2031
- }
2032
- for (const v of removed) {
2033
- changes.push({ change_type: "modified", object_type: "enum", table_name: null, detail: `Enum ${key}: value '${v}' removed` });
2034
- }
2035
- }
2036
- }
2037
- for (const key of oldEnums.keys()) {
2038
- if (!newEnums.has(key)) {
2039
- changes.push({ change_type: "removed", object_type: "enum", table_name: null, detail: `Enum ${key} removed` });
2040
- }
2057
+ pid,
2058
+ COALESCE(query, '') AS query,
2059
+ COALESCE(state, 'unknown') AS state,
2060
+ wait_event,
2061
+ wait_event_type,
2062
+ CASE WHEN state = 'active' THEN (now() - query_start)::text
2063
+ WHEN state = 'idle in transaction' THEN (now() - state_change)::text
2064
+ ELSE NULL END AS duration,
2065
+ client_addr::text,
2066
+ COALESCE(application_name, '') AS application_name,
2067
+ backend_start::text
2068
+ FROM pg_stat_activity
2069
+ WHERE pid != pg_backend_pid()
2070
+ AND state IS NOT NULL
2071
+ ORDER BY
2072
+ CASE state
2073
+ WHEN 'active' THEN 1
2074
+ WHEN 'idle in transaction' THEN 2
2075
+ ELSE 3
2076
+ END,
2077
+ query_start ASC NULLS LAST
2078
+ `);
2079
+ return r.rows;
2080
+ } finally {
2081
+ client.release();
2041
2082
  }
2042
- return changes;
2043
2083
  }
2044
2084
 
2045
- // src/server/schema-tracker.ts
2046
- var SchemaTracker = class {
2085
+ // src/server/index.ts
2086
+ init_advisor();
2087
+
2088
+ // src/server/timeseries.ts
2089
+ import Database2 from "better-sqlite3";
2090
+ import path2 from "path";
2091
+ import os2 from "os";
2092
+ import fs2 from "fs";
2093
+ var DEFAULT_DIR = path2.join(os2.homedir(), ".pg-dash");
2094
+ var DEFAULT_RETENTION_DAYS = 7;
2095
+ var TimeseriesStore = class {
2047
2096
  db;
2048
- pool;
2049
- intervalMs;
2050
- timer = null;
2051
- constructor(db, pool, intervalMs = 6 * 60 * 60 * 1e3) {
2052
- this.db = db;
2053
- this.pool = pool;
2054
- this.intervalMs = intervalMs;
2055
- this.initTables();
2056
- }
2057
- initTables() {
2097
+ insertStmt;
2098
+ retentionMs;
2099
+ constructor(dbOrDir, retentionDays = DEFAULT_RETENTION_DAYS) {
2100
+ if (dbOrDir instanceof Database2) {
2101
+ this.db = dbOrDir;
2102
+ } else {
2103
+ const dir = dbOrDir || DEFAULT_DIR;
2104
+ fs2.mkdirSync(dir, { recursive: true });
2105
+ const dbPath = path2.join(dir, "metrics.db");
2106
+ this.db = new Database2(dbPath);
2107
+ }
2108
+ this.retentionMs = retentionDays * 24 * 60 * 60 * 1e3;
2109
+ this.db.pragma("journal_mode = WAL");
2058
2110
  this.db.exec(`
2059
- CREATE TABLE IF NOT EXISTS schema_snapshots (
2060
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2061
- timestamp INTEGER NOT NULL,
2062
- snapshot TEXT NOT NULL
2063
- );
2064
- CREATE TABLE IF NOT EXISTS schema_changes (
2065
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2066
- snapshot_id INTEGER NOT NULL,
2111
+ CREATE TABLE IF NOT EXISTS metrics (
2067
2112
  timestamp INTEGER NOT NULL,
2068
- change_type TEXT NOT NULL,
2069
- object_type TEXT NOT NULL,
2070
- table_name TEXT,
2071
- detail TEXT NOT NULL,
2072
- FOREIGN KEY (snapshot_id) REFERENCES schema_snapshots(id)
2113
+ metric TEXT NOT NULL,
2114
+ value REAL NOT NULL
2073
2115
  );
2116
+ CREATE INDEX IF NOT EXISTS idx_metrics_metric_ts ON metrics(metric, timestamp);
2074
2117
  `);
2118
+ this.insertStmt = this.db.prepare(
2119
+ "INSERT INTO metrics (timestamp, metric, value) VALUES (?, ?, ?)"
2120
+ );
2075
2121
  }
2076
- async takeSnapshot() {
2077
- const snapshot = await this.buildSnapshot();
2078
- const now = Date.now();
2079
- const json = JSON.stringify(snapshot);
2080
- const info = this.db.prepare("INSERT INTO schema_snapshots (timestamp, snapshot) VALUES (?, ?)").run(now, json);
2081
- const snapshotId = Number(info.lastInsertRowid);
2082
- const prev = this.db.prepare("SELECT snapshot FROM schema_snapshots WHERE id < ? ORDER BY id DESC LIMIT 1").get(snapshotId);
2083
- let changes = [];
2084
- if (prev) {
2085
- const oldSnap = JSON.parse(prev.snapshot);
2086
- changes = diffSnapshots(oldSnap, snapshot);
2087
- if (changes.length > 0) {
2088
- const insert = this.db.prepare("INSERT INTO schema_changes (snapshot_id, timestamp, change_type, object_type, table_name, detail) VALUES (?, ?, ?, ?, ?, ?)");
2089
- const tx = this.db.transaction((chs) => {
2090
- for (const c of chs) {
2091
- insert.run(snapshotId, now, c.change_type, c.object_type, c.table_name, c.detail);
2092
- }
2093
- });
2094
- tx(changes);
2122
+ insert(metric, value, timestamp) {
2123
+ this.insertStmt.run(timestamp ?? Date.now(), metric, value);
2124
+ }
2125
+ insertMany(points) {
2126
+ const tx = this.db.transaction((pts) => {
2127
+ for (const p of pts) {
2128
+ this.insertStmt.run(p.timestamp, p.metric, p.value);
2095
2129
  }
2130
+ });
2131
+ tx(points);
2132
+ }
2133
+ query(metric, startMs, endMs) {
2134
+ const end = endMs ?? Date.now();
2135
+ return this.db.prepare(
2136
+ "SELECT timestamp, value FROM metrics WHERE metric = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp"
2137
+ ).all(metric, startMs, end);
2138
+ }
2139
+ latest(metrics) {
2140
+ const result = {};
2141
+ if (metrics && metrics.length > 0) {
2142
+ const placeholders = metrics.map(() => "?").join(",");
2143
+ const rows = this.db.prepare(
2144
+ `SELECT m.metric, m.timestamp, m.value FROM metrics m INNER JOIN (SELECT metric, MAX(timestamp) as max_ts FROM metrics WHERE metric IN (${placeholders}) GROUP BY metric) g ON m.metric = g.metric AND m.timestamp = g.max_ts`
2145
+ ).all(...metrics);
2146
+ for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
2147
+ } else {
2148
+ const rows = this.db.prepare(
2149
+ "SELECT m.metric, m.timestamp, m.value FROM metrics m INNER JOIN (SELECT metric, MAX(timestamp) as max_ts FROM metrics GROUP BY metric) g ON m.metric = g.metric AND m.timestamp = g.max_ts"
2150
+ ).all();
2151
+ for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
2096
2152
  }
2097
- return { snapshotId, changes };
2098
- }
2099
- async buildSnapshot() {
2100
- const tables = await getSchemaTables(this.pool);
2101
- const enums = await getSchemaEnums(this.pool);
2102
- const detailedTables = await Promise.all(
2103
- tables.map(async (t) => {
2104
- const detail = await getSchemaTableDetail(this.pool, `${t.schema}.${t.name}`);
2105
- if (!detail) return null;
2106
- return {
2107
- name: detail.name,
2108
- schema: detail.schema,
2109
- columns: detail.columns.map((c) => ({
2110
- name: c.name,
2111
- type: c.type,
2112
- nullable: c.nullable,
2113
- default_value: c.default_value
2114
- })),
2115
- indexes: detail.indexes.map((i) => ({
2116
- name: i.name,
2117
- definition: i.definition,
2118
- is_unique: i.is_unique,
2119
- is_primary: i.is_primary
2120
- })),
2121
- constraints: detail.constraints.map((c) => ({
2122
- name: c.name,
2123
- type: c.type,
2124
- definition: c.definition
2125
- }))
2126
- };
2127
- })
2128
- );
2129
- return {
2130
- tables: detailedTables.filter(Boolean),
2131
- enums: enums.map((e) => ({ name: e.name, schema: e.schema, values: e.values }))
2132
- };
2153
+ return result;
2154
+ }
2155
+ prune() {
2156
+ const cutoff = Date.now() - this.retentionMs;
2157
+ const info = this.db.prepare("DELETE FROM metrics WHERE timestamp < ?").run(cutoff);
2158
+ return info.changes;
2159
+ }
2160
+ close() {
2161
+ this.db.close();
2162
+ }
2163
+ };
2164
+
2165
+ // src/server/collector.ts
2166
+ import { EventEmitter } from "events";
2167
+ var Collector = class extends EventEmitter {
2168
+ constructor(pool, store, intervalMs = 3e4) {
2169
+ super();
2170
+ this.pool = pool;
2171
+ this.store = store;
2172
+ this.intervalMs = intervalMs;
2133
2173
  }
2174
+ timer = null;
2175
+ pruneTimer = null;
2176
+ prev = null;
2177
+ lastSnapshot = {};
2178
+ collectCount = 0;
2134
2179
  start() {
2135
- this.takeSnapshot().catch((err) => console.error("Schema snapshot error:", err.message));
2180
+ this.collect().catch((err) => console.error("[collector] Initial collection failed:", err));
2136
2181
  this.timer = setInterval(() => {
2137
- this.takeSnapshot().catch((err) => console.error("Schema snapshot error:", err.message));
2182
+ this.collect().catch((err) => console.error("[collector] Collection failed:", err));
2138
2183
  }, this.intervalMs);
2184
+ this.pruneTimer = setInterval(() => this.store.prune(), 60 * 60 * 1e3);
2139
2185
  }
2140
2186
  stop() {
2141
2187
  if (this.timer) {
2142
2188
  clearInterval(this.timer);
2143
2189
  this.timer = null;
2144
2190
  }
2145
- }
2146
- // API helpers
2147
- getHistory(limit = 30) {
2148
- return this.db.prepare("SELECT id, timestamp FROM schema_snapshots ORDER BY id DESC LIMIT ?").all(limit);
2149
- }
2150
- getChanges(since) {
2151
- if (since) {
2152
- return this.db.prepare("SELECT * FROM schema_changes WHERE timestamp >= ? ORDER BY timestamp DESC").all(since);
2191
+ if (this.pruneTimer) {
2192
+ clearInterval(this.pruneTimer);
2193
+ this.pruneTimer = null;
2153
2194
  }
2154
- return this.db.prepare("SELECT * FROM schema_changes ORDER BY timestamp DESC LIMIT 100").all();
2155
2195
  }
2156
- getLatestChanges() {
2157
- const latest = this.db.prepare("SELECT id FROM schema_snapshots ORDER BY id DESC LIMIT 1").get();
2158
- if (!latest) return [];
2159
- return this.db.prepare("SELECT * FROM schema_changes WHERE snapshot_id = ? ORDER BY id").all(latest.id);
2196
+ getLastSnapshot() {
2197
+ return { ...this.lastSnapshot };
2160
2198
  }
2161
- getDiff(fromId, toId) {
2162
- const from = this.db.prepare("SELECT snapshot FROM schema_snapshots WHERE id = ?").get(fromId);
2163
- const to = this.db.prepare("SELECT snapshot FROM schema_snapshots WHERE id = ?").get(toId);
2164
- if (!from || !to) return null;
2165
- return diffSnapshots(JSON.parse(from.snapshot), JSON.parse(to.snapshot));
2199
+ async collect() {
2200
+ const now = Date.now();
2201
+ const snapshot = {};
2202
+ try {
2203
+ const client = await this.pool.connect();
2204
+ try {
2205
+ const connRes = await client.query(`
2206
+ SELECT
2207
+ count(*) FILTER (WHERE state = 'active')::int AS active,
2208
+ count(*) FILTER (WHERE state = 'idle')::int AS idle,
2209
+ count(*)::int AS total
2210
+ FROM pg_stat_activity
2211
+ `);
2212
+ const conn = connRes.rows[0];
2213
+ snapshot.connections_active = conn.active;
2214
+ snapshot.connections_idle = conn.idle;
2215
+ snapshot.connections_total = conn.total;
2216
+ const dbRes = await client.query(`
2217
+ SELECT
2218
+ xact_commit, xact_rollback, deadlocks, temp_bytes,
2219
+ tup_inserted, tup_updated, tup_deleted,
2220
+ CASE WHEN (blks_hit + blks_read) = 0 THEN 1
2221
+ ELSE blks_hit::float / (blks_hit + blks_read) END AS cache_ratio,
2222
+ pg_database_size(current_database()) AS db_size
2223
+ FROM pg_stat_database WHERE datname = current_database()
2224
+ `);
2225
+ const db = dbRes.rows[0];
2226
+ if (db) {
2227
+ snapshot.cache_hit_ratio = parseFloat(db.cache_ratio);
2228
+ snapshot.db_size_bytes = parseInt(db.db_size);
2229
+ const cur = {
2230
+ timestamp: now,
2231
+ xact_commit: parseInt(db.xact_commit),
2232
+ xact_rollback: parseInt(db.xact_rollback),
2233
+ deadlocks: parseInt(db.deadlocks),
2234
+ temp_bytes: parseInt(db.temp_bytes),
2235
+ tup_inserted: parseInt(db.tup_inserted),
2236
+ tup_updated: parseInt(db.tup_updated),
2237
+ tup_deleted: parseInt(db.tup_deleted)
2238
+ };
2239
+ if (this.prev) {
2240
+ const dtSec = (now - this.prev.timestamp) / 1e3;
2241
+ if (dtSec > 0) {
2242
+ snapshot.tps_commit = Math.max(0, (cur.xact_commit - this.prev.xact_commit) / dtSec);
2243
+ snapshot.tps_rollback = Math.max(0, (cur.xact_rollback - this.prev.xact_rollback) / dtSec);
2244
+ snapshot.deadlocks = Math.max(0, cur.deadlocks - this.prev.deadlocks);
2245
+ snapshot.temp_bytes = Math.max(0, cur.temp_bytes - this.prev.temp_bytes);
2246
+ snapshot.tuple_inserted = Math.max(0, (cur.tup_inserted - this.prev.tup_inserted) / dtSec);
2247
+ snapshot.tuple_updated = Math.max(0, (cur.tup_updated - this.prev.tup_updated) / dtSec);
2248
+ snapshot.tuple_deleted = Math.max(0, (cur.tup_deleted - this.prev.tup_deleted) / dtSec);
2249
+ }
2250
+ }
2251
+ this.prev = cur;
2252
+ }
2253
+ try {
2254
+ const tsRes = await client.query(`SELECT spcname, pg_tablespace_size(oid) AS size FROM pg_tablespace`);
2255
+ let totalTablespaceSize = 0;
2256
+ for (const row of tsRes.rows) {
2257
+ totalTablespaceSize += parseInt(row.size);
2258
+ }
2259
+ if (totalTablespaceSize > 0) {
2260
+ snapshot.disk_used_bytes = totalTablespaceSize;
2261
+ }
2262
+ } catch {
2263
+ }
2264
+ try {
2265
+ const repRes = await client.query(`
2266
+ SELECT CASE WHEN pg_is_in_recovery()
2267
+ THEN pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())
2268
+ ELSE 0 END AS lag_bytes
2269
+ `);
2270
+ snapshot.replication_lag_bytes = parseInt(repRes.rows[0]?.lag_bytes ?? "0");
2271
+ } catch {
2272
+ snapshot.replication_lag_bytes = 0;
2273
+ }
2274
+ } finally {
2275
+ client.release();
2276
+ }
2277
+ } catch (err) {
2278
+ console.error("[collector] Error collecting metrics:", err.message);
2279
+ return snapshot;
2280
+ }
2281
+ this.collectCount++;
2282
+ if (this.collectCount % 10 === 0) {
2283
+ try {
2284
+ const client = await this.pool.connect();
2285
+ try {
2286
+ const tableRes = await client.query(`
2287
+ SELECT schemaname, relname,
2288
+ pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as total_size
2289
+ FROM pg_stat_user_tables
2290
+ ORDER BY pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) DESC
2291
+ LIMIT 20
2292
+ `);
2293
+ for (const row of tableRes.rows) {
2294
+ this.store.insert(`table_size:${row.schemaname}.${row.relname}`, parseInt(row.total_size), now);
2295
+ }
2296
+ } finally {
2297
+ client.release();
2298
+ }
2299
+ } catch (err) {
2300
+ console.error("[collector] Error collecting table sizes:", err.message);
2301
+ }
2302
+ }
2303
+ const points = Object.entries(snapshot).map(([metric, value]) => ({
2304
+ timestamp: now,
2305
+ metric,
2306
+ value
2307
+ }));
2308
+ if (points.length > 0) {
2309
+ this.store.insertMany(points);
2310
+ }
2311
+ this.lastSnapshot = snapshot;
2312
+ this.emit("collected", snapshot);
2313
+ return snapshot;
2166
2314
  }
2167
2315
  };
2168
2316
 
2317
+ // src/server/index.ts
2318
+ init_schema_tracker();
2319
+
2169
2320
  // src/server/notifiers.ts
2170
2321
  var SEVERITY_COLORS = {
2171
2322
  critical: { hex: "#e74c3c", decimal: 15158332, emoji: "\u{1F534}" },
@@ -2648,6 +2799,7 @@ function registerAdvisorRoutes(app, pool, longQueryThreshold, store) {
2648
2799
  }
2649
2800
 
2650
2801
  // src/server/routes/schema.ts
2802
+ init_schema();
2651
2803
  function registerSchemaRoutes(app, pool, schemaTracker) {
2652
2804
  app.get("/api/schema/tables", async (c) => {
2653
2805
  try {
@@ -4185,6 +4337,14 @@ Migration check: ${filePath}`);
4185
4337
  console.log(`::notice::diff-env: target has extra index: ${id.table}.${idx}`);
4186
4338
  }
4187
4339
  }
4340
+ for (const c of result.schema.constraintDiffs ?? []) {
4341
+ const level = c.type === "missing" ? "error" : c.type === "extra" ? "notice" : "warning";
4342
+ console.log(`::${level}::diff-env: constraint ${c.type}: ${c.detail}`);
4343
+ }
4344
+ for (const e of result.schema.enumDiffs ?? []) {
4345
+ const level = e.type === "missing" ? "error" : e.type === "extra" ? "notice" : "warning";
4346
+ console.log(`::${level}::diff-env: enum ${e.type}: ${e.detail}`);
4347
+ }
4188
4348
  }
4189
4349
  }
4190
4350
  process.exit(result.summary.identical ? 0 : 1);