@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 +966 -806
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +258 -4
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -754,69 +754,519 @@ var init_advisor = __esm({
|
|
|
754
754
|
}
|
|
755
755
|
});
|
|
756
756
|
|
|
757
|
-
// src/server/
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
|
775
|
-
|
|
879
|
+
async function getSchemaIndexes(pool) {
|
|
880
|
+
const client = await pool.connect();
|
|
776
881
|
try {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
800
|
-
|
|
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/
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
});
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
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
|
-
|
|
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
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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/
|
|
2046
|
-
|
|
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
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
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
|
|
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
|
-
|
|
2069
|
-
|
|
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
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
const
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
|
2098
|
-
}
|
|
2099
|
-
|
|
2100
|
-
const
|
|
2101
|
-
const
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
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.
|
|
2180
|
+
this.collect().catch((err) => console.error("[collector] Initial collection failed:", err));
|
|
2136
2181
|
this.timer = setInterval(() => {
|
|
2137
|
-
this.
|
|
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
|
-
|
|
2147
|
-
|
|
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
|
-
|
|
2157
|
-
|
|
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
|
-
|
|
2162
|
-
const
|
|
2163
|
-
const
|
|
2164
|
-
|
|
2165
|
-
|
|
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);
|