@checkstack/healthcheck-mysql-backend 0.0.2

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 ADDED
@@ -0,0 +1,37 @@
1
+ # @checkstack/healthcheck-mysql-backend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/backend-api@0.0.2
10
+ - @checkstack/common@0.0.2
11
+ - @checkstack/healthcheck-common@0.0.2
12
+
13
+ ## 0.0.3
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [b4eb432]
18
+ - Updated dependencies [a65e002]
19
+ - @checkstack/backend-api@1.1.0
20
+ - @checkstack/common@0.2.0
21
+ - @checkstack/healthcheck-common@0.1.1
22
+
23
+ ## 0.0.2
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [ffc28f6]
28
+ - Updated dependencies [4dd644d]
29
+ - Updated dependencies [71275dd]
30
+ - Updated dependencies [ae19ff6]
31
+ - Updated dependencies [0babb9c]
32
+ - Updated dependencies [b55fae6]
33
+ - Updated dependencies [b354ab3]
34
+ - Updated dependencies [81f3f85]
35
+ - @checkstack/common@0.1.0
36
+ - @checkstack/backend-api@1.0.0
37
+ - @checkstack/healthcheck-common@0.1.0
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@checkstack/healthcheck-mysql-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "dependencies": {
12
+ "@checkstack/backend-api": "workspace:*",
13
+ "@checkstack/common": "workspace:*",
14
+ "@checkstack/healthcheck-common": "workspace:*",
15
+ "mysql2": "^3.9.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "^1.0.0",
19
+ "typescript": "^5.0.0",
20
+ "@checkstack/tsconfig": "workspace:*",
21
+ "@checkstack/scripts": "workspace:*"
22
+ }
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ } from "@checkstack/backend-api";
5
+ import { MysqlHealthCheckStrategy } from "./strategy";
6
+ import { pluginMetadata } from "./plugin-metadata";
7
+
8
+ export default createBackendPlugin({
9
+ metadata: pluginMetadata,
10
+ register(env) {
11
+ env.registerInit({
12
+ deps: {
13
+ healthCheckRegistry: coreServices.healthCheckRegistry,
14
+ logger: coreServices.logger,
15
+ },
16
+ init: async ({ healthCheckRegistry, logger }) => {
17
+ logger.debug("🔌 Registering MySQL Health Check Strategy...");
18
+ const strategy = new MysqlHealthCheckStrategy();
19
+ healthCheckRegistry.register(strategy);
20
+ },
21
+ });
22
+ },
23
+ });
@@ -0,0 +1,8 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the MySQL Health Check backend.
5
+ */
6
+ export const pluginMetadata = definePluginMetadata({
7
+ pluginId: "healthcheck-mysql",
8
+ });
@@ -0,0 +1,222 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { MysqlHealthCheckStrategy, DbClient } from "./strategy";
3
+
4
+ describe("MysqlHealthCheckStrategy", () => {
5
+ // Helper to create mock DB client
6
+ const createMockClient = (
7
+ config: {
8
+ rowCount?: number;
9
+ queryError?: Error;
10
+ connectError?: Error;
11
+ } = {}
12
+ ): DbClient => ({
13
+ connect: mock(() =>
14
+ config.connectError
15
+ ? Promise.reject(config.connectError)
16
+ : Promise.resolve({
17
+ query: mock(() =>
18
+ config.queryError
19
+ ? Promise.reject(config.queryError)
20
+ : Promise.resolve({ rowCount: config.rowCount ?? 1 })
21
+ ),
22
+ end: mock(() => Promise.resolve()),
23
+ })
24
+ ),
25
+ });
26
+
27
+ describe("execute", () => {
28
+ it("should return healthy for successful connection", async () => {
29
+ const strategy = new MysqlHealthCheckStrategy(createMockClient());
30
+
31
+ const result = await strategy.execute({
32
+ host: "localhost",
33
+ port: 3306,
34
+ database: "test",
35
+ user: "root",
36
+ password: "secret",
37
+ timeout: 5000,
38
+ });
39
+
40
+ expect(result.status).toBe("healthy");
41
+ expect(result.metadata?.connected).toBe(true);
42
+ expect(result.metadata?.querySuccess).toBe(true);
43
+ });
44
+
45
+ it("should return unhealthy for connection error", async () => {
46
+ const strategy = new MysqlHealthCheckStrategy(
47
+ createMockClient({ connectError: new Error("Connection refused") })
48
+ );
49
+
50
+ const result = await strategy.execute({
51
+ host: "localhost",
52
+ port: 3306,
53
+ database: "test",
54
+ user: "root",
55
+ password: "secret",
56
+ timeout: 5000,
57
+ });
58
+
59
+ expect(result.status).toBe("unhealthy");
60
+ expect(result.message).toContain("Connection refused");
61
+ expect(result.metadata?.connected).toBe(false);
62
+ });
63
+
64
+ it("should return unhealthy for query error", async () => {
65
+ const strategy = new MysqlHealthCheckStrategy(
66
+ createMockClient({ queryError: new Error("Syntax error") })
67
+ );
68
+
69
+ const result = await strategy.execute({
70
+ host: "localhost",
71
+ port: 3306,
72
+ database: "test",
73
+ user: "root",
74
+ password: "secret",
75
+ timeout: 5000,
76
+ });
77
+
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
+ ],
95
+ });
96
+
97
+ expect(result.status).toBe("healthy");
98
+ });
99
+
100
+ it("should pass rowCount assertion", async () => {
101
+ const strategy = new MysqlHealthCheckStrategy(
102
+ createMockClient({ rowCount: 5 })
103
+ );
104
+
105
+ const result = await strategy.execute({
106
+ host: "localhost",
107
+ port: 3306,
108
+ database: "test",
109
+ user: "root",
110
+ password: "secret",
111
+ timeout: 5000,
112
+ assertions: [
113
+ { field: "rowCount", operator: "greaterThanOrEqual", value: 1 },
114
+ ],
115
+ });
116
+
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 }],
133
+ });
134
+
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
+ });
151
+
152
+ expect(result.status).toBe("healthy");
153
+ });
154
+ });
155
+
156
+ describe("aggregateResult", () => {
157
+ it("should calculate averages correctly", () => {
158
+ const strategy = new MysqlHealthCheckStrategy();
159
+ const runs = [
160
+ {
161
+ id: "1",
162
+ status: "healthy" as const,
163
+ latencyMs: 100,
164
+ checkId: "c1",
165
+ timestamp: new Date(),
166
+ metadata: {
167
+ connected: true,
168
+ connectionTimeMs: 50,
169
+ queryTimeMs: 10,
170
+ rowCount: 1,
171
+ querySuccess: true,
172
+ },
173
+ },
174
+ {
175
+ id: "2",
176
+ status: "healthy" as const,
177
+ latencyMs: 150,
178
+ checkId: "c1",
179
+ timestamp: new Date(),
180
+ metadata: {
181
+ connected: true,
182
+ connectionTimeMs: 100,
183
+ queryTimeMs: 20,
184
+ rowCount: 5,
185
+ querySuccess: true,
186
+ },
187
+ },
188
+ ];
189
+
190
+ const aggregated = strategy.aggregateResult(runs);
191
+
192
+ expect(aggregated.avgConnectionTime).toBe(75);
193
+ expect(aggregated.avgQueryTime).toBe(15);
194
+ expect(aggregated.successRate).toBe(100);
195
+ expect(aggregated.errorCount).toBe(0);
196
+ });
197
+
198
+ it("should count errors", () => {
199
+ const strategy = new MysqlHealthCheckStrategy();
200
+ const runs = [
201
+ {
202
+ id: "1",
203
+ status: "unhealthy" as const,
204
+ latencyMs: 100,
205
+ checkId: "c1",
206
+ timestamp: new Date(),
207
+ metadata: {
208
+ connected: false,
209
+ connectionTimeMs: 100,
210
+ querySuccess: false,
211
+ error: "Connection refused",
212
+ },
213
+ },
214
+ ];
215
+
216
+ const aggregated = strategy.aggregateResult(runs);
217
+
218
+ expect(aggregated.errorCount).toBe(1);
219
+ expect(aggregated.successRate).toBe(0);
220
+ });
221
+ });
222
+ });
@@ -0,0 +1,344 @@
1
+ import mysql from "mysql2/promise";
2
+ import {
3
+ HealthCheckStrategy,
4
+ HealthCheckResult,
5
+ HealthCheckRunForAggregation,
6
+ Versioned,
7
+ z,
8
+ timeThresholdField,
9
+ numericField,
10
+ booleanField,
11
+ evaluateAssertions,
12
+ configString,
13
+ configNumber,
14
+ } from "@checkstack/backend-api";
15
+ import {
16
+ healthResultBoolean,
17
+ healthResultNumber,
18
+ healthResultString,
19
+ } from "@checkstack/healthcheck-common";
20
+
21
+ // ============================================================================
22
+ // SCHEMAS
23
+ // ============================================================================
24
+
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
+ /**
38
+ * Configuration schema for MySQL health checks.
39
+ */
40
+ export const mysqlConfigSchema = z.object({
41
+ host: configString({}).describe("MySQL server hostname"),
42
+ port: configNumber({})
43
+ .int()
44
+ .min(1)
45
+ .max(65_535)
46
+ .default(3306)
47
+ .describe("MySQL port"),
48
+ database: configString({}).describe("Database name"),
49
+ user: configString({}).describe("Username for authentication"),
50
+ password: configString({ "x-secret": true }).describe(
51
+ "Password for authentication"
52
+ ),
53
+ timeout: configNumber({})
54
+ .min(100)
55
+ .default(10_000)
56
+ .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
+ });
65
+
66
+ export type MysqlConfig = z.infer<typeof mysqlConfigSchema>;
67
+ export type MysqlConfigInput = z.input<typeof mysqlConfigSchema>;
68
+
69
+ /**
70
+ * Per-run result metadata.
71
+ */
72
+ const mysqlResultSchema = z.object({
73
+ connected: healthResultBoolean({
74
+ "x-chart-type": "boolean",
75
+ "x-chart-label": "Connected",
76
+ }),
77
+ connectionTimeMs: healthResultNumber({
78
+ "x-chart-type": "line",
79
+ "x-chart-label": "Connection Time",
80
+ "x-chart-unit": "ms",
81
+ }),
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
+ error: healthResultString({
97
+ "x-chart-type": "status",
98
+ "x-chart-label": "Error",
99
+ }).optional(),
100
+ });
101
+
102
+ export type MysqlResult = z.infer<typeof mysqlResultSchema>;
103
+
104
+ /**
105
+ * Aggregated metadata for buckets.
106
+ */
107
+ const mysqlAggregatedSchema = z.object({
108
+ avgConnectionTime: healthResultNumber({
109
+ "x-chart-type": "line",
110
+ "x-chart-label": "Avg Connection Time",
111
+ "x-chart-unit": "ms",
112
+ }),
113
+ avgQueryTime: healthResultNumber({
114
+ "x-chart-type": "line",
115
+ "x-chart-label": "Avg Query Time",
116
+ "x-chart-unit": "ms",
117
+ }),
118
+ successRate: healthResultNumber({
119
+ "x-chart-type": "gauge",
120
+ "x-chart-label": "Success Rate",
121
+ "x-chart-unit": "%",
122
+ }),
123
+ errorCount: healthResultNumber({
124
+ "x-chart-type": "counter",
125
+ "x-chart-label": "Errors",
126
+ }),
127
+ });
128
+
129
+ export type MysqlAggregatedResult = z.infer<typeof mysqlAggregatedSchema>;
130
+
131
+ // ============================================================================
132
+ // DATABASE CLIENT INTERFACE (for testability)
133
+ // ============================================================================
134
+
135
+ export interface DbQueryResult {
136
+ rowCount: number;
137
+ }
138
+
139
+ export interface DbConnection {
140
+ query(sql: string): Promise<DbQueryResult>;
141
+ end(): Promise<void>;
142
+ }
143
+
144
+ export interface DbClient {
145
+ connect(config: {
146
+ host: string;
147
+ port: number;
148
+ database: string;
149
+ user: string;
150
+ password: string;
151
+ connectTimeout: number;
152
+ }): Promise<DbConnection>;
153
+ }
154
+
155
+ // Default client using mysql2
156
+ const defaultDbClient: DbClient = {
157
+ async connect(config) {
158
+ const connection = await mysql.createConnection({
159
+ host: config.host,
160
+ port: config.port,
161
+ database: config.database,
162
+ user: config.user,
163
+ password: config.password,
164
+ connectTimeout: config.connectTimeout,
165
+ });
166
+
167
+ return {
168
+ 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 };
172
+ },
173
+ async end() {
174
+ await connection.end();
175
+ },
176
+ };
177
+ },
178
+ };
179
+
180
+ // ============================================================================
181
+ // STRATEGY
182
+ // ============================================================================
183
+
184
+ export class MysqlHealthCheckStrategy
185
+ implements
186
+ HealthCheckStrategy<MysqlConfig, MysqlResult, MysqlAggregatedResult>
187
+ {
188
+ id = "mysql";
189
+ displayName = "MySQL Health Check";
190
+ description = "MySQL database connectivity and query health check";
191
+
192
+ private dbClient: DbClient;
193
+
194
+ constructor(dbClient: DbClient = defaultDbClient) {
195
+ this.dbClient = dbClient;
196
+ }
197
+
198
+ config: Versioned<MysqlConfig> = new Versioned({
199
+ version: 1,
200
+ schema: mysqlConfigSchema,
201
+ });
202
+
203
+ result: Versioned<MysqlResult> = new Versioned({
204
+ version: 1,
205
+ schema: mysqlResultSchema,
206
+ });
207
+
208
+ aggregatedResult: Versioned<MysqlAggregatedResult> = new Versioned({
209
+ version: 1,
210
+ schema: mysqlAggregatedSchema,
211
+ });
212
+
213
+ aggregateResult(
214
+ runs: HealthCheckRunForAggregation<MysqlResult>[]
215
+ ): 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
+ }
239
+ }
240
+
241
+ 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,
245
+ errorCount,
246
+ };
247
+ }
248
+
249
+ async execute(
250
+ config: MysqlConfigInput
251
+ ): Promise<HealthCheckResult<MysqlResult>> {
252
+ 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
+
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
+ }
321
+
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
+ }
343
+ }
344
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }