@indiekitai/pg-dash 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/cli.js +1087 -823
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +332 -23
- 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,83 @@ 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 renameTableRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+TO\s+(\w+)/gi;
|
|
1385
|
+
while ((m = renameTableRe.exec(sql)) !== null) {
|
|
1386
|
+
const oldName = m[1];
|
|
1387
|
+
const newName = m[2];
|
|
1388
|
+
issues.push({
|
|
1389
|
+
severity: "warning",
|
|
1390
|
+
code: "RENAME_TABLE",
|
|
1391
|
+
message: `Renaming table "${oldName}" to "${newName}" breaks application code referencing the old name`,
|
|
1392
|
+
suggestion: "Deploy application code that handles both names before renaming, or use a view with the old name after renaming.",
|
|
1393
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1394
|
+
tableName: oldName
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
const renameColumnRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+COLUMN\s+(\w+)\s+TO\s+(\w+)/gi;
|
|
1398
|
+
while ((m = renameColumnRe.exec(sql)) !== null) {
|
|
1399
|
+
const table = m[1];
|
|
1400
|
+
const oldCol = m[2];
|
|
1401
|
+
const newCol = m[3];
|
|
1402
|
+
issues.push({
|
|
1403
|
+
severity: "warning",
|
|
1404
|
+
code: "RENAME_COLUMN",
|
|
1405
|
+
message: `Renaming column "${oldCol}" to "${newCol}" on table "${table}" breaks application code referencing the old column name`,
|
|
1406
|
+
suggestion: "Add new column, backfill data, update application to use new column, then drop old column (expand/contract pattern).",
|
|
1407
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1408
|
+
tableName: table
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
const addConRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ADD\s+CONSTRAINT\b[^;]*(;|$)/gi;
|
|
1412
|
+
while ((m = addConRe.exec(sql)) !== null) {
|
|
1413
|
+
const fragment = m[0];
|
|
1414
|
+
const table = bareTable(m[1]);
|
|
1415
|
+
const fragUpper = fragment.toUpperCase();
|
|
1416
|
+
if (!/\bNOT\s+VALID\b/.test(fragUpper)) {
|
|
1417
|
+
issues.push({
|
|
1418
|
+
severity: "warning",
|
|
1419
|
+
code: "ADD_CONSTRAINT_SCANS_TABLE",
|
|
1420
|
+
message: "ADD CONSTRAINT validates all existing rows and holds an exclusive lock during the scan.",
|
|
1421
|
+
suggestion: "Use ADD CONSTRAINT ... NOT VALID to skip validation, then VALIDATE CONSTRAINT in a separate transaction.",
|
|
1422
|
+
lineNumber: findLineNumber(sql, m.index),
|
|
1423
|
+
tableName: table
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const hasTransaction = /\bBEGIN\b/i.test(sql) || /\bSTART\s+TRANSACTION\b/i.test(sql);
|
|
1428
|
+
const hasConcurrently = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\b/i.test(sql);
|
|
1429
|
+
if (hasTransaction && hasConcurrently) {
|
|
1430
|
+
issues.push({
|
|
1431
|
+
severity: "error",
|
|
1432
|
+
code: "CONCURRENTLY_IN_TRANSACTION",
|
|
1433
|
+
message: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block. It will fail at runtime.",
|
|
1434
|
+
suggestion: "Remove the BEGIN/COMMIT wrapper, or use a migration tool that runs CONCURRENTLY outside transactions."
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
910
1437
|
const truncRe = /\bTRUNCATE\b/gi;
|
|
911
1438
|
while ((m = truncRe.exec(sql)) !== null) {
|
|
912
1439
|
issues.push({
|
|
@@ -1062,7 +1589,7 @@ async function fetchColumns(pool) {
|
|
|
1062
1589
|
}
|
|
1063
1590
|
async function fetchIndexes(pool) {
|
|
1064
1591
|
const res = await pool.query(`
|
|
1065
|
-
SELECT tablename, indexname
|
|
1592
|
+
SELECT tablename, indexname, indexdef
|
|
1066
1593
|
FROM pg_indexes
|
|
1067
1594
|
WHERE schemaname = 'public' AND indexname NOT LIKE '%_pkey'
|
|
1068
1595
|
ORDER BY tablename, indexname
|
|
@@ -1103,6 +1630,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1103
1630
|
const missingColumns = [];
|
|
1104
1631
|
const extraColumns = [];
|
|
1105
1632
|
const typeDiffs = [];
|
|
1633
|
+
const nullableDiffs = [];
|
|
1634
|
+
const defaultDiffs = [];
|
|
1106
1635
|
for (const [colName, srcInfo] of srcMap) {
|
|
1107
1636
|
if (!tgtMap.has(colName)) {
|
|
1108
1637
|
missingColumns.push(srcInfo);
|
|
@@ -1111,6 +1640,12 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1111
1640
|
if (srcInfo.type !== tgtInfo.type) {
|
|
1112
1641
|
typeDiffs.push({ column: colName, sourceType: srcInfo.type, targetType: tgtInfo.type });
|
|
1113
1642
|
}
|
|
1643
|
+
if (srcInfo.nullable !== tgtInfo.nullable) {
|
|
1644
|
+
nullableDiffs.push({ column: colName, sourceNullable: srcInfo.nullable, targetNullable: tgtInfo.nullable });
|
|
1645
|
+
}
|
|
1646
|
+
if ((srcInfo.default ?? null) !== (tgtInfo.default ?? null)) {
|
|
1647
|
+
defaultDiffs.push({ column: colName, sourceDefault: srcInfo.default ?? null, targetDefault: tgtInfo.default ?? null });
|
|
1648
|
+
}
|
|
1114
1649
|
}
|
|
1115
1650
|
}
|
|
1116
1651
|
for (const [colName, tgtInfo] of tgtMap) {
|
|
@@ -1118,8 +1653,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1118
1653
|
extraColumns.push(tgtInfo);
|
|
1119
1654
|
}
|
|
1120
1655
|
}
|
|
1121
|
-
if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0) {
|
|
1122
|
-
diffs.push({ table, missingColumns, extraColumns, typeDiffs });
|
|
1656
|
+
if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0 || nullableDiffs.length > 0 || defaultDiffs.length > 0) {
|
|
1657
|
+
diffs.push({ table, missingColumns, extraColumns, typeDiffs, nullableDiffs, defaultDiffs });
|
|
1123
1658
|
}
|
|
1124
1659
|
}
|
|
1125
1660
|
return diffs;
|
|
@@ -1127,8 +1662,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
|
|
|
1127
1662
|
function groupIndexesByTable(indexes) {
|
|
1128
1663
|
const map = /* @__PURE__ */ new Map();
|
|
1129
1664
|
for (const idx of indexes) {
|
|
1130
|
-
if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new
|
|
1131
|
-
map.get(idx.tablename).
|
|
1665
|
+
if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new Map());
|
|
1666
|
+
map.get(idx.tablename).set(idx.indexname, idx.indexdef);
|
|
1132
1667
|
}
|
|
1133
1668
|
return map;
|
|
1134
1669
|
}
|
|
@@ -1142,12 +1677,21 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
|
|
|
1142
1677
|
]);
|
|
1143
1678
|
for (const table of allTables) {
|
|
1144
1679
|
if (!commonTables.includes(table)) continue;
|
|
1145
|
-
const
|
|
1146
|
-
const
|
|
1147
|
-
const missingIndexes = [...
|
|
1148
|
-
const extraIndexes = [...
|
|
1149
|
-
|
|
1150
|
-
|
|
1680
|
+
const srcMap = srcByTable.get(table) ?? /* @__PURE__ */ new Map();
|
|
1681
|
+
const tgtMap = tgtByTable.get(table) ?? /* @__PURE__ */ new Map();
|
|
1682
|
+
const missingIndexes = [...srcMap.keys()].filter((i) => !tgtMap.has(i));
|
|
1683
|
+
const extraIndexes = [...tgtMap.keys()].filter((i) => !srcMap.has(i));
|
|
1684
|
+
const modifiedIndexes = [];
|
|
1685
|
+
for (const [name, srcDef] of srcMap) {
|
|
1686
|
+
if (tgtMap.has(name)) {
|
|
1687
|
+
const tgtDef = tgtMap.get(name);
|
|
1688
|
+
if (srcDef !== tgtDef) {
|
|
1689
|
+
modifiedIndexes.push({ name, sourceDef: srcDef, targetDef: tgtDef });
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (missingIndexes.length > 0 || extraIndexes.length > 0 || modifiedIndexes.length > 0) {
|
|
1694
|
+
diffs.push({ table, missingIndexes, extraIndexes, modifiedIndexes });
|
|
1151
1695
|
}
|
|
1152
1696
|
}
|
|
1153
1697
|
return diffs;
|
|
@@ -1155,11 +1699,13 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
|
|
|
1155
1699
|
function countSchemaDrifts(schema) {
|
|
1156
1700
|
let n = schema.missingTables.length + schema.extraTables.length;
|
|
1157
1701
|
for (const cd of schema.columnDiffs) {
|
|
1158
|
-
n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length;
|
|
1702
|
+
n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length + cd.nullableDiffs.length + cd.defaultDiffs.length;
|
|
1159
1703
|
}
|
|
1160
1704
|
for (const id of schema.indexDiffs) {
|
|
1161
|
-
n += id.missingIndexes.length + id.extraIndexes.length;
|
|
1705
|
+
n += id.missingIndexes.length + id.extraIndexes.length + id.modifiedIndexes.length;
|
|
1162
1706
|
}
|
|
1707
|
+
n += (schema.constraintDiffs ?? []).length;
|
|
1708
|
+
n += (schema.enumDiffs ?? []).length;
|
|
1163
1709
|
return n;
|
|
1164
1710
|
}
|
|
1165
1711
|
async function diffEnvironments(sourceConn, targetConn, options) {
|
|
@@ -1172,22 +1718,46 @@ async function diffEnvironments(sourceConn, targetConn, options) {
|
|
|
1172
1718
|
sourceCols,
|
|
1173
1719
|
targetCols,
|
|
1174
1720
|
sourceIdxs,
|
|
1175
|
-
targetIdxs
|
|
1721
|
+
targetIdxs,
|
|
1722
|
+
sourceSnap,
|
|
1723
|
+
targetSnap
|
|
1176
1724
|
] = await Promise.all([
|
|
1177
1725
|
fetchTables(sourcePool),
|
|
1178
1726
|
fetchTables(targetPool),
|
|
1179
1727
|
fetchColumns(sourcePool),
|
|
1180
1728
|
fetchColumns(targetPool),
|
|
1181
1729
|
fetchIndexes(sourcePool),
|
|
1182
|
-
fetchIndexes(targetPool)
|
|
1730
|
+
fetchIndexes(targetPool),
|
|
1731
|
+
buildLiveSnapshot(sourcePool).catch(() => null),
|
|
1732
|
+
buildLiveSnapshot(targetPool).catch(() => null)
|
|
1183
1733
|
]);
|
|
1184
1734
|
const { missingTables, extraTables } = diffTables(sourceTables, targetTables);
|
|
1185
|
-
const sourceSet = new Set(sourceTables);
|
|
1186
1735
|
const targetSet = new Set(targetTables);
|
|
1187
1736
|
const commonTables = sourceTables.filter((t) => targetSet.has(t));
|
|
1188
1737
|
const columnDiffs = diffColumns(sourceCols, targetCols, commonTables);
|
|
1189
1738
|
const indexDiffs = diffIndexes(sourceIdxs, targetIdxs, commonTables);
|
|
1190
|
-
const
|
|
1739
|
+
const constraintDiffs = [];
|
|
1740
|
+
const enumDiffs = [];
|
|
1741
|
+
if (sourceSnap && targetSnap) {
|
|
1742
|
+
const snapChanges = diffSnapshots(sourceSnap, targetSnap);
|
|
1743
|
+
for (const c of snapChanges) {
|
|
1744
|
+
if (c.object_type === "constraint") {
|
|
1745
|
+
constraintDiffs.push({
|
|
1746
|
+
table: c.table_name ?? null,
|
|
1747
|
+
type: c.change_type === "added" ? "extra" : c.change_type === "removed" ? "missing" : "modified",
|
|
1748
|
+
name: c.detail.split(" ")[1] ?? c.detail,
|
|
1749
|
+
detail: c.detail
|
|
1750
|
+
});
|
|
1751
|
+
} else if (c.object_type === "enum") {
|
|
1752
|
+
enumDiffs.push({
|
|
1753
|
+
type: c.change_type === "added" ? "extra" : c.change_type === "removed" ? "missing" : "modified",
|
|
1754
|
+
name: c.detail.split(" ")[1] ?? c.detail,
|
|
1755
|
+
detail: c.detail
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
const schema = { missingTables, extraTables, columnDiffs, indexDiffs, constraintDiffs, enumDiffs };
|
|
1191
1761
|
const schemaDrifts = countSchemaDrifts(schema);
|
|
1192
1762
|
let health;
|
|
1193
1763
|
if (options?.includeHealth) {
|
|
@@ -1269,8 +1839,31 @@ function formatTextDiff(result) {
|
|
|
1269
1839
|
lines.push(` ~ column type differences:`);
|
|
1270
1840
|
lines.push(...typeChanges);
|
|
1271
1841
|
}
|
|
1842
|
+
const nullableChanges = [];
|
|
1843
|
+
const defaultChanges = [];
|
|
1844
|
+
for (const cd of schema.columnDiffs) {
|
|
1845
|
+
for (const nd of cd.nullableDiffs) {
|
|
1846
|
+
const src = nd.sourceNullable ? "nullable" : "NOT NULL";
|
|
1847
|
+
const tgt = nd.targetNullable ? "nullable" : "NOT NULL";
|
|
1848
|
+
nullableChanges.push(` ${cd.table}.${nd.column}: source=${src} \u2192 target=${tgt}`);
|
|
1849
|
+
}
|
|
1850
|
+
for (const dd of cd.defaultDiffs) {
|
|
1851
|
+
const src = dd.sourceDefault ?? "(none)";
|
|
1852
|
+
const tgt = dd.targetDefault ?? "(none)";
|
|
1853
|
+
defaultChanges.push(` ${cd.table}.${dd.column}: source=${src} \u2192 target=${tgt}`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
if (nullableChanges.length > 0) {
|
|
1857
|
+
lines.push(` ~ nullable differences:`);
|
|
1858
|
+
lines.push(...nullableChanges);
|
|
1859
|
+
}
|
|
1860
|
+
if (defaultChanges.length > 0) {
|
|
1861
|
+
lines.push(` ~ default differences:`);
|
|
1862
|
+
lines.push(...defaultChanges);
|
|
1863
|
+
}
|
|
1272
1864
|
const missingIdxs = [];
|
|
1273
1865
|
const extraIdxs = [];
|
|
1866
|
+
const modifiedIdxs = [];
|
|
1274
1867
|
for (const id of schema.indexDiffs) {
|
|
1275
1868
|
for (const idx of id.missingIndexes) {
|
|
1276
1869
|
missingIdxs.push(` ${id.table}: ${idx}`);
|
|
@@ -1278,6 +1871,9 @@ function formatTextDiff(result) {
|
|
|
1278
1871
|
for (const idx of id.extraIndexes) {
|
|
1279
1872
|
extraIdxs.push(` ${id.table}: ${idx}`);
|
|
1280
1873
|
}
|
|
1874
|
+
for (const mi of id.modifiedIndexes) {
|
|
1875
|
+
modifiedIdxs.push(` ${id.table}: ${mi.name} source="${mi.sourceDef}" \u2192 target="${mi.targetDef}"`);
|
|
1876
|
+
}
|
|
1281
1877
|
}
|
|
1282
1878
|
if (missingIdxs.length > 0) {
|
|
1283
1879
|
lines.push(` \u2717 target missing indexes:`);
|
|
@@ -1287,14 +1883,55 @@ function formatTextDiff(result) {
|
|
|
1287
1883
|
lines.push(` \u26A0 target has extra indexes:`);
|
|
1288
1884
|
lines.push(...extraIdxs);
|
|
1289
1885
|
}
|
|
1290
|
-
if (
|
|
1291
|
-
lines.push(`
|
|
1886
|
+
if (modifiedIdxs.length > 0) {
|
|
1887
|
+
lines.push(` ~ index definition differences:`);
|
|
1888
|
+
lines.push(...modifiedIdxs);
|
|
1292
1889
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
lines.push(`
|
|
1890
|
+
const missingConstraints = (schema.constraintDiffs ?? []).filter((c) => c.type === "missing");
|
|
1891
|
+
const extraConstraints = (schema.constraintDiffs ?? []).filter((c) => c.type === "extra");
|
|
1892
|
+
const modifiedConstraints = (schema.constraintDiffs ?? []).filter((c) => c.type === "modified");
|
|
1893
|
+
if (missingConstraints.length > 0) {
|
|
1894
|
+
lines.push(` \u2717 target missing constraints:`);
|
|
1895
|
+
for (const c of missingConstraints) {
|
|
1896
|
+
lines.push(` ${c.table ? c.table + ": " : ""}${c.detail}`);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
if (extraConstraints.length > 0) {
|
|
1900
|
+
lines.push(` \u26A0 target has extra constraints:`);
|
|
1901
|
+
for (const c of extraConstraints) {
|
|
1902
|
+
lines.push(` ${c.table ? c.table + ": " : ""}${c.detail}`);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
if (modifiedConstraints.length > 0) {
|
|
1906
|
+
lines.push(` ~ constraint differences:`);
|
|
1907
|
+
for (const c of modifiedConstraints) {
|
|
1908
|
+
lines.push(` ${c.table ? c.table + ": " : ""}${c.detail}`);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
const missingEnums = (schema.enumDiffs ?? []).filter((e) => e.type === "missing");
|
|
1912
|
+
const extraEnums = (schema.enumDiffs ?? []).filter((e) => e.type === "extra");
|
|
1913
|
+
const modifiedEnums = (schema.enumDiffs ?? []).filter((e) => e.type === "modified");
|
|
1914
|
+
if (missingEnums.length > 0) {
|
|
1915
|
+
lines.push(` \u2717 target missing enums:`);
|
|
1916
|
+
for (const e of missingEnums) lines.push(` ${e.detail}`);
|
|
1917
|
+
}
|
|
1918
|
+
if (extraEnums.length > 0) {
|
|
1919
|
+
lines.push(` \u26A0 target has extra enums:`);
|
|
1920
|
+
for (const e of extraEnums) lines.push(` ${e.detail}`);
|
|
1921
|
+
}
|
|
1922
|
+
if (modifiedEnums.length > 0) {
|
|
1923
|
+
lines.push(` ~ enum differences:`);
|
|
1924
|
+
for (const e of modifiedEnums) lines.push(` ${e.detail}`);
|
|
1925
|
+
}
|
|
1926
|
+
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 && nullableChanges.length === 0 && defaultChanges.length === 0 && modifiedIdxs.length === 0;
|
|
1927
|
+
if (noSchemaChanges) {
|
|
1928
|
+
lines.push(` \u2713 Schemas are identical`);
|
|
1929
|
+
}
|
|
1930
|
+
if (result.health) {
|
|
1931
|
+
const h = result.health;
|
|
1932
|
+
lines.push(``);
|
|
1933
|
+
lines.push(`Health Comparison:`);
|
|
1934
|
+
lines.push(` Source: ${h.source.score}/100 (${h.source.grade}) | Target: ${h.target.score}/100 (${h.target.grade})`);
|
|
1298
1935
|
lines.push(` Source-only issues: ${h.sourceOnlyIssues.length === 0 ? "(none)" : ""}`);
|
|
1299
1936
|
for (const iss of h.sourceOnlyIssues) lines.push(` - ${iss}`);
|
|
1300
1937
|
lines.push(` Target-only issues: ${h.targetOnlyIssues.length === 0 ? "(none)" : ""}`);
|
|
@@ -1337,14 +1974,45 @@ function formatMdDiff(result) {
|
|
|
1337
1974
|
if (missingColItems.length > 0) rows.push([`\u274C Missing columns`, missingColItems.join(", ")]);
|
|
1338
1975
|
if (extraColItems.length > 0) rows.push([`\u26A0\uFE0F Extra columns`, extraColItems.join(", ")]);
|
|
1339
1976
|
if (typeItems.length > 0) rows.push([`~ Type differences`, typeItems.join(", ")]);
|
|
1977
|
+
const nullableItems = [];
|
|
1978
|
+
const defaultItems = [];
|
|
1979
|
+
for (const cd of schema.columnDiffs) {
|
|
1980
|
+
for (const nd of cd.nullableDiffs) {
|
|
1981
|
+
const src = nd.sourceNullable ? "nullable" : "NOT NULL";
|
|
1982
|
+
const tgt = nd.targetNullable ? "nullable" : "NOT NULL";
|
|
1983
|
+
nullableItems.push(`\`${cd.table}.${nd.column}\` (${src}\u2192${tgt})`);
|
|
1984
|
+
}
|
|
1985
|
+
for (const dd of cd.defaultDiffs) {
|
|
1986
|
+
const src = dd.sourceDefault ?? "(none)";
|
|
1987
|
+
const tgt = dd.targetDefault ?? "(none)";
|
|
1988
|
+
defaultItems.push(`\`${cd.table}.${dd.column}\` (${src}\u2192${tgt})`);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
if (nullableItems.length > 0) rows.push([`~ Nullable differences`, nullableItems.join(", ")]);
|
|
1992
|
+
if (defaultItems.length > 0) rows.push([`~ Default differences`, defaultItems.join(", ")]);
|
|
1340
1993
|
const missingIdxItems = [];
|
|
1341
1994
|
const extraIdxItems = [];
|
|
1995
|
+
const modifiedIdxItems = [];
|
|
1342
1996
|
for (const id of schema.indexDiffs) {
|
|
1343
1997
|
for (const idx of id.missingIndexes) missingIdxItems.push(`\`${id.table}.${idx}\``);
|
|
1344
1998
|
for (const idx of id.extraIndexes) extraIdxItems.push(`\`${id.table}.${idx}\``);
|
|
1999
|
+
for (const mi of id.modifiedIndexes) modifiedIdxItems.push(`\`${id.table}.${mi.name}\``);
|
|
1345
2000
|
}
|
|
1346
2001
|
if (missingIdxItems.length > 0) rows.push([`\u274C Missing indexes`, missingIdxItems.join(", ")]);
|
|
1347
2002
|
if (extraIdxItems.length > 0) rows.push([`\u26A0\uFE0F Extra indexes`, extraIdxItems.join(", ")]);
|
|
2003
|
+
if (modifiedIdxItems.length > 0) rows.push([`~ Modified indexes`, modifiedIdxItems.join(", ")]);
|
|
2004
|
+
const missingConItems = (schema.constraintDiffs ?? []).filter((c) => c.type === "missing").map((c) => c.detail);
|
|
2005
|
+
const extraConItems = (schema.constraintDiffs ?? []).filter((c) => c.type === "extra").map((c) => c.detail);
|
|
2006
|
+
const modConItems = (schema.constraintDiffs ?? []).filter((c) => c.type === "modified").map((c) => c.detail);
|
|
2007
|
+
if (missingConItems.length > 0) rows.push([`\u274C Missing constraints`, missingConItems.join("; ")]);
|
|
2008
|
+
if (extraConItems.length > 0) rows.push([`\u26A0\uFE0F Extra constraints`, extraConItems.join("; ")]);
|
|
2009
|
+
if (modConItems.length > 0) rows.push([`~ Modified constraints`, modConItems.join("; ")]);
|
|
2010
|
+
const missingEnumItems = (schema.enumDiffs ?? []).filter((e) => e.type === "missing").map((e) => e.detail);
|
|
2011
|
+
const extraEnumItems = (schema.enumDiffs ?? []).filter((e) => e.type === "extra").map((e) => e.detail);
|
|
2012
|
+
const modEnumItems = (schema.enumDiffs ?? []).filter((e) => e.type === "modified").map((e) => e.detail);
|
|
2013
|
+
if (missingEnumItems.length > 0) rows.push([`\u274C Missing enums`, missingEnumItems.join("; ")]);
|
|
2014
|
+
if (extraEnumItems.length > 0) rows.push([`\u26A0\uFE0F Extra enums`, extraEnumItems.join("; ")]);
|
|
2015
|
+
if (modEnumItems.length > 0) rows.push([`~ Modified enums`, modEnumItems.join("; ")]);
|
|
1348
2016
|
if (rows.length > 0) {
|
|
1349
2017
|
lines.push(`| Type | Details |`);
|
|
1350
2018
|
lines.push(`|------|---------|`);
|
|
@@ -1366,806 +2034,382 @@ function formatMdDiff(result) {
|
|
|
1366
2034
|
if (h.targetOnlyIssues.length > 0) {
|
|
1367
2035
|
lines.push(``);
|
|
1368
2036
|
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);
|
|
2037
|
+
for (const iss of h.targetOnlyIssues) lines.push(`- ${iss}`);
|
|
2038
|
+
}
|
|
2039
|
+
if (h.sourceOnlyIssues.length > 0) {
|
|
2040
|
+
lines.push(``);
|
|
2041
|
+
lines.push(`**Source-only issues:**`);
|
|
2042
|
+
for (const iss of h.sourceOnlyIssues) lines.push(`- ${iss}`);
|
|
1849
2043
|
}
|
|
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
2044
|
}
|
|
2045
|
+
lines.push(``);
|
|
2046
|
+
const { schemaDrifts, identical } = result.summary;
|
|
2047
|
+
lines.push(`**Result: ${schemaDrifts} drift${schemaDrifts !== 1 ? "s" : ""} \u2014 environments are ${identical ? "in sync \u2713" : "NOT in sync"}**`);
|
|
2048
|
+
return lines.join("\n");
|
|
1861
2049
|
}
|
|
1862
|
-
|
|
2050
|
+
var init_env_differ = __esm({
|
|
2051
|
+
"src/server/env-differ.ts"() {
|
|
2052
|
+
"use strict";
|
|
2053
|
+
init_advisor();
|
|
2054
|
+
init_schema_tracker();
|
|
2055
|
+
init_schema_diff();
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
// src/cli.ts
|
|
2060
|
+
import { parseArgs } from "util";
|
|
2061
|
+
|
|
2062
|
+
// src/server/index.ts
|
|
2063
|
+
import { Hono } from "hono";
|
|
2064
|
+
import path3 from "path";
|
|
2065
|
+
import fs3 from "fs";
|
|
2066
|
+
import os3 from "os";
|
|
2067
|
+
import { fileURLToPath } from "url";
|
|
2068
|
+
import { Pool } from "pg";
|
|
2069
|
+
|
|
2070
|
+
// src/server/queries/overview.ts
|
|
2071
|
+
async function getOverview(pool) {
|
|
1863
2072
|
const client = await pool.connect();
|
|
1864
2073
|
try {
|
|
1865
|
-
const
|
|
2074
|
+
const version = await client.query("SHOW server_version");
|
|
2075
|
+
const uptime = await client.query(
|
|
2076
|
+
`SELECT to_char(now() - pg_postmaster_start_time(), 'DD "d" HH24 "h" MI "m"') AS uptime`
|
|
2077
|
+
);
|
|
2078
|
+
const dbSize = await client.query(
|
|
2079
|
+
"SELECT pg_size_pretty(pg_database_size(current_database())) AS size"
|
|
2080
|
+
);
|
|
2081
|
+
const dbCount = await client.query(
|
|
2082
|
+
"SELECT count(*)::int AS count FROM pg_database WHERE NOT datistemplate"
|
|
2083
|
+
);
|
|
2084
|
+
const connections = await client.query(`
|
|
1866
2085
|
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
|
|
2086
|
+
(SELECT count(*)::int FROM pg_stat_activity WHERE state = 'active') AS active,
|
|
2087
|
+
(SELECT count(*)::int FROM pg_stat_activity WHERE state = 'idle') AS idle,
|
|
2088
|
+
(SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max
|
|
1885
2089
|
`);
|
|
1886
|
-
return
|
|
2090
|
+
return {
|
|
2091
|
+
version: version.rows[0].server_version,
|
|
2092
|
+
uptime: uptime.rows[0].uptime,
|
|
2093
|
+
dbSize: dbSize.rows[0].size,
|
|
2094
|
+
databaseCount: dbCount.rows[0].count,
|
|
2095
|
+
connections: connections.rows[0]
|
|
2096
|
+
};
|
|
1887
2097
|
} finally {
|
|
1888
2098
|
client.release();
|
|
1889
2099
|
}
|
|
1890
2100
|
}
|
|
1891
|
-
|
|
2101
|
+
|
|
2102
|
+
// src/server/queries/databases.ts
|
|
2103
|
+
async function getDatabases(pool) {
|
|
1892
2104
|
const client = await pool.connect();
|
|
1893
2105
|
try {
|
|
1894
2106
|
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
|
|
2107
|
+
SELECT datname AS name,
|
|
2108
|
+
pg_size_pretty(pg_database_size(datname)) AS size,
|
|
2109
|
+
pg_database_size(datname) AS size_bytes
|
|
2110
|
+
FROM pg_database
|
|
2111
|
+
WHERE NOT datistemplate
|
|
2112
|
+
ORDER BY pg_database_size(datname) DESC
|
|
1908
2113
|
`);
|
|
1909
2114
|
return r.rows;
|
|
1910
2115
|
} finally {
|
|
1911
2116
|
client.release();
|
|
1912
2117
|
}
|
|
1913
2118
|
}
|
|
1914
|
-
|
|
2119
|
+
|
|
2120
|
+
// src/server/queries/tables.ts
|
|
2121
|
+
async function getTables(pool) {
|
|
1915
2122
|
const client = await pool.connect();
|
|
1916
2123
|
try {
|
|
1917
2124
|
const r = await client.query(`
|
|
1918
|
-
SELECT
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2125
|
+
SELECT
|
|
2126
|
+
schemaname AS schema,
|
|
2127
|
+
relname AS name,
|
|
2128
|
+
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
|
|
2129
|
+
pg_total_relation_size(relid) AS size_bytes,
|
|
2130
|
+
n_live_tup AS rows,
|
|
2131
|
+
n_dead_tup AS dead_tuples,
|
|
2132
|
+
CASE WHEN n_live_tup > 0
|
|
2133
|
+
THEN round(n_dead_tup::numeric / n_live_tup * 100, 1)
|
|
2134
|
+
ELSE 0 END AS dead_pct
|
|
2135
|
+
FROM pg_stat_user_tables
|
|
2136
|
+
ORDER BY pg_total_relation_size(relid) DESC
|
|
1923
2137
|
`);
|
|
1924
2138
|
return r.rows;
|
|
1925
2139
|
} finally {
|
|
1926
2140
|
client.release();
|
|
1927
2141
|
}
|
|
1928
2142
|
}
|
|
1929
|
-
|
|
2143
|
+
|
|
2144
|
+
// src/server/queries/activity.ts
|
|
2145
|
+
async function getActivity(pool) {
|
|
1930
2146
|
const client = await pool.connect();
|
|
1931
2147
|
try {
|
|
1932
2148
|
const r = await client.query(`
|
|
1933
2149
|
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
|
-
}
|
|
2150
|
+
pid,
|
|
2151
|
+
COALESCE(query, '') AS query,
|
|
2152
|
+
COALESCE(state, 'unknown') AS state,
|
|
2153
|
+
wait_event,
|
|
2154
|
+
wait_event_type,
|
|
2155
|
+
CASE WHEN state = 'active' THEN (now() - query_start)::text
|
|
2156
|
+
WHEN state = 'idle in transaction' THEN (now() - state_change)::text
|
|
2157
|
+
ELSE NULL END AS duration,
|
|
2158
|
+
client_addr::text,
|
|
2159
|
+
COALESCE(application_name, '') AS application_name,
|
|
2160
|
+
backend_start::text
|
|
2161
|
+
FROM pg_stat_activity
|
|
2162
|
+
WHERE pid != pg_backend_pid()
|
|
2163
|
+
AND state IS NOT NULL
|
|
2164
|
+
ORDER BY
|
|
2165
|
+
CASE state
|
|
2166
|
+
WHEN 'active' THEN 1
|
|
2167
|
+
WHEN 'idle in transaction' THEN 2
|
|
2168
|
+
ELSE 3
|
|
2169
|
+
END,
|
|
2170
|
+
query_start ASC NULLS LAST
|
|
2171
|
+
`);
|
|
2172
|
+
return r.rows;
|
|
2173
|
+
} finally {
|
|
2174
|
+
client.release();
|
|
2041
2175
|
}
|
|
2042
|
-
return changes;
|
|
2043
2176
|
}
|
|
2044
2177
|
|
|
2045
|
-
// src/server/
|
|
2046
|
-
|
|
2178
|
+
// src/server/index.ts
|
|
2179
|
+
init_advisor();
|
|
2180
|
+
|
|
2181
|
+
// src/server/timeseries.ts
|
|
2182
|
+
import Database2 from "better-sqlite3";
|
|
2183
|
+
import path2 from "path";
|
|
2184
|
+
import os2 from "os";
|
|
2185
|
+
import fs2 from "fs";
|
|
2186
|
+
var DEFAULT_DIR = path2.join(os2.homedir(), ".pg-dash");
|
|
2187
|
+
var DEFAULT_RETENTION_DAYS = 7;
|
|
2188
|
+
var TimeseriesStore = class {
|
|
2047
2189
|
db;
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2190
|
+
insertStmt;
|
|
2191
|
+
retentionMs;
|
|
2192
|
+
constructor(dbOrDir, retentionDays = DEFAULT_RETENTION_DAYS) {
|
|
2193
|
+
if (dbOrDir instanceof Database2) {
|
|
2194
|
+
this.db = dbOrDir;
|
|
2195
|
+
} else {
|
|
2196
|
+
const dir = dbOrDir || DEFAULT_DIR;
|
|
2197
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
2198
|
+
const dbPath = path2.join(dir, "metrics.db");
|
|
2199
|
+
this.db = new Database2(dbPath);
|
|
2200
|
+
}
|
|
2201
|
+
this.retentionMs = retentionDays * 24 * 60 * 60 * 1e3;
|
|
2202
|
+
this.db.pragma("journal_mode = WAL");
|
|
2058
2203
|
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,
|
|
2204
|
+
CREATE TABLE IF NOT EXISTS metrics (
|
|
2067
2205
|
timestamp INTEGER NOT NULL,
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
table_name TEXT,
|
|
2071
|
-
detail TEXT NOT NULL,
|
|
2072
|
-
FOREIGN KEY (snapshot_id) REFERENCES schema_snapshots(id)
|
|
2206
|
+
metric TEXT NOT NULL,
|
|
2207
|
+
value REAL NOT NULL
|
|
2073
2208
|
);
|
|
2209
|
+
CREATE INDEX IF NOT EXISTS idx_metrics_metric_ts ON metrics(metric, timestamp);
|
|
2074
2210
|
`);
|
|
2211
|
+
this.insertStmt = this.db.prepare(
|
|
2212
|
+
"INSERT INTO metrics (timestamp, metric, value) VALUES (?, ?, ?)"
|
|
2213
|
+
);
|
|
2075
2214
|
}
|
|
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);
|
|
2215
|
+
insert(metric, value, timestamp) {
|
|
2216
|
+
this.insertStmt.run(timestamp ?? Date.now(), metric, value);
|
|
2217
|
+
}
|
|
2218
|
+
insertMany(points) {
|
|
2219
|
+
const tx = this.db.transaction((pts) => {
|
|
2220
|
+
for (const p of pts) {
|
|
2221
|
+
this.insertStmt.run(p.timestamp, p.metric, p.value);
|
|
2095
2222
|
}
|
|
2223
|
+
});
|
|
2224
|
+
tx(points);
|
|
2225
|
+
}
|
|
2226
|
+
query(metric, startMs, endMs) {
|
|
2227
|
+
const end = endMs ?? Date.now();
|
|
2228
|
+
return this.db.prepare(
|
|
2229
|
+
"SELECT timestamp, value FROM metrics WHERE metric = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp"
|
|
2230
|
+
).all(metric, startMs, end);
|
|
2231
|
+
}
|
|
2232
|
+
latest(metrics) {
|
|
2233
|
+
const result = {};
|
|
2234
|
+
if (metrics && metrics.length > 0) {
|
|
2235
|
+
const placeholders = metrics.map(() => "?").join(",");
|
|
2236
|
+
const rows = this.db.prepare(
|
|
2237
|
+
`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`
|
|
2238
|
+
).all(...metrics);
|
|
2239
|
+
for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
|
|
2240
|
+
} else {
|
|
2241
|
+
const rows = this.db.prepare(
|
|
2242
|
+
"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"
|
|
2243
|
+
).all();
|
|
2244
|
+
for (const r of rows) result[r.metric] = { timestamp: r.timestamp, value: r.value };
|
|
2096
2245
|
}
|
|
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
|
-
};
|
|
2246
|
+
return result;
|
|
2247
|
+
}
|
|
2248
|
+
prune() {
|
|
2249
|
+
const cutoff = Date.now() - this.retentionMs;
|
|
2250
|
+
const info = this.db.prepare("DELETE FROM metrics WHERE timestamp < ?").run(cutoff);
|
|
2251
|
+
return info.changes;
|
|
2252
|
+
}
|
|
2253
|
+
close() {
|
|
2254
|
+
this.db.close();
|
|
2255
|
+
}
|
|
2256
|
+
};
|
|
2257
|
+
|
|
2258
|
+
// src/server/collector.ts
|
|
2259
|
+
import { EventEmitter } from "events";
|
|
2260
|
+
var Collector = class extends EventEmitter {
|
|
2261
|
+
constructor(pool, store, intervalMs = 3e4) {
|
|
2262
|
+
super();
|
|
2263
|
+
this.pool = pool;
|
|
2264
|
+
this.store = store;
|
|
2265
|
+
this.intervalMs = intervalMs;
|
|
2133
2266
|
}
|
|
2267
|
+
timer = null;
|
|
2268
|
+
pruneTimer = null;
|
|
2269
|
+
prev = null;
|
|
2270
|
+
lastSnapshot = {};
|
|
2271
|
+
collectCount = 0;
|
|
2134
2272
|
start() {
|
|
2135
|
-
this.
|
|
2273
|
+
this.collect().catch((err) => console.error("[collector] Initial collection failed:", err));
|
|
2136
2274
|
this.timer = setInterval(() => {
|
|
2137
|
-
this.
|
|
2275
|
+
this.collect().catch((err) => console.error("[collector] Collection failed:", err));
|
|
2138
2276
|
}, this.intervalMs);
|
|
2277
|
+
this.pruneTimer = setInterval(() => this.store.prune(), 60 * 60 * 1e3);
|
|
2139
2278
|
}
|
|
2140
2279
|
stop() {
|
|
2141
2280
|
if (this.timer) {
|
|
2142
2281
|
clearInterval(this.timer);
|
|
2143
2282
|
this.timer = null;
|
|
2144
2283
|
}
|
|
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);
|
|
2284
|
+
if (this.pruneTimer) {
|
|
2285
|
+
clearInterval(this.pruneTimer);
|
|
2286
|
+
this.pruneTimer = null;
|
|
2153
2287
|
}
|
|
2154
|
-
return this.db.prepare("SELECT * FROM schema_changes ORDER BY timestamp DESC LIMIT 100").all();
|
|
2155
2288
|
}
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
if (!latest) return [];
|
|
2159
|
-
return this.db.prepare("SELECT * FROM schema_changes WHERE snapshot_id = ? ORDER BY id").all(latest.id);
|
|
2289
|
+
getLastSnapshot() {
|
|
2290
|
+
return { ...this.lastSnapshot };
|
|
2160
2291
|
}
|
|
2161
|
-
|
|
2162
|
-
const
|
|
2163
|
-
const
|
|
2164
|
-
|
|
2165
|
-
|
|
2292
|
+
async collect() {
|
|
2293
|
+
const now = Date.now();
|
|
2294
|
+
const snapshot = {};
|
|
2295
|
+
try {
|
|
2296
|
+
const client = await this.pool.connect();
|
|
2297
|
+
try {
|
|
2298
|
+
const connRes = await client.query(`
|
|
2299
|
+
SELECT
|
|
2300
|
+
count(*) FILTER (WHERE state = 'active')::int AS active,
|
|
2301
|
+
count(*) FILTER (WHERE state = 'idle')::int AS idle,
|
|
2302
|
+
count(*)::int AS total
|
|
2303
|
+
FROM pg_stat_activity
|
|
2304
|
+
`);
|
|
2305
|
+
const conn = connRes.rows[0];
|
|
2306
|
+
snapshot.connections_active = conn.active;
|
|
2307
|
+
snapshot.connections_idle = conn.idle;
|
|
2308
|
+
snapshot.connections_total = conn.total;
|
|
2309
|
+
const dbRes = await client.query(`
|
|
2310
|
+
SELECT
|
|
2311
|
+
xact_commit, xact_rollback, deadlocks, temp_bytes,
|
|
2312
|
+
tup_inserted, tup_updated, tup_deleted,
|
|
2313
|
+
CASE WHEN (blks_hit + blks_read) = 0 THEN 1
|
|
2314
|
+
ELSE blks_hit::float / (blks_hit + blks_read) END AS cache_ratio,
|
|
2315
|
+
pg_database_size(current_database()) AS db_size
|
|
2316
|
+
FROM pg_stat_database WHERE datname = current_database()
|
|
2317
|
+
`);
|
|
2318
|
+
const db = dbRes.rows[0];
|
|
2319
|
+
if (db) {
|
|
2320
|
+
snapshot.cache_hit_ratio = parseFloat(db.cache_ratio);
|
|
2321
|
+
snapshot.db_size_bytes = parseInt(db.db_size);
|
|
2322
|
+
const cur = {
|
|
2323
|
+
timestamp: now,
|
|
2324
|
+
xact_commit: parseInt(db.xact_commit),
|
|
2325
|
+
xact_rollback: parseInt(db.xact_rollback),
|
|
2326
|
+
deadlocks: parseInt(db.deadlocks),
|
|
2327
|
+
temp_bytes: parseInt(db.temp_bytes),
|
|
2328
|
+
tup_inserted: parseInt(db.tup_inserted),
|
|
2329
|
+
tup_updated: parseInt(db.tup_updated),
|
|
2330
|
+
tup_deleted: parseInt(db.tup_deleted)
|
|
2331
|
+
};
|
|
2332
|
+
if (this.prev) {
|
|
2333
|
+
const dtSec = (now - this.prev.timestamp) / 1e3;
|
|
2334
|
+
if (dtSec > 0) {
|
|
2335
|
+
snapshot.tps_commit = Math.max(0, (cur.xact_commit - this.prev.xact_commit) / dtSec);
|
|
2336
|
+
snapshot.tps_rollback = Math.max(0, (cur.xact_rollback - this.prev.xact_rollback) / dtSec);
|
|
2337
|
+
snapshot.deadlocks = Math.max(0, cur.deadlocks - this.prev.deadlocks);
|
|
2338
|
+
snapshot.temp_bytes = Math.max(0, cur.temp_bytes - this.prev.temp_bytes);
|
|
2339
|
+
snapshot.tuple_inserted = Math.max(0, (cur.tup_inserted - this.prev.tup_inserted) / dtSec);
|
|
2340
|
+
snapshot.tuple_updated = Math.max(0, (cur.tup_updated - this.prev.tup_updated) / dtSec);
|
|
2341
|
+
snapshot.tuple_deleted = Math.max(0, (cur.tup_deleted - this.prev.tup_deleted) / dtSec);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
this.prev = cur;
|
|
2345
|
+
}
|
|
2346
|
+
try {
|
|
2347
|
+
const tsRes = await client.query(`SELECT spcname, pg_tablespace_size(oid) AS size FROM pg_tablespace`);
|
|
2348
|
+
let totalTablespaceSize = 0;
|
|
2349
|
+
for (const row of tsRes.rows) {
|
|
2350
|
+
totalTablespaceSize += parseInt(row.size);
|
|
2351
|
+
}
|
|
2352
|
+
if (totalTablespaceSize > 0) {
|
|
2353
|
+
snapshot.disk_used_bytes = totalTablespaceSize;
|
|
2354
|
+
}
|
|
2355
|
+
} catch {
|
|
2356
|
+
}
|
|
2357
|
+
try {
|
|
2358
|
+
const repRes = await client.query(`
|
|
2359
|
+
SELECT CASE WHEN pg_is_in_recovery()
|
|
2360
|
+
THEN pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn())
|
|
2361
|
+
ELSE 0 END AS lag_bytes
|
|
2362
|
+
`);
|
|
2363
|
+
snapshot.replication_lag_bytes = parseInt(repRes.rows[0]?.lag_bytes ?? "0");
|
|
2364
|
+
} catch {
|
|
2365
|
+
snapshot.replication_lag_bytes = 0;
|
|
2366
|
+
}
|
|
2367
|
+
} finally {
|
|
2368
|
+
client.release();
|
|
2369
|
+
}
|
|
2370
|
+
} catch (err) {
|
|
2371
|
+
console.error("[collector] Error collecting metrics:", err.message);
|
|
2372
|
+
return snapshot;
|
|
2373
|
+
}
|
|
2374
|
+
this.collectCount++;
|
|
2375
|
+
if (this.collectCount % 10 === 0) {
|
|
2376
|
+
try {
|
|
2377
|
+
const client = await this.pool.connect();
|
|
2378
|
+
try {
|
|
2379
|
+
const tableRes = await client.query(`
|
|
2380
|
+
SELECT schemaname, relname,
|
|
2381
|
+
pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) as total_size
|
|
2382
|
+
FROM pg_stat_user_tables
|
|
2383
|
+
ORDER BY pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) DESC
|
|
2384
|
+
LIMIT 20
|
|
2385
|
+
`);
|
|
2386
|
+
for (const row of tableRes.rows) {
|
|
2387
|
+
this.store.insert(`table_size:${row.schemaname}.${row.relname}`, parseInt(row.total_size), now);
|
|
2388
|
+
}
|
|
2389
|
+
} finally {
|
|
2390
|
+
client.release();
|
|
2391
|
+
}
|
|
2392
|
+
} catch (err) {
|
|
2393
|
+
console.error("[collector] Error collecting table sizes:", err.message);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
const points = Object.entries(snapshot).map(([metric, value]) => ({
|
|
2397
|
+
timestamp: now,
|
|
2398
|
+
metric,
|
|
2399
|
+
value
|
|
2400
|
+
}));
|
|
2401
|
+
if (points.length > 0) {
|
|
2402
|
+
this.store.insertMany(points);
|
|
2403
|
+
}
|
|
2404
|
+
this.lastSnapshot = snapshot;
|
|
2405
|
+
this.emit("collected", snapshot);
|
|
2406
|
+
return snapshot;
|
|
2166
2407
|
}
|
|
2167
2408
|
};
|
|
2168
2409
|
|
|
2410
|
+
// src/server/index.ts
|
|
2411
|
+
init_schema_tracker();
|
|
2412
|
+
|
|
2169
2413
|
// src/server/notifiers.ts
|
|
2170
2414
|
var SEVERITY_COLORS = {
|
|
2171
2415
|
critical: { hex: "#e74c3c", decimal: 15158332, emoji: "\u{1F534}" },
|
|
@@ -2648,6 +2892,7 @@ function registerAdvisorRoutes(app, pool, longQueryThreshold, store) {
|
|
|
2648
2892
|
}
|
|
2649
2893
|
|
|
2650
2894
|
// src/server/routes/schema.ts
|
|
2895
|
+
init_schema();
|
|
2651
2896
|
function registerSchemaRoutes(app, pool, schemaTracker) {
|
|
2652
2897
|
app.get("/api/schema/tables", async (c) => {
|
|
2653
2898
|
try {
|
|
@@ -2905,12 +3150,23 @@ async function analyzeExplainPlan(explainJson, pool) {
|
|
|
2905
3150
|
if (pool) {
|
|
2906
3151
|
existingIndexCols = await getExistingIndexColumns(pool, scan.table);
|
|
2907
3152
|
}
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
3153
|
+
const uncoveredCols = cols.filter(
|
|
3154
|
+
(col) => !existingIndexCols.some((idxCols) => idxCols.length > 0 && idxCols[0] === col)
|
|
3155
|
+
);
|
|
3156
|
+
if (uncoveredCols.length === 0) continue;
|
|
3157
|
+
const benefit = rateBenefit(scan.rowCount);
|
|
3158
|
+
if (uncoveredCols.length >= 2) {
|
|
3159
|
+
const idxName = `idx_${scan.table}_${uncoveredCols.join("_")}`;
|
|
3160
|
+
const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${uncoveredCols.join(", ")})`;
|
|
3161
|
+
result.missingIndexes.push({
|
|
3162
|
+
table: scan.table,
|
|
3163
|
+
columns: uncoveredCols,
|
|
3164
|
+
reason: `Seq Scan with multi-column filter (${uncoveredCols.join(", ")}) on ${fmtRows(scan.rowCount)} rows \u2014 composite index preferred`,
|
|
3165
|
+
sql,
|
|
3166
|
+
estimatedBenefit: benefit
|
|
3167
|
+
});
|
|
3168
|
+
} else {
|
|
3169
|
+
const col = uncoveredCols[0];
|
|
2914
3170
|
const idxName = `idx_${scan.table}_${col}`;
|
|
2915
3171
|
const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${col})`;
|
|
2916
3172
|
result.missingIndexes.push({
|
|
@@ -4185,6 +4441,14 @@ Migration check: ${filePath}`);
|
|
|
4185
4441
|
console.log(`::notice::diff-env: target has extra index: ${id.table}.${idx}`);
|
|
4186
4442
|
}
|
|
4187
4443
|
}
|
|
4444
|
+
for (const c of result.schema.constraintDiffs ?? []) {
|
|
4445
|
+
const level = c.type === "missing" ? "error" : c.type === "extra" ? "notice" : "warning";
|
|
4446
|
+
console.log(`::${level}::diff-env: constraint ${c.type}: ${c.detail}`);
|
|
4447
|
+
}
|
|
4448
|
+
for (const e of result.schema.enumDiffs ?? []) {
|
|
4449
|
+
const level = e.type === "missing" ? "error" : e.type === "extra" ? "notice" : "warning";
|
|
4450
|
+
console.log(`::${level}::diff-env: enum ${e.type}: ${e.detail}`);
|
|
4451
|
+
}
|
|
4188
4452
|
}
|
|
4189
4453
|
}
|
|
4190
4454
|
process.exit(result.summary.identical ? 0 : 1);
|