postgresai 0.15.0-dev.6 → 0.15.0-rc.0
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 +3 -1
- package/bin/postgres-ai.ts +119 -71
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +867 -232
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup.ts +225 -0
- package/lib/init.ts +195 -3
- package/lib/metrics-loader.ts +3 -1
- package/lib/supabase.ts +8 -1
- package/package.json +4 -4
- package/scripts/embed-checkup-dictionary.ts +9 -0
- package/scripts/embed-metrics.ts +2 -0
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +139 -0
- package/test/checkup.test.ts +1288 -2
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +528 -8
- package/test/monitoring.test.ts +2 -2
- package/test/permission-check-sql.test.ts +116 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test the SQL logic for checking postgres_ai.pg_statistic view existence
|
|
3
|
+
* across different permission scenarios.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect } from "bun:test";
|
|
6
|
+
|
|
7
|
+
describe("postgres_ai.pg_statistic permission check SQL", () => {
|
|
8
|
+
test("to_regclass() returns NULL when schema doesn't exist", () => {
|
|
9
|
+
// Simulate the SQL check behavior
|
|
10
|
+
const viewExists = null; // to_regclass('postgres_ai.pg_statistic') when schema doesn't exist
|
|
11
|
+
const granted = viewExists !== null;
|
|
12
|
+
|
|
13
|
+
expect(granted).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("to_regclass() returns NULL when user lacks USAGE on schema", () => {
|
|
17
|
+
// When user lacks USAGE on postgres_ai schema, to_regclass() returns NULL
|
|
18
|
+
// even if the schema and view exist
|
|
19
|
+
const viewExists = null; // to_regclass('postgres_ai.pg_statistic') when no USAGE
|
|
20
|
+
const granted = viewExists !== null;
|
|
21
|
+
|
|
22
|
+
expect(granted).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("to_regclass() returns oid when view exists and user has access", () => {
|
|
26
|
+
// When user has USAGE on schema and view exists
|
|
27
|
+
const viewExists = 12345; // to_regclass('postgres_ai.pg_statistic') returns oid
|
|
28
|
+
const granted = viewExists !== null;
|
|
29
|
+
|
|
30
|
+
expect(granted).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("has_table_privilege is skipped (returns null) when view doesn't exist", () => {
|
|
34
|
+
const viewExists = null;
|
|
35
|
+
const selectGranted = viewExists === null ? null : true; // skipped
|
|
36
|
+
|
|
37
|
+
expect(selectGranted).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("has_table_privilege is checked when view exists", () => {
|
|
41
|
+
const viewExists = 12345;
|
|
42
|
+
const userHasSelect = true;
|
|
43
|
+
const selectGranted = viewExists === null ? null : userHasSelect;
|
|
44
|
+
|
|
45
|
+
expect(selectGranted).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("Expected behavior per scenario", () => {
|
|
50
|
+
test("Scenario 1: Superuser with postgres_ai.pg_statistic", () => {
|
|
51
|
+
// to_regclass returns oid, has_table_privilege returns true
|
|
52
|
+
const checkViewExists = true; // to_regclass('postgres_ai.pg_statistic') is not null
|
|
53
|
+
const checkSelectPrivilege = true; // has_table_privilege returns true
|
|
54
|
+
|
|
55
|
+
const missingOptional: string[] = [];
|
|
56
|
+
if (!checkViewExists) {
|
|
57
|
+
missingOptional.push("postgres_ai.pg_statistic view exists");
|
|
58
|
+
}
|
|
59
|
+
if (checkSelectPrivilege === false) {
|
|
60
|
+
missingOptional.push("select on postgres_ai.pg_statistic");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(missingOptional).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("Scenario 2: pg_monitor, no postgres_ai schema access (before prepare-db)", () => {
|
|
67
|
+
// to_regclass returns NULL because user lacks USAGE on postgres_ai schema
|
|
68
|
+
const checkViewExists = false; // to_regclass('postgres_ai.pg_statistic') is null
|
|
69
|
+
const checkSelectPrivilege = null; // skipped because view doesn't exist
|
|
70
|
+
|
|
71
|
+
const missingOptional: string[] = [];
|
|
72
|
+
if (!checkViewExists) {
|
|
73
|
+
missingOptional.push("postgres_ai.pg_statistic view exists");
|
|
74
|
+
}
|
|
75
|
+
if (checkSelectPrivilege === false) {
|
|
76
|
+
missingOptional.push("select on postgres_ai.pg_statistic");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Should show warning about missing view but NOT crash
|
|
80
|
+
expect(missingOptional).toEqual(["postgres_ai.pg_statistic view exists"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("Scenario 3: No pg_monitor (before prepare-db)", () => {
|
|
84
|
+
// to_regclass returns NULL because schema doesn't exist yet
|
|
85
|
+
const checkViewExists = false; // to_regclass('postgres_ai.pg_statistic') is null
|
|
86
|
+
const checkSelectPrivilege = null; // skipped
|
|
87
|
+
|
|
88
|
+
const missingOptional: string[] = [];
|
|
89
|
+
if (!checkViewExists) {
|
|
90
|
+
missingOptional.push("postgres_ai.pg_statistic view exists");
|
|
91
|
+
}
|
|
92
|
+
if (checkSelectPrivilege === false) {
|
|
93
|
+
missingOptional.push("select on postgres_ai.pg_statistic");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Should show warning but NOT crash
|
|
97
|
+
expect(missingOptional).toEqual(["postgres_ai.pg_statistic view exists"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("Scenario 8: After prepare-db with schema grants", () => {
|
|
101
|
+
// to_regclass returns oid, has_table_privilege returns true
|
|
102
|
+
const checkViewExists = true; // to_regclass('postgres_ai.pg_statistic') is not null
|
|
103
|
+
const checkSelectPrivilege = true; // has_table_privilege returns true
|
|
104
|
+
|
|
105
|
+
const missingOptional: string[] = [];
|
|
106
|
+
if (!checkViewExists) {
|
|
107
|
+
missingOptional.push("postgres_ai.pg_statistic view exists");
|
|
108
|
+
}
|
|
109
|
+
if (checkSelectPrivilege === false) {
|
|
110
|
+
missingOptional.push("select on postgres_ai.pg_statistic");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Should be clean, no warnings
|
|
114
|
+
expect(missingOptional).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -78,4 +78,85 @@ describe("Schema validation", () => {
|
|
|
78
78
|
validateAgainstSchema(report, checkId);
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
test("I001 validates with available pg_stat_io data", () => {
|
|
83
|
+
const report = {
|
|
84
|
+
version: null,
|
|
85
|
+
build_ts: null,
|
|
86
|
+
generation_mode: null,
|
|
87
|
+
checkId: "I001",
|
|
88
|
+
checkTitle: "I/O statistics (pg_stat_io)",
|
|
89
|
+
timestamptz: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
|
90
|
+
nodes: { primary: "node-01", standbys: [] },
|
|
91
|
+
results: {
|
|
92
|
+
"node-01": {
|
|
93
|
+
data: {
|
|
94
|
+
available: true,
|
|
95
|
+
by_backend_type: [{
|
|
96
|
+
backend_type: "total",
|
|
97
|
+
reads: 10,
|
|
98
|
+
read_bytes_mb: 64,
|
|
99
|
+
read_time_ms: 20,
|
|
100
|
+
writes: 5,
|
|
101
|
+
write_bytes_mb: 32,
|
|
102
|
+
write_time_ms: 10,
|
|
103
|
+
writebacks: 4,
|
|
104
|
+
writeback_bytes_mb: 16,
|
|
105
|
+
writeback_time_ms: 8,
|
|
106
|
+
fsyncs: 2,
|
|
107
|
+
fsync_time_ms: 6,
|
|
108
|
+
extends: 3,
|
|
109
|
+
extend_bytes_mb: 24,
|
|
110
|
+
hits: 90,
|
|
111
|
+
evictions: 7,
|
|
112
|
+
reuses: 11,
|
|
113
|
+
}],
|
|
114
|
+
analysis: {
|
|
115
|
+
total_read_mb: 64,
|
|
116
|
+
total_write_mb: 32,
|
|
117
|
+
total_io_time_ms: 30,
|
|
118
|
+
read_hit_ratio_pct: 90,
|
|
119
|
+
avg_read_time_ms: 2,
|
|
120
|
+
avg_write_time_ms: 2,
|
|
121
|
+
},
|
|
122
|
+
stats_reset_s: 7200,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
validateAgainstSchema(report, "I001");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("I001 validates with unavailable pg_stat_io data", () => {
|
|
132
|
+
const report = {
|
|
133
|
+
version: null,
|
|
134
|
+
build_ts: null,
|
|
135
|
+
generation_mode: null,
|
|
136
|
+
checkId: "I001",
|
|
137
|
+
checkTitle: "I/O statistics (pg_stat_io)",
|
|
138
|
+
timestamptz: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
|
139
|
+
nodes: { primary: "node-01", standbys: [] },
|
|
140
|
+
results: {
|
|
141
|
+
"node-01": {
|
|
142
|
+
data: {
|
|
143
|
+
available: false,
|
|
144
|
+
min_version_required: "16",
|
|
145
|
+
by_backend_type: [],
|
|
146
|
+
analysis: {
|
|
147
|
+
total_read_mb: 0,
|
|
148
|
+
total_write_mb: 0,
|
|
149
|
+
total_io_time_ms: 0,
|
|
150
|
+
read_hit_ratio_pct: 0,
|
|
151
|
+
avg_read_time_ms: null,
|
|
152
|
+
avg_write_time_ms: null,
|
|
153
|
+
},
|
|
154
|
+
stats_reset_s: null,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
validateAgainstSchema(report, "I001");
|
|
161
|
+
});
|
|
81
162
|
});
|
package/test/test-utils.ts
CHANGED
|
@@ -15,6 +15,16 @@ export interface MockClientOptions {
|
|
|
15
15
|
invalidIndexesRows?: any[];
|
|
16
16
|
unusedIndexesRows?: any[];
|
|
17
17
|
redundantIndexesRows?: any[];
|
|
18
|
+
tableBloatRows?: any[];
|
|
19
|
+
indexBloatRows?: any[];
|
|
20
|
+
vacuumStatsRows?: any[];
|
|
21
|
+
deadlockStatsRows?: any[];
|
|
22
|
+
pgStatStatementsExtensionRows?: any[];
|
|
23
|
+
pgStatStatementsStatsRows?: any[];
|
|
24
|
+
pgStatStatementsSampleRows?: any[];
|
|
25
|
+
pgStatKcacheExtensionRows?: any[];
|
|
26
|
+
pgStatKcacheStatsRows?: any[];
|
|
27
|
+
pgStatKcacheSampleRows?: any[];
|
|
18
28
|
sensitiveColumnsRows?: any[];
|
|
19
29
|
}
|
|
20
30
|
|
|
@@ -46,6 +56,16 @@ export function createMockClient(options: MockClientOptions = {}) {
|
|
|
46
56
|
invalidIndexesRows = [],
|
|
47
57
|
unusedIndexesRows = [],
|
|
48
58
|
redundantIndexesRows = [],
|
|
59
|
+
tableBloatRows = [],
|
|
60
|
+
indexBloatRows = [],
|
|
61
|
+
vacuumStatsRows = [],
|
|
62
|
+
deadlockStatsRows = [{ deadlocks: "0", conflicts: "0", stats_reset: null }],
|
|
63
|
+
pgStatStatementsExtensionRows = [],
|
|
64
|
+
pgStatStatementsStatsRows = [],
|
|
65
|
+
pgStatStatementsSampleRows = [],
|
|
66
|
+
pgStatKcacheExtensionRows = [],
|
|
67
|
+
pgStatKcacheStatsRows = [],
|
|
68
|
+
pgStatKcacheSampleRows = [],
|
|
49
69
|
sensitiveColumnsRows = [],
|
|
50
70
|
} = options;
|
|
51
71
|
|
|
@@ -99,13 +119,42 @@ export function createMockClient(options: MockClientOptions = {}) {
|
|
|
99
119
|
if (sql.includes("redundant_indexes_grouped") && sql.includes("columns like")) {
|
|
100
120
|
return { rows: redundantIndexesRows };
|
|
101
121
|
}
|
|
122
|
+
// F004/F005: bloat metrics from metrics.yml
|
|
123
|
+
if (sql.includes("tag_idxname") && sql.includes("bloat_size")) {
|
|
124
|
+
return { rows: indexBloatRows };
|
|
125
|
+
}
|
|
126
|
+
if (sql.includes("tag_tblname") && sql.includes("bloat_size")) {
|
|
127
|
+
return { rows: tableBloatRows };
|
|
128
|
+
}
|
|
129
|
+
// Vacuum stats used by F004/F005
|
|
130
|
+
if (sql.includes("pg_stat_user_tables") && sql.includes("last_vacuum")) {
|
|
131
|
+
return { rows: vacuumStatsRows };
|
|
132
|
+
}
|
|
133
|
+
// G003: Deadlock/conflict stats
|
|
134
|
+
if (sql.includes("coalesce(sum(deadlocks)") && sql.includes("current_database()")) {
|
|
135
|
+
return { rows: deadlockStatsRows };
|
|
136
|
+
}
|
|
102
137
|
// D004: pg_stat_statements extension check
|
|
103
138
|
if (sql.includes("pg_extension") && sql.includes("pg_stat_statements")) {
|
|
104
|
-
return { rows:
|
|
139
|
+
return { rows: pgStatStatementsExtensionRows };
|
|
140
|
+
}
|
|
141
|
+
// D004: pg_stat_statements aggregate and sample queries
|
|
142
|
+
if (sql.includes("from pg_stat_statements") && sql.includes("count(*) as cnt")) {
|
|
143
|
+
return { rows: pgStatStatementsStatsRows };
|
|
144
|
+
}
|
|
145
|
+
if (sql.includes("from pg_stat_statements s") && sql.includes("order by calls desc")) {
|
|
146
|
+
return { rows: pgStatStatementsSampleRows };
|
|
105
147
|
}
|
|
106
148
|
// D004: pg_stat_kcache extension check
|
|
107
149
|
if (sql.includes("pg_extension") && sql.includes("pg_stat_kcache")) {
|
|
108
|
-
return { rows:
|
|
150
|
+
return { rows: pgStatKcacheExtensionRows };
|
|
151
|
+
}
|
|
152
|
+
// D004: pg_stat_kcache aggregate and sample queries
|
|
153
|
+
if (sql.includes("from pg_stat_kcache") && sql.includes("count(*) as cnt")) {
|
|
154
|
+
return { rows: pgStatKcacheStatsRows };
|
|
155
|
+
}
|
|
156
|
+
if (sql.includes("from pg_stat_kcache k") && sql.includes("order by")) {
|
|
157
|
+
return { rows: pgStatKcacheSampleRows };
|
|
109
158
|
}
|
|
110
159
|
// G001: Memory settings query
|
|
111
160
|
if (sql.includes("pg_size_bytes") && sql.includes("shared_buffers") && sql.includes("work_mem")) {
|