@checkstack/healthcheck-mysql-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 CHANGED
@@ -1,5 +1,48 @@
1
1
  # @checkstack/healthcheck-mysql-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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-mysql-backend",
3
- "version": "0.0.2",
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 { MysqlHealthCheckStrategy } 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 MySQL Health Check Strategy...");
18
17
  const strategy = new MysqlHealthCheckStrategy();
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 { MysqlTransportClient } from "./transport-client";
4
+
5
+ describe("QueryCollector", () => {
6
+ const createMockClient = (
7
+ response: {
8
+ rowCount?: number;
9
+ error?: string;
10
+ } = {}
11
+ ): MysqlTransportClient => ({
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: 5 });
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(5);
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: "Table not found" });
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("Table not found");
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 { MysqlTransportClient } 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 MySQL query collector.
68
+ * Executes SQL queries and checks results.
69
+ */
70
+ export class QueryCollector
71
+ implements
72
+ CollectorStrategy<
73
+ MysqlTransportClient,
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: MysqlTransportClient;
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("MysqlHealthCheckStrategy", () => {
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 MysqlHealthCheckStrategy(createMockClient());
30
30
 
31
- const result = await strategy.execute({
31
+ const connectedClient = await strategy.createClient({
32
32
  host: "localhost",
33
33
  port: 3306,
34
34
  database: "test",
@@ -37,17 +37,35 @@ describe("MysqlHealthCheckStrategy", () => {
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 MysqlHealthCheckStrategy(
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: 3306,
56
+ database: "test",
57
+ user: "root",
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 MysqlHealthCheckStrategy(createMockClient());
68
+ const connectedClient = await strategy.createClient({
51
69
  host: "localhost",
52
70
  port: 3306,
53
71
  database: "test",
@@ -56,17 +74,20 @@ describe("MysqlHealthCheckStrategy", () => {
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 MysqlHealthCheckStrategy(
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: 3306,
72
93
  database: "test",
@@ -75,81 +96,35 @@ describe("MysqlHealthCheckStrategy", () => {
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 MysqlHealthCheckStrategy(createMockClient());
84
-
85
- const result = await strategy.execute({
86
- host: "localhost",
87
- port: 3306,
88
- database: "test",
89
- user: "root",
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 MysqlHealthCheckStrategy(
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: 3306,
108
115
  database: "test",
109
116
  user: "root",
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 MysqlHealthCheckStrategy(
122
- createMockClient({ rowCount: 0 })
123
- );
124
-
125
- const result = await strategy.execute({
126
- host: "localhost",
127
- port: 3306,
128
- database: "test",
129
- user: "root",
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 MysqlHealthCheckStrategy(createMockClient());
141
-
142
- const result = await strategy.execute({
143
- host: "localhost",
144
- port: 3306,
145
- database: "test",
146
- user: "root",
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("MysqlHealthCheckStrategy", () => {
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("MysqlHealthCheckStrategy", () => {
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("MysqlHealthCheckStrategy", () => {
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("MysqlHealthCheckStrategy", () => {
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,39 +1,28 @@
1
1
  import mysql from "mysql2/promise";
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,
9
+ type ConnectedClient,
14
10
  } from "@checkstack/backend-api";
15
11
  import {
16
12
  healthResultBoolean,
17
13
  healthResultNumber,
18
14
  healthResultString,
19
15
  } from "@checkstack/healthcheck-common";
16
+ import type {
17
+ MysqlTransportClient,
18
+ SqlQueryRequest,
19
+ SqlQueryResult,
20
+ } from "./transport-client";
20
21
 
21
22
  // ============================================================================
22
23
  // SCHEMAS
23
24
  // ============================================================================
24
25
 
25
- /**
26
- * Assertion schema for MySQL health checks using shared factories.
27
- */
28
- const mysqlAssertionSchema = z.discriminatedUnion("field", [
29
- timeThresholdField("connectionTime"),
30
- timeThresholdField("queryTime"),
31
- numericField("rowCount", { min: 0 }),
32
- booleanField("querySuccess"),
33
- ]);
34
-
35
- export type MysqlAssertion = z.infer<typeof mysqlAssertionSchema>;
36
-
37
26
  /**
38
27
  * Configuration schema for MySQL health checks.
39
28
  */
@@ -46,21 +35,12 @@ export const mysqlConfigSchema = z.object({
46
35
  .default(3306)
47
36
  .describe("MySQL port"),
48
37
  database: configString({}).describe("Database name"),
49
- user: configString({}).describe("Username for authentication"),
50
- password: configString({ "x-secret": true }).describe(
51
- "Password for authentication"
52
- ),
38
+ user: configString({}).describe("Database user"),
39
+ password: configString({ "x-secret": true }).describe("Database password"),
53
40
  timeout: configNumber({})
54
41
  .min(100)
55
42
  .default(10_000)
56
43
  .describe("Connection timeout in milliseconds"),
57
- query: configString({})
58
- .default("SELECT 1")
59
- .describe("Health check query to execute"),
60
- assertions: z
61
- .array(mysqlAssertionSchema)
62
- .optional()
63
- .describe("Validation conditions"),
64
44
  });
65
45
 
66
46
  export type MysqlConfig = z.infer<typeof mysqlConfigSchema>;
@@ -79,27 +59,13 @@ const mysqlResultSchema = z.object({
79
59
  "x-chart-label": "Connection Time",
80
60
  "x-chart-unit": "ms",
81
61
  }),
82
- queryTimeMs: healthResultNumber({
83
- "x-chart-type": "line",
84
- "x-chart-label": "Query Time",
85
- "x-chart-unit": "ms",
86
- }).optional(),
87
- rowCount: healthResultNumber({
88
- "x-chart-type": "counter",
89
- "x-chart-label": "Row Count",
90
- }).optional(),
91
- querySuccess: healthResultBoolean({
92
- "x-chart-type": "boolean",
93
- "x-chart-label": "Query Success",
94
- }),
95
- failedAssertion: mysqlAssertionSchema.optional(),
96
62
  error: healthResultString({
97
63
  "x-chart-type": "status",
98
64
  "x-chart-label": "Error",
99
65
  }).optional(),
100
66
  });
101
67
 
102
- export type MysqlResult = z.infer<typeof mysqlResultSchema>;
68
+ type MysqlResult = z.infer<typeof mysqlResultSchema>;
103
69
 
104
70
  /**
105
71
  * Aggregated metadata for buckets.
@@ -110,9 +76,9 @@ const mysqlAggregatedSchema = z.object({
110
76
  "x-chart-label": "Avg Connection Time",
111
77
  "x-chart-unit": "ms",
112
78
  }),
113
- avgQueryTime: healthResultNumber({
79
+ maxConnectionTime: healthResultNumber({
114
80
  "x-chart-type": "line",
115
- "x-chart-label": "Avg Query Time",
81
+ "x-chart-label": "Max Connection Time",
116
82
  "x-chart-unit": "ms",
117
83
  }),
118
84
  successRate: healthResultNumber({
@@ -126,17 +92,17 @@ const mysqlAggregatedSchema = z.object({
126
92
  }),
127
93
  });
128
94
 
129
- export type MysqlAggregatedResult = z.infer<typeof mysqlAggregatedSchema>;
95
+ type MysqlAggregatedResult = z.infer<typeof mysqlAggregatedSchema>;
130
96
 
131
97
  // ============================================================================
132
98
  // DATABASE CLIENT INTERFACE (for testability)
133
99
  // ============================================================================
134
100
 
135
- export interface DbQueryResult {
101
+ interface DbQueryResult {
136
102
  rowCount: number;
137
103
  }
138
104
 
139
- export interface DbConnection {
105
+ interface DbConnection {
140
106
  query(sql: string): Promise<DbQueryResult>;
141
107
  end(): Promise<void>;
142
108
  }
@@ -166,9 +132,8 @@ const defaultDbClient: DbClient = {
166
132
 
167
133
  return {
168
134
  async query(sql: string): Promise<DbQueryResult> {
169
- const [rows] = await connection.query(sql);
170
- const rowCount = Array.isArray(rows) ? rows.length : 0;
171
- return { rowCount };
135
+ const [rows] = await connection.execute(sql);
136
+ return { rowCount: Array.isArray(rows) ? rows.length : 0 };
172
137
  },
173
138
  async end() {
174
139
  await connection.end();
@@ -183,7 +148,12 @@ const defaultDbClient: DbClient = {
183
148
 
184
149
  export class MysqlHealthCheckStrategy
185
150
  implements
186
- HealthCheckStrategy<MysqlConfig, MysqlResult, MysqlAggregatedResult>
151
+ HealthCheckStrategy<
152
+ MysqlConfig,
153
+ MysqlTransportClient,
154
+ MysqlResult,
155
+ MysqlAggregatedResult
156
+ >
187
157
  {
188
158
  id = "mysql";
189
159
  displayName = "MySQL Health Check";
@@ -196,13 +166,29 @@ export class MysqlHealthCheckStrategy
196
166
  }
197
167
 
198
168
  config: Versioned<MysqlConfig> = new Versioned({
199
- version: 1,
169
+ version: 2,
200
170
  schema: mysqlConfigSchema,
171
+ migrations: [
172
+ {
173
+ fromVersion: 1,
174
+ toVersion: 2,
175
+ description: "Migrate to createClient pattern (no config changes)",
176
+ migrate: (data: unknown) => data,
177
+ },
178
+ ],
201
179
  });
202
180
 
203
181
  result: Versioned<MysqlResult> = new Versioned({
204
- version: 1,
182
+ version: 2,
205
183
  schema: mysqlResultSchema,
184
+ migrations: [
185
+ {
186
+ fromVersion: 1,
187
+ toVersion: 2,
188
+ description: "Migrate to createClient pattern (no result changes)",
189
+ migrate: (data: unknown) => data,
190
+ },
191
+ ],
206
192
  });
207
193
 
208
194
  aggregatedResult: Versioned<MysqlAggregatedResult> = new Versioned({
@@ -213,132 +199,83 @@ export class MysqlHealthCheckStrategy
213
199
  aggregateResult(
214
200
  runs: HealthCheckRunForAggregation<MysqlResult>[]
215
201
  ): MysqlAggregatedResult {
216
- let totalConnectionTime = 0;
217
- let totalQueryTime = 0;
218
- let successCount = 0;
219
- let errorCount = 0;
220
- let validRuns = 0;
221
- let queryRuns = 0;
222
-
223
- for (const run of runs) {
224
- if (run.metadata?.error) {
225
- errorCount++;
226
- continue;
227
- }
228
- if (run.status === "healthy") {
229
- successCount++;
230
- }
231
- if (run.metadata) {
232
- totalConnectionTime += run.metadata.connectionTimeMs;
233
- if (run.metadata.queryTimeMs !== undefined) {
234
- totalQueryTime += run.metadata.queryTimeMs;
235
- queryRuns++;
236
- }
237
- validRuns++;
238
- }
202
+ const validRuns = runs.filter((r) => r.metadata);
203
+
204
+ if (validRuns.length === 0) {
205
+ return {
206
+ avgConnectionTime: 0,
207
+ maxConnectionTime: 0,
208
+ successRate: 0,
209
+ errorCount: 0,
210
+ };
239
211
  }
240
212
 
213
+ const connectionTimes = validRuns
214
+ .map((r) => r.metadata?.connectionTimeMs)
215
+ .filter((t): t is number => typeof t === "number");
216
+
217
+ const avgConnectionTime =
218
+ connectionTimes.length > 0
219
+ ? Math.round(
220
+ connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
221
+ )
222
+ : 0;
223
+
224
+ const maxConnectionTime =
225
+ connectionTimes.length > 0 ? Math.max(...connectionTimes) : 0;
226
+
227
+ const successCount = validRuns.filter(
228
+ (r) => r.metadata?.connected === true
229
+ ).length;
230
+ const successRate = Math.round((successCount / validRuns.length) * 100);
231
+
232
+ const errorCount = validRuns.filter(
233
+ (r) => r.metadata?.error !== undefined
234
+ ).length;
235
+
241
236
  return {
242
- avgConnectionTime: validRuns > 0 ? totalConnectionTime / validRuns : 0,
243
- avgQueryTime: queryRuns > 0 ? totalQueryTime / queryRuns : 0,
244
- successRate: runs.length > 0 ? (successCount / runs.length) * 100 : 0,
237
+ avgConnectionTime,
238
+ maxConnectionTime,
239
+ successRate,
245
240
  errorCount,
246
241
  };
247
242
  }
248
243
 
249
- async execute(
244
+ async createClient(
250
245
  config: MysqlConfigInput
251
- ): Promise<HealthCheckResult<MysqlResult>> {
246
+ ): Promise<ConnectedClient<MysqlTransportClient>> {
252
247
  const validatedConfig = this.config.validate(config);
253
- const start = performance.now();
254
-
255
- try {
256
- // Connect to database
257
- const connection = await this.dbClient.connect({
258
- host: validatedConfig.host,
259
- port: validatedConfig.port,
260
- database: validatedConfig.database,
261
- user: validatedConfig.user,
262
- password: validatedConfig.password,
263
- connectTimeout: validatedConfig.timeout,
264
- });
265
-
266
- const connectionTimeMs = Math.round(performance.now() - start);
267
-
268
- // Execute health check query
269
- const queryStart = performance.now();
270
- let querySuccess = false;
271
- let rowCount: number | undefined;
272
- let queryTimeMs: number | undefined;
273
-
274
- try {
275
- const result = await connection.query(validatedConfig.query);
276
- querySuccess = true;
277
- rowCount = result.rowCount;
278
- queryTimeMs = Math.round(performance.now() - queryStart);
279
- } catch {
280
- querySuccess = false;
281
- queryTimeMs = Math.round(performance.now() - queryStart);
282
- }
283
-
284
- await connection.end();
285
-
286
- const result: Omit<MysqlResult, "failedAssertion" | "error"> = {
287
- connected: true,
288
- connectionTimeMs,
289
- queryTimeMs,
290
- rowCount,
291
- querySuccess,
292
- };
293
248
 
294
- // Evaluate assertions using shared utility
295
- const failedAssertion = evaluateAssertions(validatedConfig.assertions, {
296
- connectionTime: connectionTimeMs,
297
- queryTime: queryTimeMs ?? 0,
298
- rowCount: rowCount ?? 0,
299
- querySuccess,
300
- });
301
-
302
- if (failedAssertion) {
303
- return {
304
- status: "unhealthy",
305
- latencyMs: connectionTimeMs + (queryTimeMs ?? 0),
306
- message: `Assertion failed: ${failedAssertion.field} ${
307
- failedAssertion.operator
308
- }${"value" in failedAssertion ? ` ${failedAssertion.value}` : ""}`,
309
- metadata: { ...result, failedAssertion },
310
- };
311
- }
312
-
313
- if (!querySuccess) {
314
- return {
315
- status: "unhealthy",
316
- latencyMs: connectionTimeMs + (queryTimeMs ?? 0),
317
- message: "Health check query failed",
318
- metadata: result,
319
- };
320
- }
249
+ const connection = await this.dbClient.connect({
250
+ host: validatedConfig.host,
251
+ port: validatedConfig.port,
252
+ database: validatedConfig.database,
253
+ user: validatedConfig.user,
254
+ password: validatedConfig.password,
255
+ connectTimeout: validatedConfig.timeout,
256
+ });
321
257
 
322
- return {
323
- status: "healthy",
324
- latencyMs: connectionTimeMs + (queryTimeMs ?? 0),
325
- message: `MySQL - query returned ${rowCount} row(s) in ${queryTimeMs}ms`,
326
- metadata: result,
327
- };
328
- } catch (error: unknown) {
329
- const end = performance.now();
330
- const isError = error instanceof Error;
331
- return {
332
- status: "unhealthy",
333
- latencyMs: Math.round(end - start),
334
- message: isError ? error.message : "MySQL connection failed",
335
- metadata: {
336
- connected: false,
337
- connectionTimeMs: Math.round(end - start),
338
- querySuccess: false,
339
- error: isError ? error.name : "UnknownError",
340
- },
341
- };
342
- }
258
+ const client: MysqlTransportClient = {
259
+ async exec(request: SqlQueryRequest): Promise<SqlQueryResult> {
260
+ try {
261
+ const result = await connection.query(request.query);
262
+ return { rowCount: result.rowCount };
263
+ } catch (error) {
264
+ return {
265
+ rowCount: 0,
266
+ error: error instanceof Error ? error.message : String(error),
267
+ };
268
+ }
269
+ },
270
+ };
271
+
272
+ return {
273
+ client,
274
+ close: () => {
275
+ connection.end().catch(() => {
276
+ // Ignore close errors
277
+ });
278
+ },
279
+ };
343
280
  }
344
281
  }
@@ -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
+ * MySQL transport client for query execution.
20
+ */
21
+ export type MysqlTransportClient = TransportClient<
22
+ SqlQueryRequest,
23
+ SqlQueryResult
24
+ >;