postgresai 0.14.0-dev.86 → 0.14.0-dev.87
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/bin/postgres-ai.ts +171 -12
- package/dist/bin/postgres-ai.js +367 -15
- package/lib/checkup-api.ts +47 -1
- package/lib/checkup-summary.ts +283 -0
- package/package.json +1 -1
- package/test/checkup.integration.test.ts +27 -0
- package/test/checkup.test.ts +580 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate human-readable summaries from checkup report JSON.
|
|
3
|
+
* Used for default CLI output without requiring API calls.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: This file uses `any` types for report structures to maintain flexibility
|
|
6
|
+
* with the dynamic JSON schema from the API. Future improvement: define proper
|
|
7
|
+
* TypeScript interfaces for report structures based on schema files.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface CheckSummary {
|
|
11
|
+
status: 'ok' | 'warning' | 'info';
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract summary information from a checkup report.
|
|
17
|
+
* Parses the JSON structure to extract key metrics for CLI display.
|
|
18
|
+
*/
|
|
19
|
+
export function generateCheckSummary(checkId: string, report: any): CheckSummary {
|
|
20
|
+
const nodeIds = Object.keys(report.results || {});
|
|
21
|
+
if (nodeIds.length === 0) {
|
|
22
|
+
return { status: 'info', message: 'No data' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Take first node for summary (most deployments use single node)
|
|
26
|
+
const nodeData = report.results[nodeIds[0]];
|
|
27
|
+
|
|
28
|
+
switch (checkId) {
|
|
29
|
+
// Index health checks
|
|
30
|
+
case 'H001': return summarizeH001(nodeData);
|
|
31
|
+
case 'H002': return summarizeH002(nodeData);
|
|
32
|
+
case 'H004': return summarizeH004(nodeData);
|
|
33
|
+
// Version checks
|
|
34
|
+
case 'A002': return summarizeA002(nodeData);
|
|
35
|
+
case 'A013': return summarizeA013(nodeData);
|
|
36
|
+
// Settings checks
|
|
37
|
+
case 'A003': return summarizeA003(nodeData);
|
|
38
|
+
case 'A004': return summarizeA004(nodeData);
|
|
39
|
+
case 'A007': return summarizeA007(nodeData);
|
|
40
|
+
case 'D001': return summarizeD001(nodeData);
|
|
41
|
+
case 'D004': return summarizeD004(nodeData);
|
|
42
|
+
case 'F001': return summarizeF001(nodeData);
|
|
43
|
+
case 'G001': return summarizeG001(nodeData);
|
|
44
|
+
case 'G003': return summarizeG003(nodeData);
|
|
45
|
+
default:
|
|
46
|
+
return { status: 'info', message: 'Check completed' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function summarizeA003(nodeData: any): CheckSummary {
|
|
51
|
+
const data = nodeData?.data || {};
|
|
52
|
+
const settingsCount = Object.keys(data).length;
|
|
53
|
+
|
|
54
|
+
if (settingsCount === 0) {
|
|
55
|
+
return { status: 'info', message: 'No settings found' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
status: 'info',
|
|
60
|
+
message: `${settingsCount} setting${settingsCount > 1 ? 's' : ''} collected`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function summarizeA004(nodeData: any): CheckSummary {
|
|
65
|
+
const data = nodeData?.data;
|
|
66
|
+
if (!data) {
|
|
67
|
+
return { status: 'info', message: 'Cluster information collected' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dbCount = Object.keys(data.database_sizes || {}).length;
|
|
71
|
+
if (dbCount > 0) {
|
|
72
|
+
return { status: 'info', message: `${dbCount} database${dbCount > 1 ? 's' : ''} analyzed` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { status: 'info', message: 'Cluster information collected' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function summarizeA007(nodeData: any): CheckSummary {
|
|
79
|
+
const data = nodeData?.data || {};
|
|
80
|
+
const alteredCount = Object.keys(data).length;
|
|
81
|
+
|
|
82
|
+
if (alteredCount === 0) {
|
|
83
|
+
return { status: 'ok', message: 'No altered settings' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
status: 'info',
|
|
88
|
+
message: `${alteredCount} setting${alteredCount > 1 ? 's' : ''} altered from defaults`
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatBytes(bytes: number): string {
|
|
93
|
+
if (bytes === 0) return '0 B';
|
|
94
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
95
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KiB`;
|
|
96
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MiB`;
|
|
97
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function summarizeH001(nodeData: any): CheckSummary {
|
|
101
|
+
const data = nodeData?.data || {};
|
|
102
|
+
let totalCount = 0;
|
|
103
|
+
let totalSize = 0;
|
|
104
|
+
|
|
105
|
+
// Aggregate across all databases
|
|
106
|
+
for (const dbData of Object.values(data)) {
|
|
107
|
+
const dbEntry = dbData as any;
|
|
108
|
+
totalCount += dbEntry.total_count || 0;
|
|
109
|
+
totalSize += dbEntry.total_size_bytes || 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (totalCount === 0) {
|
|
113
|
+
return { status: 'ok', message: 'No invalid indexes' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
status: 'warning',
|
|
118
|
+
message: `Found ${totalCount} invalid index${totalCount > 1 ? 'es' : ''} (${formatBytes(totalSize)})`
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function summarizeH002(nodeData: any): CheckSummary {
|
|
123
|
+
const data = nodeData?.data || {};
|
|
124
|
+
let totalCount = 0;
|
|
125
|
+
let totalSize = 0;
|
|
126
|
+
|
|
127
|
+
// Aggregate across all databases
|
|
128
|
+
for (const dbData of Object.values(data)) {
|
|
129
|
+
const dbEntry = dbData as any;
|
|
130
|
+
totalCount += dbEntry.total_count || 0;
|
|
131
|
+
totalSize += dbEntry.total_size_bytes || 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (totalCount === 0) {
|
|
135
|
+
return { status: 'ok', message: 'All indexes utilized' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
status: 'warning',
|
|
140
|
+
message: `Found ${totalCount} unused index${totalCount > 1 ? 'es' : ''} (${formatBytes(totalSize)})`
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function summarizeH004(nodeData: any): CheckSummary {
|
|
145
|
+
const data = nodeData?.data || {};
|
|
146
|
+
let totalCount = 0;
|
|
147
|
+
let totalSize = 0;
|
|
148
|
+
|
|
149
|
+
// Aggregate across all databases
|
|
150
|
+
for (const dbData of Object.values(data)) {
|
|
151
|
+
const dbEntry = dbData as any;
|
|
152
|
+
totalCount += dbEntry.total_count || 0;
|
|
153
|
+
totalSize += dbEntry.total_size_bytes || 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (totalCount === 0) {
|
|
157
|
+
return { status: 'ok', message: 'No redundant indexes' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
status: 'warning',
|
|
162
|
+
message: `Found ${totalCount} redundant index${totalCount > 1 ? 'es' : ''} (${formatBytes(totalSize)})`
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function summarizeA002(nodeData: any): CheckSummary {
|
|
167
|
+
// A002 stores version in data.version (not postgres_version)
|
|
168
|
+
const ver = nodeData?.data?.version;
|
|
169
|
+
if (!ver) {
|
|
170
|
+
return { status: 'info', message: 'Version checked' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const major = parseInt(ver.server_major_ver, 10);
|
|
174
|
+
|
|
175
|
+
// PostgreSQL 17 is current (as of early 2025)
|
|
176
|
+
if (major >= 17) {
|
|
177
|
+
return { status: 'ok', message: `PostgreSQL ${major}` };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (major >= 15) {
|
|
181
|
+
return { status: 'info', message: `PostgreSQL ${major}` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
status: 'warning',
|
|
186
|
+
message: `PostgreSQL ${major} (consider upgrading)`
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function summarizeA013(nodeData: any): CheckSummary {
|
|
191
|
+
// A013 stores version in data.version (not postgres_version)
|
|
192
|
+
const ver = nodeData?.data?.version;
|
|
193
|
+
if (!ver) {
|
|
194
|
+
return { status: 'info', message: 'Minor version checked' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const current = ver.version || '';
|
|
198
|
+
return {
|
|
199
|
+
status: 'info',
|
|
200
|
+
message: `Version ${current}`
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function summarizeD001(nodeData: any): CheckSummary {
|
|
205
|
+
const data = nodeData?.data || {};
|
|
206
|
+
const settingsCount = Object.keys(data).length;
|
|
207
|
+
|
|
208
|
+
if (settingsCount === 0) {
|
|
209
|
+
return { status: 'info', message: 'No logging settings found' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
status: 'info',
|
|
214
|
+
message: `${settingsCount} logging setting${settingsCount > 1 ? 's' : ''} collected`
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function summarizeD004(nodeData: any): CheckSummary {
|
|
219
|
+
const data = nodeData?.data || {};
|
|
220
|
+
const settingsCount = Object.keys(data).length;
|
|
221
|
+
|
|
222
|
+
if (settingsCount === 0) {
|
|
223
|
+
return { status: 'info', message: 'No pg_stat_statements settings found' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
status: 'info',
|
|
228
|
+
message: `${settingsCount} pg_stat_statements setting${settingsCount > 1 ? 's' : ''} collected`
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function summarizeF001(nodeData: any): CheckSummary {
|
|
233
|
+
const data = nodeData?.data || {};
|
|
234
|
+
const settingsCount = Object.keys(data).length;
|
|
235
|
+
|
|
236
|
+
if (settingsCount === 0) {
|
|
237
|
+
return { status: 'info', message: 'No autovacuum settings found' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
status: 'info',
|
|
242
|
+
message: `${settingsCount} autovacuum setting${settingsCount > 1 ? 's' : ''} collected`
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function summarizeG001(nodeData: any): CheckSummary {
|
|
247
|
+
const data = nodeData?.data || {};
|
|
248
|
+
const settingsCount = Object.keys(data).length;
|
|
249
|
+
|
|
250
|
+
if (settingsCount === 0) {
|
|
251
|
+
return { status: 'info', message: 'No memory settings found' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
status: 'info',
|
|
256
|
+
message: `${settingsCount} memory setting${settingsCount > 1 ? 's' : ''} collected`
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function summarizeG003(nodeData: any): CheckSummary {
|
|
261
|
+
const data = nodeData?.data || {};
|
|
262
|
+
|
|
263
|
+
// G003 has settings and deadlock_stats
|
|
264
|
+
const settings = data.settings || {};
|
|
265
|
+
const deadlockStats = data.deadlock_stats;
|
|
266
|
+
const settingsCount = Object.keys(settings).length;
|
|
267
|
+
|
|
268
|
+
if (deadlockStats && typeof deadlockStats.deadlocks === 'number' && deadlockStats.deadlocks > 0) {
|
|
269
|
+
return {
|
|
270
|
+
status: 'warning',
|
|
271
|
+
message: `${deadlockStats.deadlocks} deadlock${deadlockStats.deadlocks > 1 ? 's' : ''} detected`
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (settingsCount === 0) {
|
|
276
|
+
return { status: 'info', message: 'No timeout/lock settings found' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
status: 'info',
|
|
281
|
+
message: `${settingsCount} timeout/lock setting${settingsCount > 1 ? 's' : ''} collected`
|
|
282
|
+
};
|
|
283
|
+
}
|
package/package.json
CHANGED
|
@@ -174,6 +174,11 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
|
|
|
174
174
|
|
|
175
175
|
// 60s timeout for hooks - PostgreSQL startup can take 30s+ in slow CI
|
|
176
176
|
beforeAll(async () => {
|
|
177
|
+
// Create empty config directory for tests
|
|
178
|
+
const emptyConfigDir = "/tmp/postgresai-test-empty-config/postgresai";
|
|
179
|
+
fs.mkdirSync(emptyConfigDir, { recursive: true });
|
|
180
|
+
fs.writeFileSync(path.join(emptyConfigDir, "config.json"), "{}");
|
|
181
|
+
|
|
177
182
|
pg = await createTempPostgres();
|
|
178
183
|
client = await pg.connect();
|
|
179
184
|
}, { timeout: 60000 });
|
|
@@ -318,4 +323,26 @@ describe.skipIf(!!skipReason)("checkup integration: express mode schema compatib
|
|
|
318
323
|
expect(nodeResult).toHaveProperty("data");
|
|
319
324
|
expect(typeof nodeResult.data).toBe("object");
|
|
320
325
|
});
|
|
326
|
+
|
|
327
|
+
test("CLI --markdown flag works without API key", async () => {
|
|
328
|
+
// Test that --markdown works even without an API key
|
|
329
|
+
const connString = `postgresql://postgres@${pg.socketDir}:${pg.port}/postgres`;
|
|
330
|
+
const cliPath = path.resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
|
|
331
|
+
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
|
|
332
|
+
|
|
333
|
+
const result = Bun.spawnSync(
|
|
334
|
+
[bunBin, cliPath, "checkup", connString, "--check-id", "H002", "--markdown", "--no-upload"],
|
|
335
|
+
{
|
|
336
|
+
env: {
|
|
337
|
+
...process.env,
|
|
338
|
+
XDG_CONFIG_HOME: "/tmp/postgresai-test-empty-config",
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
344
|
+
|
|
345
|
+
// Should not complain about missing API key
|
|
346
|
+
expect(stderr).not.toMatch(/API key is required/i);
|
|
347
|
+
});
|
|
321
348
|
});
|