@checkstack/healthcheck-postgres-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-postgres-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,24 @@
1
+ {
2
+ "name": "@checkstack/healthcheck-postgres-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
+ "pg": "^8.11.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "^1.0.0",
19
+ "@types/pg": "^8.11.0",
20
+ "typescript": "^5.0.0",
21
+ "@checkstack/tsconfig": "workspace:*",
22
+ "@checkstack/scripts": "workspace:*"
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ } from "@checkstack/backend-api";
5
+ import { PostgresHealthCheckStrategy } 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 PostgreSQL Health Check Strategy...");
18
+ const strategy = new PostgresHealthCheckStrategy();
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 PostgreSQL Health Check backend.
5
+ */
6
+ export const pluginMetadata = definePluginMetadata({
7
+ pluginId: "healthcheck-postgres",
8
+ });
@@ -0,0 +1,222 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { PostgresHealthCheckStrategy, DbClient } from "./strategy";
3
+
4
+ describe("PostgresHealthCheckStrategy", () => {
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 PostgresHealthCheckStrategy(createMockClient());
30
+
31
+ const result = await strategy.execute({
32
+ host: "localhost",
33
+ port: 5432,
34
+ database: "test",
35
+ user: "postgres",
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 PostgresHealthCheckStrategy(
47
+ createMockClient({ connectError: new Error("Connection refused") })
48
+ );
49
+
50
+ const result = await strategy.execute({
51
+ host: "localhost",
52
+ port: 5432,
53
+ database: "test",
54
+ user: "postgres",
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 PostgresHealthCheckStrategy(
66
+ createMockClient({ queryError: new Error("Syntax error") })
67
+ );
68
+
69
+ const result = await strategy.execute({
70
+ host: "localhost",
71
+ port: 5432,
72
+ database: "test",
73
+ user: "postgres",
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 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
+ ],
95
+ });
96
+
97
+ expect(result.status).toBe("healthy");
98
+ });
99
+
100
+ it("should pass rowCount assertion", async () => {
101
+ const strategy = new PostgresHealthCheckStrategy(
102
+ createMockClient({ rowCount: 5 })
103
+ );
104
+
105
+ const result = await strategy.execute({
106
+ host: "localhost",
107
+ port: 5432,
108
+ database: "test",
109
+ user: "postgres",
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 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 }],
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 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
+ });
151
+
152
+ expect(result.status).toBe("healthy");
153
+ });
154
+ });
155
+
156
+ describe("aggregateResult", () => {
157
+ it("should calculate averages correctly", () => {
158
+ const strategy = new PostgresHealthCheckStrategy();
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 PostgresHealthCheckStrategy();
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,339 @@
1
+ import { Client, type ClientConfig } from "pg";
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
+ configBoolean,
15
+ } from "@checkstack/backend-api";
16
+ import {
17
+ healthResultBoolean,
18
+ healthResultNumber,
19
+ healthResultString,
20
+ } from "@checkstack/healthcheck-common";
21
+
22
+ // ============================================================================
23
+ // SCHEMAS
24
+ // ============================================================================
25
+
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
+ /**
39
+ * Configuration schema for PostgreSQL health checks.
40
+ */
41
+ export const postgresConfigSchema = z.object({
42
+ host: configString({}).describe("PostgreSQL server hostname"),
43
+ port: configNumber({})
44
+ .int()
45
+ .min(1)
46
+ .max(65_535)
47
+ .default(5432)
48
+ .describe("PostgreSQL port"),
49
+ 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"),
55
+ timeout: configNumber({})
56
+ .min(100)
57
+ .default(10_000)
58
+ .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
+ });
67
+
68
+ export type PostgresConfig = z.infer<typeof postgresConfigSchema>;
69
+ export type PostgresConfigInput = z.input<typeof postgresConfigSchema>;
70
+
71
+ /**
72
+ * Per-run result metadata.
73
+ */
74
+ const postgresResultSchema = z.object({
75
+ connected: healthResultBoolean({
76
+ "x-chart-type": "boolean",
77
+ "x-chart-label": "Connected",
78
+ }),
79
+ connectionTimeMs: healthResultNumber({
80
+ "x-chart-type": "line",
81
+ "x-chart-label": "Connection Time",
82
+ "x-chart-unit": "ms",
83
+ }),
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
+ error: healthResultString({
103
+ "x-chart-type": "status",
104
+ "x-chart-label": "Error",
105
+ }).optional(),
106
+ });
107
+
108
+ export type PostgresResult = z.infer<typeof postgresResultSchema>;
109
+
110
+ /**
111
+ * Aggregated metadata for buckets.
112
+ */
113
+ const postgresAggregatedSchema = z.object({
114
+ avgConnectionTime: healthResultNumber({
115
+ "x-chart-type": "line",
116
+ "x-chart-label": "Avg Connection Time",
117
+ "x-chart-unit": "ms",
118
+ }),
119
+ avgQueryTime: healthResultNumber({
120
+ "x-chart-type": "line",
121
+ "x-chart-label": "Avg Query Time",
122
+ "x-chart-unit": "ms",
123
+ }),
124
+ successRate: healthResultNumber({
125
+ "x-chart-type": "gauge",
126
+ "x-chart-label": "Success Rate",
127
+ "x-chart-unit": "%",
128
+ }),
129
+ errorCount: healthResultNumber({
130
+ "x-chart-type": "counter",
131
+ "x-chart-label": "Errors",
132
+ }),
133
+ });
134
+
135
+ export type PostgresAggregatedResult = z.infer<typeof postgresAggregatedSchema>;
136
+
137
+ // ============================================================================
138
+ // DATABASE CLIENT INTERFACE (for testability)
139
+ // ============================================================================
140
+
141
+ export interface DbQueryResult {
142
+ rowCount: number | null;
143
+ }
144
+
145
+ export interface DbClient {
146
+ connect(config: ClientConfig): Promise<{
147
+ query(sql: string): Promise<DbQueryResult>;
148
+ end(): Promise<void>;
149
+ }>;
150
+ }
151
+
152
+ // Default client using pg
153
+ const defaultDbClient: DbClient = {
154
+ async connect(config) {
155
+ const client = new Client(config);
156
+ await client.connect();
157
+
158
+ return {
159
+ async query(sql: string): Promise<DbQueryResult> {
160
+ const result = await client.query(sql);
161
+ return { rowCount: result.rowCount };
162
+ },
163
+ async end() {
164
+ await client.end();
165
+ },
166
+ };
167
+ },
168
+ };
169
+
170
+ // ============================================================================
171
+ // STRATEGY
172
+ // ============================================================================
173
+
174
+ export class PostgresHealthCheckStrategy
175
+ implements
176
+ HealthCheckStrategy<
177
+ PostgresConfig,
178
+ PostgresResult,
179
+ PostgresAggregatedResult
180
+ >
181
+ {
182
+ id = "postgres";
183
+ displayName = "PostgreSQL Health Check";
184
+ description = "PostgreSQL database connectivity and query health check";
185
+
186
+ private dbClient: DbClient;
187
+
188
+ constructor(dbClient: DbClient = defaultDbClient) {
189
+ this.dbClient = dbClient;
190
+ }
191
+
192
+ config: Versioned<PostgresConfig> = new Versioned({
193
+ version: 1,
194
+ schema: postgresConfigSchema,
195
+ });
196
+
197
+ result: Versioned<PostgresResult> = new Versioned({
198
+ version: 1,
199
+ schema: postgresResultSchema,
200
+ });
201
+
202
+ aggregatedResult: Versioned<PostgresAggregatedResult> = new Versioned({
203
+ version: 1,
204
+ schema: postgresAggregatedSchema,
205
+ });
206
+
207
+ aggregateResult(
208
+ runs: HealthCheckRunForAggregation<PostgresResult>[]
209
+ ): 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;
216
+
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
+ }
233
+ }
234
+
235
+ 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,
239
+ errorCount,
240
+ };
241
+ }
242
+
243
+ async execute(
244
+ config: PostgresConfigInput
245
+ ): Promise<HealthCheckResult<PostgresResult>> {
246
+ 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
+
308
+ if (!querySuccess) {
309
+ return {
310
+ status: "unhealthy",
311
+ latencyMs: connectionTimeMs + (queryTimeMs ?? 0),
312
+ message: "Health check query failed",
313
+ metadata: result,
314
+ };
315
+ }
316
+
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
+ }
338
+ }
339
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }