@checkstack/healthcheck-postgres-backend 0.0.3 → 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 CHANGED
@@ -1,5 +1,41 @@
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
+
3
39
  ## 0.0.3
4
40
 
5
41
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-postgres-backend",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
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
+ }
@@ -24,11 +24,11 @@ describe("PostgresHealthCheckStrategy", () => {
24
24
  ),
25
25
  });
26
26
 
27
- describe("execute", () => {
28
- it("should return healthy for successful connection", async () => {
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 result = await strategy.execute({
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(result.status).toBe("healthy");
41
- expect(result.metadata?.connected).toBe(true);
42
- expect(result.metadata?.querySuccess).toBe(true);
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 return unhealthy for connection error", async () => {
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
- const result = await strategy.execute({
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
- expect(result.status).toBe("unhealthy");
60
- expect(result.message).toContain("Connection refused");
61
- expect(result.metadata?.connected).toBe(false);
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 unhealthy for query error", async () => {
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
- expect(result.status).toBe("unhealthy");
79
- expect(result.metadata?.querySuccess).toBe(false);
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.status).toBe("healthy");
103
+ expect(result.error).toContain("Syntax error");
104
+
105
+ connectedClient.close();
98
106
  });
99
107
 
100
- it("should pass rowCount assertion", async () => {
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
- expect(result.status).toBe("healthy");
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.status).toBe("unhealthy");
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
- expect(result.status).toBe("healthy");
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("Username for authentication"),
51
- password: configString({ "x-secret": true }).describe(
52
- "Password for authentication"
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
- export type PostgresResult = z.infer<typeof postgresResultSchema>;
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
- avgQueryTime: healthResultNumber({
81
+ maxConnectionTime: healthResultNumber({
120
82
  "x-chart-type": "line",
121
- "x-chart-label": "Avg Query Time",
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
- export type PostgresAggregatedResult = z.infer<typeof postgresAggregatedSchema>;
97
+ type PostgresAggregatedResult = z.infer<typeof postgresAggregatedSchema>;
136
98
 
137
99
  // ============================================================================
138
100
  // DATABASE CLIENT INTERFACE (for testability)
139
101
  // ============================================================================
140
102
 
141
- export interface DbQueryResult {
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: 1,
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: 1,
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
- let totalConnectionTime = 0;
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
- for (const run of runs) {
218
- if (run.metadata?.error) {
219
- errorCount++;
220
- continue;
221
- }
222
- if (run.status === "healthy") {
223
- successCount++;
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: validRuns > 0 ? totalConnectionTime / validRuns : 0,
237
- avgQueryTime: queryRuns > 0 ? totalQueryTime / queryRuns : 0,
238
- successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
223
+ avgConnectionTime,
224
+ maxConnectionTime,
225
+ successRate,
239
226
  errorCount,
240
227
  };
241
228
  }
242
229
 
243
- async execute(
230
+ async createClient(
244
231
  config: PostgresConfigInput
245
- ): Promise<HealthCheckResult<PostgresResult>> {
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
- if (!querySuccess) {
309
- return {
310
- status: "unhealthy",
311
- latencyMs: connectionTimeMs + (queryTimeMs ?? 0),
312
- message: "Health check query failed",
313
- metadata: result,
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
- return {
318
- status: "healthy",
319
- latencyMs: connectionTimeMs + (queryTimeMs ?? 0),
320
- message: `PostgreSQL - query returned ${rowCount} row(s) in ${queryTimeMs}ms`,
321
- metadata: result,
322
- };
323
- } catch (error: unknown) {
324
- const end = performance.now();
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
+ >;