@checkstack/healthcheck-postgres-backend 0.0.2 → 0.1.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/CHANGELOG.md +43 -0
- package/package.json +1 -1
- package/src/index.ts +7 -5
- package/src/query-collector.test.ts +132 -0
- package/src/query-collector.ts +141 -0
- package/src/strategy.test.ts +46 -77
- package/src/strategy.ts +100 -171
- package/src/transport-client.ts +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @checkstack/healthcheck-postgres-backend
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f5b1f49: Refactored health check strategies to use `createClient()` pattern with built-in collectors.
|
|
8
|
+
|
|
9
|
+
**Strategy Changes:**
|
|
10
|
+
|
|
11
|
+
- Replaced `execute()` with `createClient()` that returns a transport client
|
|
12
|
+
- Strategy configs now only contain connection parameters
|
|
13
|
+
- Collector configs handle what to do with the connection
|
|
14
|
+
|
|
15
|
+
**Built-in Collectors Added:**
|
|
16
|
+
|
|
17
|
+
- DNS: `LookupCollector` for hostname resolution
|
|
18
|
+
- gRPC: `HealthCollector` for gRPC health protocol
|
|
19
|
+
- HTTP: `RequestCollector` for HTTP requests
|
|
20
|
+
- MySQL: `QueryCollector` for database queries
|
|
21
|
+
- Ping: `PingCollector` for ICMP ping
|
|
22
|
+
- Postgres: `QueryCollector` for database queries
|
|
23
|
+
- Redis: `CommandCollector` for Redis commands
|
|
24
|
+
- Script: `ExecuteCollector` for script execution
|
|
25
|
+
- SSH: `CommandCollector` for SSH commands
|
|
26
|
+
- TCP: `BannerCollector` for TCP banner grabbing
|
|
27
|
+
- TLS: `CertificateCollector` for certificate inspection
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- Updated dependencies [f5b1f49]
|
|
32
|
+
- Updated dependencies [f5b1f49]
|
|
33
|
+
- Updated dependencies [f5b1f49]
|
|
34
|
+
- Updated dependencies [f5b1f49]
|
|
35
|
+
- @checkstack/backend-api@0.1.0
|
|
36
|
+
- @checkstack/healthcheck-common@0.1.0
|
|
37
|
+
- @checkstack/common@0.0.3
|
|
38
|
+
|
|
39
|
+
## 0.0.3
|
|
40
|
+
|
|
41
|
+
### Patch Changes
|
|
42
|
+
|
|
43
|
+
- Updated dependencies [cb82e4d]
|
|
44
|
+
- @checkstack/healthcheck-common@0.0.3
|
|
45
|
+
|
|
3
46
|
## 0.0.2
|
|
4
47
|
|
|
5
48
|
### Patch Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createBackendPlugin,
|
|
3
|
-
coreServices,
|
|
4
|
-
} from "@checkstack/backend-api";
|
|
1
|
+
import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
|
|
5
2
|
import { PostgresHealthCheckStrategy } from "./strategy";
|
|
6
3
|
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
import { QueryCollector } from "./query-collector";
|
|
7
5
|
|
|
8
6
|
export default createBackendPlugin({
|
|
9
7
|
metadata: pluginMetadata,
|
|
@@ -11,13 +9,17 @@ export default createBackendPlugin({
|
|
|
11
9
|
env.registerInit({
|
|
12
10
|
deps: {
|
|
13
11
|
healthCheckRegistry: coreServices.healthCheckRegistry,
|
|
12
|
+
collectorRegistry: coreServices.collectorRegistry,
|
|
14
13
|
logger: coreServices.logger,
|
|
15
14
|
},
|
|
16
|
-
init: async ({ healthCheckRegistry, logger }) => {
|
|
15
|
+
init: async ({ healthCheckRegistry, collectorRegistry, logger }) => {
|
|
17
16
|
logger.debug("🔌 Registering PostgreSQL Health Check Strategy...");
|
|
18
17
|
const strategy = new PostgresHealthCheckStrategy();
|
|
19
18
|
healthCheckRegistry.register(strategy);
|
|
19
|
+
collectorRegistry.register(new QueryCollector());
|
|
20
20
|
},
|
|
21
21
|
});
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
export { pluginMetadata } from "./plugin-metadata";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { QueryCollector, type QueryConfig } from "./query-collector";
|
|
3
|
+
import type { PostgresTransportClient } from "./transport-client";
|
|
4
|
+
|
|
5
|
+
describe("QueryCollector", () => {
|
|
6
|
+
const createMockClient = (
|
|
7
|
+
response: {
|
|
8
|
+
rowCount?: number;
|
|
9
|
+
error?: string;
|
|
10
|
+
} = {}
|
|
11
|
+
): PostgresTransportClient => ({
|
|
12
|
+
exec: mock(() =>
|
|
13
|
+
Promise.resolve({
|
|
14
|
+
rowCount: response.rowCount ?? 1,
|
|
15
|
+
error: response.error,
|
|
16
|
+
})
|
|
17
|
+
),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("execute", () => {
|
|
21
|
+
it("should execute query successfully", async () => {
|
|
22
|
+
const collector = new QueryCollector();
|
|
23
|
+
const client = createMockClient({ rowCount: 10 });
|
|
24
|
+
|
|
25
|
+
const result = await collector.execute({
|
|
26
|
+
config: { query: "SELECT * FROM users" },
|
|
27
|
+
client,
|
|
28
|
+
pluginId: "test",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.result.rowCount).toBe(10);
|
|
32
|
+
expect(result.result.success).toBe(true);
|
|
33
|
+
expect(result.result.executionTimeMs).toBeGreaterThanOrEqual(0);
|
|
34
|
+
expect(result.error).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should return error for failed query", async () => {
|
|
38
|
+
const collector = new QueryCollector();
|
|
39
|
+
const client = createMockClient({ error: "Relation does not exist" });
|
|
40
|
+
|
|
41
|
+
const result = await collector.execute({
|
|
42
|
+
config: { query: "SELECT * FROM nonexistent" },
|
|
43
|
+
client,
|
|
44
|
+
pluginId: "test",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result.result.success).toBe(false);
|
|
48
|
+
expect(result.error).toBe("Relation does not exist");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should pass query to client", async () => {
|
|
52
|
+
const collector = new QueryCollector();
|
|
53
|
+
const client = createMockClient();
|
|
54
|
+
|
|
55
|
+
await collector.execute({
|
|
56
|
+
config: { query: "SELECT COUNT(*) FROM orders" },
|
|
57
|
+
client,
|
|
58
|
+
pluginId: "test",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(client.exec).toHaveBeenCalledWith({
|
|
62
|
+
query: "SELECT COUNT(*) FROM orders",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("aggregateResult", () => {
|
|
68
|
+
it("should calculate average execution time and success rate", () => {
|
|
69
|
+
const collector = new QueryCollector();
|
|
70
|
+
const runs = [
|
|
71
|
+
{
|
|
72
|
+
id: "1",
|
|
73
|
+
status: "healthy" as const,
|
|
74
|
+
latencyMs: 10,
|
|
75
|
+
checkId: "c1",
|
|
76
|
+
timestamp: new Date(),
|
|
77
|
+
metadata: { rowCount: 1, executionTimeMs: 50, success: true },
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "2",
|
|
81
|
+
status: "healthy" as const,
|
|
82
|
+
latencyMs: 15,
|
|
83
|
+
checkId: "c1",
|
|
84
|
+
timestamp: new Date(),
|
|
85
|
+
metadata: { rowCount: 5, executionTimeMs: 100, success: true },
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const aggregated = collector.aggregateResult(runs);
|
|
90
|
+
|
|
91
|
+
expect(aggregated.avgExecutionTimeMs).toBe(75);
|
|
92
|
+
expect(aggregated.successRate).toBe(100);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should calculate success rate correctly", () => {
|
|
96
|
+
const collector = new QueryCollector();
|
|
97
|
+
const runs = [
|
|
98
|
+
{
|
|
99
|
+
id: "1",
|
|
100
|
+
status: "healthy" as const,
|
|
101
|
+
latencyMs: 10,
|
|
102
|
+
checkId: "c1",
|
|
103
|
+
timestamp: new Date(),
|
|
104
|
+
metadata: { rowCount: 1, executionTimeMs: 50, success: true },
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "2",
|
|
108
|
+
status: "unhealthy" as const,
|
|
109
|
+
latencyMs: 15,
|
|
110
|
+
checkId: "c1",
|
|
111
|
+
timestamp: new Date(),
|
|
112
|
+
metadata: { rowCount: 0, executionTimeMs: 100, success: false },
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const aggregated = collector.aggregateResult(runs);
|
|
117
|
+
|
|
118
|
+
expect(aggregated.successRate).toBe(50);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("metadata", () => {
|
|
123
|
+
it("should have correct static properties", () => {
|
|
124
|
+
const collector = new QueryCollector();
|
|
125
|
+
|
|
126
|
+
expect(collector.id).toBe("query");
|
|
127
|
+
expect(collector.displayName).toBe("SQL Query");
|
|
128
|
+
expect(collector.allowMultiple).toBe(true);
|
|
129
|
+
expect(collector.supportedPlugins).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Versioned,
|
|
3
|
+
z,
|
|
4
|
+
type HealthCheckRunForAggregation,
|
|
5
|
+
type CollectorResult,
|
|
6
|
+
type CollectorStrategy,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import {
|
|
9
|
+
healthResultNumber,
|
|
10
|
+
healthResultBoolean,
|
|
11
|
+
} from "@checkstack/healthcheck-common";
|
|
12
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
13
|
+
import type { PostgresTransportClient } from "./transport-client";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// CONFIGURATION SCHEMA
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
const queryConfigSchema = z.object({
|
|
20
|
+
query: z.string().min(1).default("SELECT 1").describe("SQL query to execute"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type QueryConfig = z.infer<typeof queryConfigSchema>;
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// RESULT SCHEMAS
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
const queryResultSchema = z.object({
|
|
30
|
+
rowCount: healthResultNumber({
|
|
31
|
+
"x-chart-type": "counter",
|
|
32
|
+
"x-chart-label": "Row Count",
|
|
33
|
+
}),
|
|
34
|
+
executionTimeMs: healthResultNumber({
|
|
35
|
+
"x-chart-type": "line",
|
|
36
|
+
"x-chart-label": "Execution Time",
|
|
37
|
+
"x-chart-unit": "ms",
|
|
38
|
+
}),
|
|
39
|
+
success: healthResultBoolean({
|
|
40
|
+
"x-chart-type": "boolean",
|
|
41
|
+
"x-chart-label": "Success",
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type QueryResult = z.infer<typeof queryResultSchema>;
|
|
46
|
+
|
|
47
|
+
const queryAggregatedSchema = z.object({
|
|
48
|
+
avgExecutionTimeMs: healthResultNumber({
|
|
49
|
+
"x-chart-type": "line",
|
|
50
|
+
"x-chart-label": "Avg Execution Time",
|
|
51
|
+
"x-chart-unit": "ms",
|
|
52
|
+
}),
|
|
53
|
+
successRate: healthResultNumber({
|
|
54
|
+
"x-chart-type": "gauge",
|
|
55
|
+
"x-chart-label": "Success Rate",
|
|
56
|
+
"x-chart-unit": "%",
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type QueryAggregatedResult = z.infer<typeof queryAggregatedSchema>;
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// QUERY COLLECTOR
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Built-in PostgreSQL query collector.
|
|
68
|
+
* Executes SQL queries and checks results.
|
|
69
|
+
*/
|
|
70
|
+
export class QueryCollector
|
|
71
|
+
implements
|
|
72
|
+
CollectorStrategy<
|
|
73
|
+
PostgresTransportClient,
|
|
74
|
+
QueryConfig,
|
|
75
|
+
QueryResult,
|
|
76
|
+
QueryAggregatedResult
|
|
77
|
+
>
|
|
78
|
+
{
|
|
79
|
+
id = "query";
|
|
80
|
+
displayName = "SQL Query";
|
|
81
|
+
description = "Execute a SQL query and check the result";
|
|
82
|
+
|
|
83
|
+
supportedPlugins = [pluginMetadata];
|
|
84
|
+
|
|
85
|
+
allowMultiple = true;
|
|
86
|
+
|
|
87
|
+
config = new Versioned({ version: 1, schema: queryConfigSchema });
|
|
88
|
+
result = new Versioned({ version: 1, schema: queryResultSchema });
|
|
89
|
+
aggregatedResult = new Versioned({
|
|
90
|
+
version: 1,
|
|
91
|
+
schema: queryAggregatedSchema,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async execute({
|
|
95
|
+
config,
|
|
96
|
+
client,
|
|
97
|
+
}: {
|
|
98
|
+
config: QueryConfig;
|
|
99
|
+
client: PostgresTransportClient;
|
|
100
|
+
pluginId: string;
|
|
101
|
+
}): Promise<CollectorResult<QueryResult>> {
|
|
102
|
+
const startTime = Date.now();
|
|
103
|
+
|
|
104
|
+
const response = await client.exec({ query: config.query });
|
|
105
|
+
const executionTimeMs = Date.now() - startTime;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
result: {
|
|
109
|
+
rowCount: response.rowCount,
|
|
110
|
+
executionTimeMs,
|
|
111
|
+
success: !response.error,
|
|
112
|
+
},
|
|
113
|
+
error: response.error,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
aggregateResult(
|
|
118
|
+
runs: HealthCheckRunForAggregation<QueryResult>[]
|
|
119
|
+
): QueryAggregatedResult {
|
|
120
|
+
const times = runs
|
|
121
|
+
.map((r) => r.metadata?.executionTimeMs)
|
|
122
|
+
.filter((v): v is number => typeof v === "number");
|
|
123
|
+
|
|
124
|
+
const successes = runs
|
|
125
|
+
.map((r) => r.metadata?.success)
|
|
126
|
+
.filter((v): v is boolean => typeof v === "boolean");
|
|
127
|
+
|
|
128
|
+
const successCount = successes.filter(Boolean).length;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
avgExecutionTimeMs:
|
|
132
|
+
times.length > 0
|
|
133
|
+
? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
|
|
134
|
+
: 0,
|
|
135
|
+
successRate:
|
|
136
|
+
successes.length > 0
|
|
137
|
+
? Math.round((successCount / successes.length) * 100)
|
|
138
|
+
: 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/strategy.test.ts
CHANGED
|
@@ -24,11 +24,11 @@ describe("PostgresHealthCheckStrategy", () => {
|
|
|
24
24
|
),
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
describe("
|
|
28
|
-
it("should return
|
|
27
|
+
describe("createClient", () => {
|
|
28
|
+
it("should return a connected client for successful connection", async () => {
|
|
29
29
|
const strategy = new PostgresHealthCheckStrategy(createMockClient());
|
|
30
30
|
|
|
31
|
-
const
|
|
31
|
+
const connectedClient = await strategy.createClient({
|
|
32
32
|
host: "localhost",
|
|
33
33
|
port: 5432,
|
|
34
34
|
database: "test",
|
|
@@ -37,17 +37,35 @@ describe("PostgresHealthCheckStrategy", () => {
|
|
|
37
37
|
timeout: 5000,
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
expect(
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
40
|
+
expect(connectedClient.client).toBeDefined();
|
|
41
|
+
expect(connectedClient.client.exec).toBeDefined();
|
|
42
|
+
expect(connectedClient.close).toBeDefined();
|
|
43
|
+
|
|
44
|
+
connectedClient.close();
|
|
43
45
|
});
|
|
44
46
|
|
|
45
|
-
it("should
|
|
47
|
+
it("should throw for connection error", async () => {
|
|
46
48
|
const strategy = new PostgresHealthCheckStrategy(
|
|
47
49
|
createMockClient({ connectError: new Error("Connection refused") })
|
|
48
50
|
);
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
await expect(
|
|
53
|
+
strategy.createClient({
|
|
54
|
+
host: "localhost",
|
|
55
|
+
port: 5432,
|
|
56
|
+
database: "test",
|
|
57
|
+
user: "postgres",
|
|
58
|
+
password: "secret",
|
|
59
|
+
timeout: 5000,
|
|
60
|
+
})
|
|
61
|
+
).rejects.toThrow("Connection refused");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("client.exec", () => {
|
|
66
|
+
it("should execute query successfully", async () => {
|
|
67
|
+
const strategy = new PostgresHealthCheckStrategy(createMockClient());
|
|
68
|
+
const connectedClient = await strategy.createClient({
|
|
51
69
|
host: "localhost",
|
|
52
70
|
port: 5432,
|
|
53
71
|
database: "test",
|
|
@@ -56,17 +74,20 @@ describe("PostgresHealthCheckStrategy", () => {
|
|
|
56
74
|
timeout: 5000,
|
|
57
75
|
});
|
|
58
76
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
const result = await connectedClient.client.exec({
|
|
78
|
+
query: "SELECT 1",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.rowCount).toBe(1);
|
|
82
|
+
|
|
83
|
+
connectedClient.close();
|
|
62
84
|
});
|
|
63
85
|
|
|
64
|
-
it("should return
|
|
86
|
+
it("should return error for query error", async () => {
|
|
65
87
|
const strategy = new PostgresHealthCheckStrategy(
|
|
66
88
|
createMockClient({ queryError: new Error("Syntax error") })
|
|
67
89
|
);
|
|
68
|
-
|
|
69
|
-
const result = await strategy.execute({
|
|
90
|
+
const connectedClient = await strategy.createClient({
|
|
70
91
|
host: "localhost",
|
|
71
92
|
port: 5432,
|
|
72
93
|
database: "test",
|
|
@@ -75,81 +96,35 @@ describe("PostgresHealthCheckStrategy", () => {
|
|
|
75
96
|
timeout: 5000,
|
|
76
97
|
});
|
|
77
98
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("should pass connectionTime assertion when below threshold", async () => {
|
|
83
|
-
const strategy = new PostgresHealthCheckStrategy(createMockClient());
|
|
84
|
-
|
|
85
|
-
const result = await strategy.execute({
|
|
86
|
-
host: "localhost",
|
|
87
|
-
port: 5432,
|
|
88
|
-
database: "test",
|
|
89
|
-
user: "postgres",
|
|
90
|
-
password: "secret",
|
|
91
|
-
timeout: 5000,
|
|
92
|
-
assertions: [
|
|
93
|
-
{ field: "connectionTime", operator: "lessThan", value: 5000 },
|
|
94
|
-
],
|
|
99
|
+
const result = await connectedClient.client.exec({
|
|
100
|
+
query: "INVALID SQL",
|
|
95
101
|
});
|
|
96
102
|
|
|
97
|
-
expect(result.
|
|
103
|
+
expect(result.error).toContain("Syntax error");
|
|
104
|
+
|
|
105
|
+
connectedClient.close();
|
|
98
106
|
});
|
|
99
107
|
|
|
100
|
-
it("should
|
|
108
|
+
it("should return custom row count", async () => {
|
|
101
109
|
const strategy = new PostgresHealthCheckStrategy(
|
|
102
110
|
createMockClient({ rowCount: 5 })
|
|
103
111
|
);
|
|
104
|
-
|
|
105
|
-
const result = await strategy.execute({
|
|
112
|
+
const connectedClient = await strategy.createClient({
|
|
106
113
|
host: "localhost",
|
|
107
114
|
port: 5432,
|
|
108
115
|
database: "test",
|
|
109
116
|
user: "postgres",
|
|
110
117
|
password: "secret",
|
|
111
118
|
timeout: 5000,
|
|
112
|
-
assertions: [
|
|
113
|
-
{ field: "rowCount", operator: "greaterThanOrEqual", value: 1 },
|
|
114
|
-
],
|
|
115
119
|
});
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
it("should fail rowCount assertion when no rows", async () => {
|
|
121
|
-
const strategy = new PostgresHealthCheckStrategy(
|
|
122
|
-
createMockClient({ rowCount: 0 })
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const result = await strategy.execute({
|
|
126
|
-
host: "localhost",
|
|
127
|
-
port: 5432,
|
|
128
|
-
database: "test",
|
|
129
|
-
user: "postgres",
|
|
130
|
-
password: "secret",
|
|
131
|
-
timeout: 5000,
|
|
132
|
-
assertions: [{ field: "rowCount", operator: "greaterThan", value: 0 }],
|
|
121
|
+
const result = await connectedClient.client.exec({
|
|
122
|
+
query: "SELECT * FROM users",
|
|
133
123
|
});
|
|
134
124
|
|
|
135
|
-
expect(result.
|
|
136
|
-
expect(result.message).toContain("Assertion failed");
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("should pass querySuccess assertion", async () => {
|
|
140
|
-
const strategy = new PostgresHealthCheckStrategy(createMockClient());
|
|
141
|
-
|
|
142
|
-
const result = await strategy.execute({
|
|
143
|
-
host: "localhost",
|
|
144
|
-
port: 5432,
|
|
145
|
-
database: "test",
|
|
146
|
-
user: "postgres",
|
|
147
|
-
password: "secret",
|
|
148
|
-
timeout: 5000,
|
|
149
|
-
assertions: [{ field: "querySuccess", operator: "isTrue" }],
|
|
150
|
-
});
|
|
125
|
+
expect(result.rowCount).toBe(5);
|
|
151
126
|
|
|
152
|
-
|
|
127
|
+
connectedClient.close();
|
|
153
128
|
});
|
|
154
129
|
});
|
|
155
130
|
|
|
@@ -166,9 +141,7 @@ describe("PostgresHealthCheckStrategy", () => {
|
|
|
166
141
|
metadata: {
|
|
167
142
|
connected: true,
|
|
168
143
|
connectionTimeMs: 50,
|
|
169
|
-
queryTimeMs: 10,
|
|
170
144
|
rowCount: 1,
|
|
171
|
-
querySuccess: true,
|
|
172
145
|
},
|
|
173
146
|
},
|
|
174
147
|
{
|
|
@@ -180,9 +153,7 @@ describe("PostgresHealthCheckStrategy", () => {
|
|
|
180
153
|
metadata: {
|
|
181
154
|
connected: true,
|
|
182
155
|
connectionTimeMs: 100,
|
|
183
|
-
queryTimeMs: 20,
|
|
184
156
|
rowCount: 5,
|
|
185
|
-
querySuccess: true,
|
|
186
157
|
},
|
|
187
158
|
},
|
|
188
159
|
];
|
|
@@ -190,7 +161,6 @@ describe("PostgresHealthCheckStrategy", () => {
|
|
|
190
161
|
const aggregated = strategy.aggregateResult(runs);
|
|
191
162
|
|
|
192
163
|
expect(aggregated.avgConnectionTime).toBe(75);
|
|
193
|
-
expect(aggregated.avgQueryTime).toBe(15);
|
|
194
164
|
expect(aggregated.successRate).toBe(100);
|
|
195
165
|
expect(aggregated.errorCount).toBe(0);
|
|
196
166
|
});
|
|
@@ -207,7 +177,6 @@ describe("PostgresHealthCheckStrategy", () => {
|
|
|
207
177
|
metadata: {
|
|
208
178
|
connected: false,
|
|
209
179
|
connectionTimeMs: 100,
|
|
210
|
-
querySuccess: false,
|
|
211
180
|
error: "Connection refused",
|
|
212
181
|
},
|
|
213
182
|
},
|
package/src/strategy.ts
CHANGED
|
@@ -1,40 +1,29 @@
|
|
|
1
1
|
import { Client, type ClientConfig } from "pg";
|
|
2
2
|
import {
|
|
3
3
|
HealthCheckStrategy,
|
|
4
|
-
HealthCheckResult,
|
|
5
4
|
HealthCheckRunForAggregation,
|
|
6
5
|
Versioned,
|
|
7
6
|
z,
|
|
8
|
-
timeThresholdField,
|
|
9
|
-
numericField,
|
|
10
|
-
booleanField,
|
|
11
|
-
evaluateAssertions,
|
|
12
7
|
configString,
|
|
13
8
|
configNumber,
|
|
14
9
|
configBoolean,
|
|
10
|
+
type ConnectedClient,
|
|
15
11
|
} from "@checkstack/backend-api";
|
|
16
12
|
import {
|
|
17
13
|
healthResultBoolean,
|
|
18
14
|
healthResultNumber,
|
|
19
15
|
healthResultString,
|
|
20
16
|
} from "@checkstack/healthcheck-common";
|
|
17
|
+
import type {
|
|
18
|
+
PostgresTransportClient,
|
|
19
|
+
SqlQueryRequest,
|
|
20
|
+
SqlQueryResult,
|
|
21
|
+
} from "./transport-client";
|
|
21
22
|
|
|
22
23
|
// ============================================================================
|
|
23
24
|
// SCHEMAS
|
|
24
25
|
// ============================================================================
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
* Assertion schema for PostgreSQL health checks using shared factories.
|
|
28
|
-
*/
|
|
29
|
-
const postgresAssertionSchema = z.discriminatedUnion("field", [
|
|
30
|
-
timeThresholdField("connectionTime"),
|
|
31
|
-
timeThresholdField("queryTime"),
|
|
32
|
-
numericField("rowCount", { min: 0 }),
|
|
33
|
-
booleanField("querySuccess"),
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
|
-
export type PostgresAssertion = z.infer<typeof postgresAssertionSchema>;
|
|
37
|
-
|
|
38
27
|
/**
|
|
39
28
|
* Configuration schema for PostgreSQL health checks.
|
|
40
29
|
*/
|
|
@@ -47,22 +36,13 @@ export const postgresConfigSchema = z.object({
|
|
|
47
36
|
.default(5432)
|
|
48
37
|
.describe("PostgreSQL port"),
|
|
49
38
|
database: configString({}).describe("Database name"),
|
|
50
|
-
user: configString({}).describe("
|
|
51
|
-
password: configString({ "x-secret": true }).describe(
|
|
52
|
-
|
|
53
|
-
),
|
|
54
|
-
ssl: configBoolean({}).default(false).describe("Use SSL/TLS connection"),
|
|
39
|
+
user: configString({}).describe("Database user"),
|
|
40
|
+
password: configString({ "x-secret": true }).describe("Database password"),
|
|
41
|
+
ssl: configBoolean({}).default(false).describe("Use SSL connection"),
|
|
55
42
|
timeout: configNumber({})
|
|
56
43
|
.min(100)
|
|
57
44
|
.default(10_000)
|
|
58
45
|
.describe("Connection timeout in milliseconds"),
|
|
59
|
-
query: configString({})
|
|
60
|
-
.default("SELECT 1")
|
|
61
|
-
.describe("Health check query to execute"),
|
|
62
|
-
assertions: z
|
|
63
|
-
.array(postgresAssertionSchema)
|
|
64
|
-
.optional()
|
|
65
|
-
.describe("Validation conditions"),
|
|
66
46
|
});
|
|
67
47
|
|
|
68
48
|
export type PostgresConfig = z.infer<typeof postgresConfigSchema>;
|
|
@@ -81,31 +61,13 @@ const postgresResultSchema = z.object({
|
|
|
81
61
|
"x-chart-label": "Connection Time",
|
|
82
62
|
"x-chart-unit": "ms",
|
|
83
63
|
}),
|
|
84
|
-
queryTimeMs: healthResultNumber({
|
|
85
|
-
"x-chart-type": "line",
|
|
86
|
-
"x-chart-label": "Query Time",
|
|
87
|
-
"x-chart-unit": "ms",
|
|
88
|
-
}).optional(),
|
|
89
|
-
rowCount: healthResultNumber({
|
|
90
|
-
"x-chart-type": "counter",
|
|
91
|
-
"x-chart-label": "Row Count",
|
|
92
|
-
}).optional(),
|
|
93
|
-
serverVersion: healthResultString({
|
|
94
|
-
"x-chart-type": "text",
|
|
95
|
-
"x-chart-label": "Server Version",
|
|
96
|
-
}).optional(),
|
|
97
|
-
querySuccess: healthResultBoolean({
|
|
98
|
-
"x-chart-type": "boolean",
|
|
99
|
-
"x-chart-label": "Query Success",
|
|
100
|
-
}),
|
|
101
|
-
failedAssertion: postgresAssertionSchema.optional(),
|
|
102
64
|
error: healthResultString({
|
|
103
65
|
"x-chart-type": "status",
|
|
104
66
|
"x-chart-label": "Error",
|
|
105
67
|
}).optional(),
|
|
106
68
|
});
|
|
107
69
|
|
|
108
|
-
|
|
70
|
+
type PostgresResult = z.infer<typeof postgresResultSchema>;
|
|
109
71
|
|
|
110
72
|
/**
|
|
111
73
|
* Aggregated metadata for buckets.
|
|
@@ -116,9 +78,9 @@ const postgresAggregatedSchema = z.object({
|
|
|
116
78
|
"x-chart-label": "Avg Connection Time",
|
|
117
79
|
"x-chart-unit": "ms",
|
|
118
80
|
}),
|
|
119
|
-
|
|
81
|
+
maxConnectionTime: healthResultNumber({
|
|
120
82
|
"x-chart-type": "line",
|
|
121
|
-
"x-chart-label": "
|
|
83
|
+
"x-chart-label": "Max Connection Time",
|
|
122
84
|
"x-chart-unit": "ms",
|
|
123
85
|
}),
|
|
124
86
|
successRate: healthResultNumber({
|
|
@@ -132,13 +94,13 @@ const postgresAggregatedSchema = z.object({
|
|
|
132
94
|
}),
|
|
133
95
|
});
|
|
134
96
|
|
|
135
|
-
|
|
97
|
+
type PostgresAggregatedResult = z.infer<typeof postgresAggregatedSchema>;
|
|
136
98
|
|
|
137
99
|
// ============================================================================
|
|
138
100
|
// DATABASE CLIENT INTERFACE (for testability)
|
|
139
101
|
// ============================================================================
|
|
140
102
|
|
|
141
|
-
|
|
103
|
+
interface DbQueryResult {
|
|
142
104
|
rowCount: number | null;
|
|
143
105
|
}
|
|
144
106
|
|
|
@@ -154,7 +116,6 @@ const defaultDbClient: DbClient = {
|
|
|
154
116
|
async connect(config) {
|
|
155
117
|
const client = new Client(config);
|
|
156
118
|
await client.connect();
|
|
157
|
-
|
|
158
119
|
return {
|
|
159
120
|
async query(sql: string): Promise<DbQueryResult> {
|
|
160
121
|
const result = await client.query(sql);
|
|
@@ -175,6 +136,7 @@ export class PostgresHealthCheckStrategy
|
|
|
175
136
|
implements
|
|
176
137
|
HealthCheckStrategy<
|
|
177
138
|
PostgresConfig,
|
|
139
|
+
PostgresTransportClient,
|
|
178
140
|
PostgresResult,
|
|
179
141
|
PostgresAggregatedResult
|
|
180
142
|
>
|
|
@@ -190,13 +152,29 @@ export class PostgresHealthCheckStrategy
|
|
|
190
152
|
}
|
|
191
153
|
|
|
192
154
|
config: Versioned<PostgresConfig> = new Versioned({
|
|
193
|
-
version:
|
|
155
|
+
version: 2,
|
|
194
156
|
schema: postgresConfigSchema,
|
|
157
|
+
migrations: [
|
|
158
|
+
{
|
|
159
|
+
fromVersion: 1,
|
|
160
|
+
toVersion: 2,
|
|
161
|
+
description: "Migrate to createClient pattern (no config changes)",
|
|
162
|
+
migrate: (data: unknown) => data,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
195
165
|
});
|
|
196
166
|
|
|
197
167
|
result: Versioned<PostgresResult> = new Versioned({
|
|
198
|
-
version:
|
|
168
|
+
version: 2,
|
|
199
169
|
schema: postgresResultSchema,
|
|
170
|
+
migrations: [
|
|
171
|
+
{
|
|
172
|
+
fromVersion: 1,
|
|
173
|
+
toVersion: 2,
|
|
174
|
+
description: "Migrate to createClient pattern (no result changes)",
|
|
175
|
+
migrate: (data: unknown) => data,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
200
178
|
});
|
|
201
179
|
|
|
202
180
|
aggregatedResult: Versioned<PostgresAggregatedResult> = new Versioned({
|
|
@@ -207,133 +185,84 @@ export class PostgresHealthCheckStrategy
|
|
|
207
185
|
aggregateResult(
|
|
208
186
|
runs: HealthCheckRunForAggregation<PostgresResult>[]
|
|
209
187
|
): PostgresAggregatedResult {
|
|
210
|
-
|
|
211
|
-
let totalQueryTime = 0;
|
|
212
|
-
let successCount = 0;
|
|
213
|
-
let errorCount = 0;
|
|
214
|
-
let validRuns = 0;
|
|
215
|
-
let queryRuns = 0;
|
|
188
|
+
const validRuns = runs.filter((r) => r.metadata);
|
|
216
189
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
if (run.metadata) {
|
|
226
|
-
totalConnectionTime += run.metadata.connectionTimeMs;
|
|
227
|
-
if (run.metadata.queryTimeMs !== undefined) {
|
|
228
|
-
totalQueryTime += run.metadata.queryTimeMs;
|
|
229
|
-
queryRuns++;
|
|
230
|
-
}
|
|
231
|
-
validRuns++;
|
|
232
|
-
}
|
|
190
|
+
if (validRuns.length === 0) {
|
|
191
|
+
return {
|
|
192
|
+
avgConnectionTime: 0,
|
|
193
|
+
maxConnectionTime: 0,
|
|
194
|
+
successRate: 0,
|
|
195
|
+
errorCount: 0,
|
|
196
|
+
};
|
|
233
197
|
}
|
|
234
198
|
|
|
199
|
+
const connectionTimes = validRuns
|
|
200
|
+
.map((r) => r.metadata?.connectionTimeMs)
|
|
201
|
+
.filter((t): t is number => typeof t === "number");
|
|
202
|
+
|
|
203
|
+
const avgConnectionTime =
|
|
204
|
+
connectionTimes.length > 0
|
|
205
|
+
? Math.round(
|
|
206
|
+
connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
|
|
207
|
+
)
|
|
208
|
+
: 0;
|
|
209
|
+
|
|
210
|
+
const maxConnectionTime =
|
|
211
|
+
connectionTimes.length > 0 ? Math.max(...connectionTimes) : 0;
|
|
212
|
+
|
|
213
|
+
const successCount = validRuns.filter(
|
|
214
|
+
(r) => r.metadata?.connected === true
|
|
215
|
+
).length;
|
|
216
|
+
const successRate = Math.round((successCount / validRuns.length) * 100);
|
|
217
|
+
|
|
218
|
+
const errorCount = validRuns.filter(
|
|
219
|
+
(r) => r.metadata?.error !== undefined
|
|
220
|
+
).length;
|
|
221
|
+
|
|
235
222
|
return {
|
|
236
|
-
avgConnectionTime
|
|
237
|
-
|
|
238
|
-
successRate
|
|
223
|
+
avgConnectionTime,
|
|
224
|
+
maxConnectionTime,
|
|
225
|
+
successRate,
|
|
239
226
|
errorCount,
|
|
240
227
|
};
|
|
241
228
|
}
|
|
242
229
|
|
|
243
|
-
async
|
|
230
|
+
async createClient(
|
|
244
231
|
config: PostgresConfigInput
|
|
245
|
-
): Promise<
|
|
232
|
+
): Promise<ConnectedClient<PostgresTransportClient>> {
|
|
246
233
|
const validatedConfig = this.config.validate(config);
|
|
247
|
-
const start = performance.now();
|
|
248
|
-
|
|
249
|
-
try {
|
|
250
|
-
// Connect to database
|
|
251
|
-
const connection = await this.dbClient.connect({
|
|
252
|
-
host: validatedConfig.host,
|
|
253
|
-
port: validatedConfig.port,
|
|
254
|
-
database: validatedConfig.database,
|
|
255
|
-
user: validatedConfig.user,
|
|
256
|
-
password: validatedConfig.password,
|
|
257
|
-
ssl: validatedConfig.ssl ? { rejectUnauthorized: false } : false,
|
|
258
|
-
connectionTimeoutMillis: validatedConfig.timeout,
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
const connectionTimeMs = Math.round(performance.now() - start);
|
|
262
|
-
|
|
263
|
-
// Execute health check query
|
|
264
|
-
const queryStart = performance.now();
|
|
265
|
-
let querySuccess = false;
|
|
266
|
-
let rowCount: number | undefined;
|
|
267
|
-
let queryTimeMs: number | undefined;
|
|
268
|
-
|
|
269
|
-
try {
|
|
270
|
-
const result = await connection.query(validatedConfig.query);
|
|
271
|
-
querySuccess = true;
|
|
272
|
-
rowCount = result.rowCount ?? 0;
|
|
273
|
-
queryTimeMs = Math.round(performance.now() - queryStart);
|
|
274
|
-
} catch {
|
|
275
|
-
querySuccess = false;
|
|
276
|
-
queryTimeMs = Math.round(performance.now() - queryStart);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
await connection.end();
|
|
280
|
-
|
|
281
|
-
const result: Omit<PostgresResult, "failedAssertion" | "error"> = {
|
|
282
|
-
connected: true,
|
|
283
|
-
connectionTimeMs,
|
|
284
|
-
queryTimeMs,
|
|
285
|
-
rowCount,
|
|
286
|
-
querySuccess,
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
// Evaluate assertions using shared utility
|
|
290
|
-
const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
|
|
291
|
-
connectionTime: connectionTimeMs,
|
|
292
|
-
queryTime: queryTimeMs ?? 0,
|
|
293
|
-
rowCount: rowCount ?? 0,
|
|
294
|
-
querySuccess,
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
if (failedAssertion) {
|
|
298
|
-
return {
|
|
299
|
-
status: "unhealthy",
|
|
300
|
-
latencyMs: connectionTimeMs + (queryTimeMs ?? 0),
|
|
301
|
-
message: `Assertion failed: ${failedAssertion.field} ${
|
|
302
|
-
failedAssertion.operator
|
|
303
|
-
}${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
|
|
304
|
-
metadata: { ...result, failedAssertion },
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
234
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
235
|
+
const connection = await this.dbClient.connect({
|
|
236
|
+
host: validatedConfig.host,
|
|
237
|
+
port: validatedConfig.port,
|
|
238
|
+
database: validatedConfig.database,
|
|
239
|
+
user: validatedConfig.user,
|
|
240
|
+
password: validatedConfig.password,
|
|
241
|
+
ssl: validatedConfig.ssl ? { rejectUnauthorized: false } : undefined,
|
|
242
|
+
connectionTimeoutMillis: validatedConfig.timeout,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const client: PostgresTransportClient = {
|
|
246
|
+
async exec(request: SqlQueryRequest): Promise<SqlQueryResult> {
|
|
247
|
+
try {
|
|
248
|
+
const result = await connection.query(request.query);
|
|
249
|
+
return { rowCount: result.rowCount ?? 0 };
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return {
|
|
252
|
+
rowCount: 0,
|
|
253
|
+
error: error instanceof Error ? error.message : String(error),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
};
|
|
316
258
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const isError = error instanceof Error;
|
|
326
|
-
return {
|
|
327
|
-
status: "unhealthy",
|
|
328
|
-
latencyMs: Math.round(end - start),
|
|
329
|
-
message: isError ? error.message : "PostgreSQL connection failed",
|
|
330
|
-
metadata: {
|
|
331
|
-
connected: false,
|
|
332
|
-
connectionTimeMs: Math.round(end - start),
|
|
333
|
-
querySuccess: false,
|
|
334
|
-
error: isError ? error.name : "UnknownError",
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
}
|
|
259
|
+
return {
|
|
260
|
+
client,
|
|
261
|
+
close: () => {
|
|
262
|
+
connection.end().catch(() => {
|
|
263
|
+
// Ignore close errors
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
};
|
|
338
267
|
}
|
|
339
268
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TransportClient } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SQL query request.
|
|
5
|
+
*/
|
|
6
|
+
export interface SqlQueryRequest {
|
|
7
|
+
query: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* SQL query result.
|
|
12
|
+
*/
|
|
13
|
+
export interface SqlQueryResult {
|
|
14
|
+
rowCount: number;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* PostgreSQL transport client for query execution.
|
|
20
|
+
*/
|
|
21
|
+
export type PostgresTransportClient = TransportClient<
|
|
22
|
+
SqlQueryRequest,
|
|
23
|
+
SqlQueryResult
|
|
24
|
+
>;
|