@checkstack/healthcheck-postgres-backend 0.1.13 → 0.1.14

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,35 @@
1
1
  # @checkstack/healthcheck-postgres-backend
2
2
 
3
+ ## 0.1.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 48c2080: Migrate aggregation from batch to incremental (`mergeResult`)
8
+
9
+ ### Breaking Changes (Internal)
10
+
11
+ - Replaced `aggregateResult(runs[])` with `mergeResult(existing, run)` interface across all HealthCheckStrategy and CollectorStrategy implementations
12
+
13
+ ### New Features
14
+
15
+ - Added incremental aggregation utilities in `@checkstack/backend-api`:
16
+ - `mergeCounter()` - track occurrences
17
+ - `mergeAverage()` - track sum/count, compute avg
18
+ - `mergeRate()` - track success/total, compute %
19
+ - `mergeMinMax()` - track min/max values
20
+ - Exported Zod schemas for internal state: `averageStateSchema`, `rateStateSchema`, `minMaxStateSchema`, `counterStateSchema`
21
+
22
+ ### Improvements
23
+
24
+ - Enables O(1) storage overhead by maintaining incremental aggregation state
25
+ - Prepares for real-time hourly aggregation without batch accumulation
26
+
27
+ - Updated dependencies [f676e11]
28
+ - Updated dependencies [48c2080]
29
+ - @checkstack/common@0.6.2
30
+ - @checkstack/backend-api@0.6.0
31
+ - @checkstack/healthcheck-common@0.8.2
32
+
3
33
  ## 0.1.13
4
34
 
5
35
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-postgres-backend",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -9,16 +9,16 @@
9
9
  "lint:code": "eslint . --max-warnings 0"
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/backend-api": "0.5.1",
13
- "@checkstack/common": "0.6.0",
14
- "@checkstack/healthcheck-common": "0.7.0",
12
+ "@checkstack/backend-api": "0.5.2",
13
+ "@checkstack/common": "0.6.1",
14
+ "@checkstack/healthcheck-common": "0.8.1",
15
15
  "pg": "^8.11.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/bun": "^1.0.0",
19
19
  "@types/pg": "^8.11.0",
20
20
  "typescript": "^5.0.0",
21
- "@checkstack/tsconfig": "0.0.2",
22
- "@checkstack/scripts": "0.1.0"
21
+ "@checkstack/tsconfig": "0.0.3",
22
+ "@checkstack/scripts": "0.1.1"
23
23
  }
24
24
  }
@@ -64,7 +64,7 @@ describe("QueryCollector", () => {
64
64
  });
65
65
  });
66
66
 
67
- describe("aggregateResult", () => {
67
+ describe("mergeResult", () => {
68
68
  it("should calculate average execution time and success rate", () => {
69
69
  const collector = new QueryCollector();
70
70
  const runs = [
@@ -86,7 +86,8 @@ describe("QueryCollector", () => {
86
86
  },
87
87
  ];
88
88
 
89
- const aggregated = collector.aggregateResult(runs);
89
+ let aggregated = collector.mergeResult(undefined, runs[0]);
90
+ aggregated = collector.mergeResult(aggregated, runs[1]);
90
91
 
91
92
  expect(aggregated.avgExecutionTimeMs).toBe(75);
92
93
  expect(aggregated.successRate).toBe(100);
@@ -113,7 +114,8 @@ describe("QueryCollector", () => {
113
114
  },
114
115
  ];
115
116
 
116
- const aggregated = collector.aggregateResult(runs);
117
+ let aggregated = collector.mergeResult(undefined, runs[0]);
118
+ aggregated = collector.mergeResult(aggregated, runs[1]);
117
119
 
118
120
  expect(aggregated.successRate).toBe(50);
119
121
  });
@@ -4,6 +4,12 @@ import {
4
4
  type HealthCheckRunForAggregation,
5
5
  type CollectorResult,
6
6
  type CollectorStrategy,
7
+ mergeAverage,
8
+ mergeRate,
9
+ averageStateSchema,
10
+ rateStateSchema,
11
+ type AverageState,
12
+ type RateState,
7
13
  } from "@checkstack/backend-api";
8
14
  import {
9
15
  healthResultNumber,
@@ -45,7 +51,7 @@ const queryResultSchema = healthResultSchema({
45
51
 
46
52
  export type QueryResult = z.infer<typeof queryResultSchema>;
47
53
 
48
- const queryAggregatedSchema = healthResultSchema({
54
+ const queryAggregatedDisplaySchema = healthResultSchema({
49
55
  avgExecutionTimeMs: healthResultNumber({
50
56
  "x-chart-type": "line",
51
57
  "x-chart-label": "Avg Execution Time",
@@ -58,6 +64,15 @@ const queryAggregatedSchema = healthResultSchema({
58
64
  }),
59
65
  });
60
66
 
67
+ const queryAggregatedInternalSchema = z.object({
68
+ _executionTime: averageStateSchema.optional(),
69
+ _success: rateStateSchema.optional(),
70
+ });
71
+
72
+ const queryAggregatedSchema = queryAggregatedDisplaySchema.merge(
73
+ queryAggregatedInternalSchema,
74
+ );
75
+
61
76
  export type QueryAggregatedResult = z.infer<typeof queryAggregatedSchema>;
62
77
 
63
78
  // ============================================================================
@@ -68,15 +83,12 @@ export type QueryAggregatedResult = z.infer<typeof queryAggregatedSchema>;
68
83
  * Built-in PostgreSQL query collector.
69
84
  * Executes SQL queries and checks results.
70
85
  */
71
- export class QueryCollector
72
- implements
73
- CollectorStrategy<
74
- PostgresTransportClient,
75
- QueryConfig,
76
- QueryResult,
77
- QueryAggregatedResult
78
- >
79
- {
86
+ export class QueryCollector implements CollectorStrategy<
87
+ PostgresTransportClient,
88
+ QueryConfig,
89
+ QueryResult,
90
+ QueryAggregatedResult
91
+ > {
80
92
  id = "query";
81
93
  displayName = "SQL Query";
82
94
  description = "Execute a SQL query and check the result";
@@ -115,28 +127,27 @@ export class QueryCollector
115
127
  };
116
128
  }
117
129
 
118
- aggregateResult(
119
- runs: HealthCheckRunForAggregation<QueryResult>[]
130
+ mergeResult(
131
+ existing: QueryAggregatedResult | undefined,
132
+ run: HealthCheckRunForAggregation<QueryResult>,
120
133
  ): QueryAggregatedResult {
121
- const times = runs
122
- .map((r) => r.metadata?.executionTimeMs)
123
- .filter((v): v is number => typeof v === "number");
134
+ const metadata = run.metadata;
124
135
 
125
- const successes = runs
126
- .map((r) => r.metadata?.success)
127
- .filter((v): v is boolean => typeof v === "boolean");
136
+ const executionTimeState = mergeAverage(
137
+ existing?._executionTime as AverageState | undefined,
138
+ metadata?.executionTimeMs,
139
+ );
128
140
 
129
- const successCount = successes.filter(Boolean).length;
141
+ const successState = mergeRate(
142
+ existing?._success as RateState | undefined,
143
+ metadata?.success,
144
+ );
130
145
 
131
146
  return {
132
- avgExecutionTimeMs:
133
- times.length > 0
134
- ? Math.round(times.reduce((a, b) => a + b, 0) / times.length)
135
- : 0,
136
- successRate:
137
- successes.length > 0
138
- ? Math.round((successCount / successes.length) * 100)
139
- : 0,
147
+ avgExecutionTimeMs: executionTimeState.avg,
148
+ successRate: successState.rate,
149
+ _executionTime: executionTimeState,
150
+ _success: successState,
140
151
  };
141
152
  }
142
153
  }
@@ -8,7 +8,7 @@ describe("PostgresHealthCheckStrategy", () => {
8
8
  rowCount?: number;
9
9
  queryError?: Error;
10
10
  connectError?: Error;
11
- } = {}
11
+ } = {},
12
12
  ): DbClient => ({
13
13
  connect: mock(() =>
14
14
  config.connectError
@@ -17,10 +17,10 @@ describe("PostgresHealthCheckStrategy", () => {
17
17
  query: mock(() =>
18
18
  config.queryError
19
19
  ? Promise.reject(config.queryError)
20
- : Promise.resolve({ rowCount: config.rowCount ?? 1 })
20
+ : Promise.resolve({ rowCount: config.rowCount ?? 1 }),
21
21
  ),
22
22
  end: mock(() => Promise.resolve()),
23
- })
23
+ }),
24
24
  ),
25
25
  });
26
26
 
@@ -46,7 +46,7 @@ describe("PostgresHealthCheckStrategy", () => {
46
46
 
47
47
  it("should throw for connection error", async () => {
48
48
  const strategy = new PostgresHealthCheckStrategy(
49
- createMockClient({ connectError: new Error("Connection refused") })
49
+ createMockClient({ connectError: new Error("Connection refused") }),
50
50
  );
51
51
 
52
52
  await expect(
@@ -57,7 +57,7 @@ describe("PostgresHealthCheckStrategy", () => {
57
57
  user: "postgres",
58
58
  password: "secret",
59
59
  timeout: 5000,
60
- })
60
+ }),
61
61
  ).rejects.toThrow("Connection refused");
62
62
  });
63
63
  });
@@ -85,7 +85,7 @@ describe("PostgresHealthCheckStrategy", () => {
85
85
 
86
86
  it("should return error for query error", async () => {
87
87
  const strategy = new PostgresHealthCheckStrategy(
88
- createMockClient({ queryError: new Error("Syntax error") })
88
+ createMockClient({ queryError: new Error("Syntax error") }),
89
89
  );
90
90
  const connectedClient = await strategy.createClient({
91
91
  host: "localhost",
@@ -107,7 +107,7 @@ describe("PostgresHealthCheckStrategy", () => {
107
107
 
108
108
  it("should return custom row count", async () => {
109
109
  const strategy = new PostgresHealthCheckStrategy(
110
- createMockClient({ rowCount: 5 })
110
+ createMockClient({ rowCount: 5 }),
111
111
  );
112
112
  const connectedClient = await strategy.createClient({
113
113
  host: "localhost",
@@ -128,8 +128,8 @@ describe("PostgresHealthCheckStrategy", () => {
128
128
  });
129
129
  });
130
130
 
131
- describe("aggregateResult", () => {
132
- it("should calculate averages correctly", () => {
131
+ describe("mergeResult", () => {
132
+ it("should calculate averages correctly through incremental merging", () => {
133
133
  const strategy = new PostgresHealthCheckStrategy();
134
134
  const runs = [
135
135
  {
@@ -158,7 +158,9 @@ describe("PostgresHealthCheckStrategy", () => {
158
158
  },
159
159
  ];
160
160
 
161
- const aggregated = strategy.aggregateResult(runs);
161
+ // Merge incrementally
162
+ let aggregated = strategy.mergeResult(undefined, runs[0]);
163
+ aggregated = strategy.mergeResult(aggregated, runs[1]);
162
164
 
163
165
  expect(aggregated.avgConnectionTime).toBe(75);
164
166
  expect(aggregated.successRate).toBe(100);
@@ -167,22 +169,20 @@ describe("PostgresHealthCheckStrategy", () => {
167
169
 
168
170
  it("should count errors", () => {
169
171
  const strategy = new PostgresHealthCheckStrategy();
170
- const runs = [
171
- {
172
- id: "1",
173
- status: "unhealthy" as const,
174
- latencyMs: 100,
175
- checkId: "c1",
176
- timestamp: new Date(),
177
- metadata: {
178
- connected: false,
179
- connectionTimeMs: 100,
180
- error: "Connection refused",
181
- },
172
+ const run = {
173
+ id: "1",
174
+ status: "unhealthy" as const,
175
+ latencyMs: 100,
176
+ checkId: "c1",
177
+ timestamp: new Date(),
178
+ metadata: {
179
+ connected: false,
180
+ connectionTimeMs: 100,
181
+ error: "Connection refused",
182
182
  },
183
- ];
183
+ };
184
184
 
185
- const aggregated = strategy.aggregateResult(runs);
185
+ const aggregated = strategy.mergeResult(undefined, run);
186
186
 
187
187
  expect(aggregated.errorCount).toBe(1);
188
188
  expect(aggregated.successRate).toBe(0);
package/src/strategy.ts CHANGED
@@ -8,6 +8,18 @@ import {
8
8
  configNumber,
9
9
  configBoolean,
10
10
  type ConnectedClient,
11
+ mergeAverage,
12
+ mergeRate,
13
+ mergeCounter,
14
+ mergeMinMax,
15
+ averageStateSchema,
16
+ minMaxStateSchema,
17
+ rateStateSchema,
18
+ counterStateSchema,
19
+ type AverageState,
20
+ type RateState,
21
+ type CounterState,
22
+ type MinMaxState,
11
23
  } from "@checkstack/backend-api";
12
24
  import {
13
25
  healthResultBoolean,
@@ -73,7 +85,8 @@ type PostgresResult = z.infer<typeof postgresResultSchema>;
73
85
  /**
74
86
  * Aggregated metadata for buckets.
75
87
  */
76
- const postgresAggregatedSchema = healthResultSchema({
88
+ // UI-visible aggregated fields (for charts)
89
+ const postgresAggregatedDisplaySchema = healthResultSchema({
77
90
  avgConnectionTime: healthResultNumber({
78
91
  "x-chart-type": "line",
79
92
  "x-chart-label": "Avg Connection Time",
@@ -95,6 +108,18 @@ const postgresAggregatedSchema = healthResultSchema({
95
108
  }),
96
109
  });
97
110
 
111
+ // Internal state for incremental aggregation
112
+ const postgresAggregatedInternalSchema = z.object({
113
+ _connectionTime: averageStateSchema.optional(),
114
+ _maxConnectionTime: minMaxStateSchema.optional(),
115
+ _success: rateStateSchema.optional(),
116
+ _errors: counterStateSchema.optional(),
117
+ });
118
+
119
+ const postgresAggregatedSchema = postgresAggregatedDisplaySchema.merge(
120
+ postgresAggregatedInternalSchema,
121
+ );
122
+
98
123
  type PostgresAggregatedResult = z.infer<typeof postgresAggregatedSchema>;
99
124
 
100
125
  // ============================================================================
@@ -133,15 +158,12 @@ const defaultDbClient: DbClient = {
133
158
  // STRATEGY
134
159
  // ============================================================================
135
160
 
136
- export class PostgresHealthCheckStrategy
137
- implements
138
- HealthCheckStrategy<
139
- PostgresConfig,
140
- PostgresTransportClient,
141
- PostgresResult,
142
- PostgresAggregatedResult
143
- >
144
- {
161
+ export class PostgresHealthCheckStrategy implements HealthCheckStrategy<
162
+ PostgresConfig,
163
+ PostgresTransportClient,
164
+ PostgresResult,
165
+ PostgresAggregatedResult
166
+ > {
145
167
  id = "postgres";
146
168
  displayName = "PostgreSQL Health Check";
147
169
  description = "PostgreSQL database connectivity and query health check";
@@ -183,53 +205,50 @@ export class PostgresHealthCheckStrategy
183
205
  schema: postgresAggregatedSchema,
184
206
  });
185
207
 
186
- aggregateResult(
187
- runs: HealthCheckRunForAggregation<PostgresResult>[]
208
+ mergeResult(
209
+ existing: PostgresAggregatedResult | undefined,
210
+ run: HealthCheckRunForAggregation<PostgresResult>,
188
211
  ): PostgresAggregatedResult {
189
- const validRuns = runs.filter((r) => r.metadata);
190
-
191
- if (validRuns.length === 0) {
192
- return {
193
- avgConnectionTime: 0,
194
- maxConnectionTime: 0,
195
- successRate: 0,
196
- errorCount: 0,
197
- };
198
- }
199
-
200
- const connectionTimes = validRuns
201
- .map((r) => r.metadata?.connectionTimeMs)
202
- .filter((t): t is number => typeof t === "number");
212
+ const metadata = run.metadata;
203
213
 
204
- const avgConnectionTime =
205
- connectionTimes.length > 0
206
- ? Math.round(
207
- connectionTimes.reduce((a, b) => a + b, 0) / connectionTimes.length
208
- )
209
- : 0;
214
+ // Merge connection time average
215
+ const connectionTimeState = mergeAverage(
216
+ existing?._connectionTime as AverageState | undefined,
217
+ metadata?.connectionTimeMs,
218
+ );
210
219
 
211
- const maxConnectionTime =
212
- connectionTimes.length > 0 ? Math.max(...connectionTimes) : 0;
220
+ // Merge max connection time
221
+ const maxConnectionTimeState = mergeMinMax(
222
+ existing?._maxConnectionTime as MinMaxState | undefined,
223
+ metadata?.connectionTimeMs,
224
+ );
213
225
 
214
- const successCount = validRuns.filter(
215
- (r) => r.metadata?.connected === true
216
- ).length;
217
- const successRate = Math.round((successCount / validRuns.length) * 100);
226
+ // Merge success rate
227
+ const successState = mergeRate(
228
+ existing?._success as RateState | undefined,
229
+ metadata?.connected,
230
+ );
218
231
 
219
- const errorCount = validRuns.filter(
220
- (r) => r.metadata?.error !== undefined
221
- ).length;
232
+ // Merge error count
233
+ const errorState = mergeCounter(
234
+ existing?._errors as CounterState | undefined,
235
+ metadata?.error !== undefined,
236
+ );
222
237
 
223
238
  return {
224
- avgConnectionTime,
225
- maxConnectionTime,
226
- successRate,
227
- errorCount,
239
+ avgConnectionTime: connectionTimeState.avg,
240
+ maxConnectionTime: maxConnectionTimeState.max,
241
+ successRate: successState.rate,
242
+ errorCount: errorState.count,
243
+ _connectionTime: connectionTimeState,
244
+ _maxConnectionTime: maxConnectionTimeState,
245
+ _success: successState,
246
+ _errors: errorState,
228
247
  };
229
248
  }
230
249
 
231
250
  async createClient(
232
- config: PostgresConfigInput
251
+ config: PostgresConfigInput,
233
252
  ): Promise<ConnectedClient<PostgresTransportClient>> {
234
253
  const validatedConfig = this.config.validate(config);
235
254