@checkstack/backend-api 0.5.2 → 0.6.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,36 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor 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
+ ### Patch Changes
28
+
29
+ - Updated dependencies [f676e11]
30
+ - @checkstack/common@0.6.2
31
+ - @checkstack/signal-common@0.1.6
32
+ - @checkstack/queue-api@0.2.3
33
+
3
34
  ## 0.5.2
4
35
 
5
36
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "scripts": {
@@ -9,9 +9,9 @@
9
9
  "lint:code": "eslint . --max-warnings 0"
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/common": "0.6.0",
13
- "@checkstack/queue-api": "0.2.1",
14
- "@checkstack/signal-common": "0.1.4",
12
+ "@checkstack/common": "0.6.1",
13
+ "@checkstack/queue-api": "0.2.2",
14
+ "@checkstack/signal-common": "0.1.5",
15
15
  "@orpc/client": "^1.13.2",
16
16
  "@orpc/openapi": "^1.13.2",
17
17
  "@orpc/server": "^1.13.2",
@@ -23,8 +23,8 @@
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/bun": "latest",
26
- "@checkstack/tsconfig": "0.0.2",
27
- "@checkstack/scripts": "0.1.0"
26
+ "@checkstack/tsconfig": "0.0.3",
27
+ "@checkstack/scripts": "0.1.1"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "hono": "^4.0.0",
@@ -29,7 +29,7 @@ export interface CollectorStrategy<
29
29
  TClient extends TransportClient<unknown, unknown>,
30
30
  TConfig = unknown,
31
31
  TResult = Record<string, unknown>,
32
- TAggregated = Record<string, unknown>
32
+ TAggregated = Record<string, unknown>,
33
33
  > {
34
34
  /** Unique identifier for this collector */
35
35
  id: string;
@@ -76,8 +76,15 @@ export interface CollectorStrategy<
76
76
  }): Promise<CollectorResult<TResult>>;
77
77
 
78
78
  /**
79
- * Aggregate results from multiple runs into a summary.
80
- * Called during retention processing.
79
+ * Incrementally merge new run data into an existing aggregate.
80
+ * Called after each health check run for real-time aggregation.
81
+ *
82
+ * @param existing - Existing aggregated result (undefined for first run in bucket)
83
+ * @param newRun - Data from the new run to merge
84
+ * @returns Updated aggregated result
81
85
  */
82
- aggregateResult(runs: HealthCheckRunForAggregation<TResult>[]): TAggregated;
86
+ mergeResult(
87
+ existing: TAggregated | undefined,
88
+ newRun: HealthCheckRunForAggregation<TResult>,
89
+ ): TAggregated;
83
90
  }
@@ -13,10 +13,10 @@ export interface HealthCheckResult<TMetadata = Record<string, unknown>> {
13
13
  }
14
14
 
15
15
  /**
16
- * Raw run data for aggregation (passed to aggregateMetadata function).
16
+ * Raw run data for aggregation (passed to mergeResult function).
17
17
  */
18
18
  export interface HealthCheckRunForAggregation<
19
- TResultMetadata = Record<string, unknown>
19
+ TResultMetadata = Record<string, unknown>,
20
20
  > {
21
21
  status: "healthy" | "unhealthy" | "degraded";
22
22
  latencyMs?: number;
@@ -27,7 +27,7 @@ export interface HealthCheckRunForAggregation<
27
27
  * Connected transport client with cleanup capability.
28
28
  */
29
29
  export interface ConnectedClient<
30
- TClient extends TransportClient<unknown, unknown>
30
+ TClient extends TransportClient<unknown, unknown>,
31
31
  > {
32
32
  /** The connected transport client */
33
33
  client: TClient;
@@ -54,7 +54,7 @@ export interface HealthCheckStrategy<
54
54
  unknown
55
55
  >,
56
56
  TResult = Record<string, unknown>,
57
- TAggregatedResult = Record<string, unknown>
57
+ TAggregatedResult = Record<string, unknown>,
58
58
  > {
59
59
  id: string;
60
60
  displayName: string;
@@ -80,13 +80,18 @@ export interface HealthCheckStrategy<
80
80
  createClient(config: TConfig): Promise<ConnectedClient<TClient>>;
81
81
 
82
82
  /**
83
- * Aggregate results from multiple runs into a summary for bucket storage.
84
- * Called during retention processing when raw data is aggregated.
83
+ * Incrementally merge new run data into an existing aggregate.
84
+ * Called after each health check run for real-time aggregation.
85
85
  * Core metrics (counts, latency) are auto-calculated by platform.
86
86
  * This function only handles strategy-specific result aggregation.
87
+ *
88
+ * @param existing - Existing aggregated result (undefined for first run in bucket)
89
+ * @param newRun - Data from the new run to merge
90
+ * @returns Updated aggregated result
87
91
  */
88
- aggregateResult(
89
- runs: HealthCheckRunForAggregation<TResult>[]
92
+ mergeResult(
93
+ existing: TAggregatedResult | undefined,
94
+ newRun: HealthCheckRunForAggregation<TResult>,
90
95
  ): TAggregatedResult;
91
96
  }
92
97
 
@@ -111,10 +116,10 @@ export interface HealthCheckRegistry {
111
116
  TransportClient<unknown, unknown>,
112
117
  unknown,
113
118
  unknown
114
- >
119
+ >,
115
120
  ): void;
116
121
  getStrategy(
117
- id: string
122
+ id: string,
118
123
  ):
119
124
  | HealthCheckStrategy<
120
125
  unknown,
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ mergeCounter,
4
+ mergeAverage,
5
+ mergeRate,
6
+ mergeMinMax,
7
+ } from "./incremental-aggregation";
8
+
9
+ describe("mergeCounter", () => {
10
+ it("creates new counter from undefined", () => {
11
+ const result = mergeCounter(undefined, true);
12
+ expect(result).toEqual({ count: 1 });
13
+ });
14
+
15
+ it("increments existing counter with true", () => {
16
+ const result = mergeCounter({ count: 5 }, true);
17
+ expect(result).toEqual({ count: 6 });
18
+ });
19
+
20
+ it("does not increment with false", () => {
21
+ const result = mergeCounter({ count: 5 }, false);
22
+ expect(result).toEqual({ count: 5 });
23
+ });
24
+
25
+ it("accepts numeric increment", () => {
26
+ const result = mergeCounter({ count: 10 }, 3);
27
+ expect(result).toEqual({ count: 13 });
28
+ });
29
+
30
+ it("handles zero increment", () => {
31
+ const result = mergeCounter({ count: 10 }, 0);
32
+ expect(result).toEqual({ count: 10 });
33
+ });
34
+ });
35
+
36
+ describe("mergeAverage", () => {
37
+ it("creates new average from undefined with value", () => {
38
+ const result = mergeAverage(undefined, 100);
39
+ expect(result).toEqual({ _sum: 100, _count: 1, avg: 100 });
40
+ });
41
+
42
+ it("returns initial state when undefined value passed to undefined", () => {
43
+ const result = mergeAverage(undefined, undefined);
44
+ expect(result).toEqual({ _sum: 0, _count: 0, avg: 0 });
45
+ });
46
+
47
+ it("correctly computes average across multiple values", () => {
48
+ let state = mergeAverage(undefined, 100);
49
+ state = mergeAverage(state, 200);
50
+ state = mergeAverage(state, 300);
51
+ expect(state).toEqual({ _sum: 600, _count: 3, avg: 200 });
52
+ });
53
+
54
+ it("skips undefined values without affecting count", () => {
55
+ let state = mergeAverage(undefined, 100);
56
+ state = mergeAverage(state, undefined);
57
+ state = mergeAverage(state, 200);
58
+ expect(state).toEqual({ _sum: 300, _count: 2, avg: 150 });
59
+ });
60
+
61
+ it("rounds average to 1 decimal place", () => {
62
+ let state = mergeAverage(undefined, 100);
63
+ state = mergeAverage(state, 101);
64
+ // (100 + 101) / 2 = 100.5, rounds to 1 decimal place
65
+ expect(state.avg).toBe(100.5);
66
+ });
67
+ });
68
+
69
+ describe("mergeRate", () => {
70
+ it("creates new rate from undefined with success", () => {
71
+ const result = mergeRate(undefined, true);
72
+ expect(result).toEqual({ _success: 1, _total: 1, rate: 100 });
73
+ });
74
+
75
+ it("creates new rate from undefined with failure", () => {
76
+ const result = mergeRate(undefined, false);
77
+ expect(result).toEqual({ _success: 0, _total: 1, rate: 0 });
78
+ });
79
+
80
+ it("returns initial state when undefined value passed to undefined", () => {
81
+ const result = mergeRate(undefined, undefined);
82
+ expect(result).toEqual({ _success: 0, _total: 0, rate: 0 });
83
+ });
84
+
85
+ it("correctly computes rate across multiple values", () => {
86
+ let state = mergeRate(undefined, true);
87
+ state = mergeRate(state, true);
88
+ state = mergeRate(state, false);
89
+ state = mergeRate(state, true);
90
+ // 3/4 = 75%
91
+ expect(state).toEqual({ _success: 3, _total: 4, rate: 75 });
92
+ });
93
+
94
+ it("skips undefined values without affecting totals", () => {
95
+ let state = mergeRate(undefined, true);
96
+ state = mergeRate(state, undefined);
97
+ state = mergeRate(state, false);
98
+ expect(state).toEqual({ _success: 1, _total: 2, rate: 50 });
99
+ });
100
+ });
101
+
102
+ describe("mergeMinMax", () => {
103
+ it("creates new min/max from undefined", () => {
104
+ const result = mergeMinMax(undefined, 50);
105
+ expect(result).toEqual({ min: 50, max: 50 });
106
+ });
107
+
108
+ it("returns initial state when undefined value passed to undefined", () => {
109
+ const result = mergeMinMax(undefined, undefined);
110
+ expect(result).toEqual({ min: 0, max: 0 });
111
+ });
112
+
113
+ it("updates min when new value is lower", () => {
114
+ let state = mergeMinMax(undefined, 50);
115
+ state = mergeMinMax(state, 30);
116
+ expect(state).toEqual({ min: 30, max: 50 });
117
+ });
118
+
119
+ it("updates max when new value is higher", () => {
120
+ let state = mergeMinMax(undefined, 50);
121
+ state = mergeMinMax(state, 80);
122
+ expect(state).toEqual({ min: 50, max: 80 });
123
+ });
124
+
125
+ it("updates both when appropriate", () => {
126
+ let state = mergeMinMax(undefined, 50);
127
+ state = mergeMinMax(state, 20);
128
+ state = mergeMinMax(state, 100);
129
+ state = mergeMinMax(state, 60);
130
+ expect(state).toEqual({ min: 20, max: 100 });
131
+ });
132
+
133
+ it("skips undefined values", () => {
134
+ let state = mergeMinMax(undefined, 50);
135
+ state = mergeMinMax(state, undefined);
136
+ state = mergeMinMax(state, 30);
137
+ expect(state).toEqual({ min: 30, max: 50 });
138
+ });
139
+
140
+ it("handles negative values", () => {
141
+ let state = mergeMinMax(undefined, -10);
142
+ state = mergeMinMax(state, -50);
143
+ state = mergeMinMax(state, -5);
144
+ expect(state).toEqual({ min: -50, max: -5 });
145
+ });
146
+ });
@@ -0,0 +1,178 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Incremental aggregation utilities for real-time metrics.
5
+ * These utilities enable O(1) memory aggregation without storing raw data.
6
+ *
7
+ * Each pattern provides:
8
+ * - A Zod schema for validation/serialization
9
+ * - A TypeScript interface (inferred from schema)
10
+ * - A merge function for incremental updates
11
+ */
12
+
13
+ // ===== Counter Pattern =====
14
+
15
+ /**
16
+ * Zod schema for accumulated counter state.
17
+ */
18
+ export const counterStateSchema = z.object({
19
+ count: z.number(),
20
+ });
21
+
22
+ /**
23
+ * Accumulated counter state.
24
+ */
25
+ export type CounterState = z.infer<typeof counterStateSchema>;
26
+
27
+ /**
28
+ * Incrementally merge a counter.
29
+ * Use for tracking occurrences (errorCount, requestCount, etc.)
30
+ *
31
+ * @param existing - Previous counter state (undefined for first run)
32
+ * @param increment - Value to add (boolean true = 1, false = 0, or direct number)
33
+ */
34
+ export function mergeCounter(
35
+ existing: CounterState | undefined,
36
+ increment: boolean | number,
37
+ ): CounterState {
38
+ const value =
39
+ typeof increment === "boolean" ? (increment ? 1 : 0) : increment;
40
+ return {
41
+ count: (existing?.count ?? 0) + value,
42
+ };
43
+ }
44
+
45
+ // ===== Average Pattern =====
46
+
47
+ /**
48
+ * Zod schema for accumulated average state.
49
+ * Internal `_sum` and `_count` fields enable accurate averaging.
50
+ */
51
+ export const averageStateSchema = z.object({
52
+ /** Internal: sum of all values */
53
+ _sum: z.number(),
54
+ /** Internal: count of values */
55
+ _count: z.number(),
56
+ /** Computed average (rounded) */
57
+ avg: z.number(),
58
+ });
59
+
60
+ /**
61
+ * Accumulated average state.
62
+ */
63
+ export type AverageState = z.infer<typeof averageStateSchema>;
64
+
65
+ /**
66
+ * Incrementally merge an average.
67
+ * Use for tracking averages (avgResponseTimeMs, avgExecutionTimeMs, etc.)
68
+ *
69
+ * @param existing - Previous average state (undefined for first run)
70
+ * @param value - New value to incorporate (undefined skipped)
71
+ */
72
+ export function mergeAverage(
73
+ existing: AverageState | undefined,
74
+ value: number | undefined,
75
+ ): AverageState {
76
+ if (value === undefined) {
77
+ // No new value, return existing or initial state
78
+ return existing ?? { _sum: 0, _count: 0, avg: 0 };
79
+ }
80
+
81
+ const sum = (existing?._sum ?? 0) + value;
82
+ const count = (existing?._count ?? 0) + 1;
83
+
84
+ return {
85
+ _sum: sum,
86
+ _count: count,
87
+ // Round to 1 decimal place to preserve precision for float metrics (e.g., load averages)
88
+ avg: Math.round((sum / count) * 10) / 10,
89
+ };
90
+ }
91
+
92
+ // ===== Rate Pattern =====
93
+
94
+ /**
95
+ * Zod schema for accumulated rate state (percentage).
96
+ * Internal `_success` and `_total` fields enable accurate rate calculation.
97
+ */
98
+ export const rateStateSchema = z.object({
99
+ /** Internal: count of successes */
100
+ _success: z.number(),
101
+ /** Internal: total count */
102
+ _total: z.number(),
103
+ /** Computed rate as percentage (0-100, rounded) */
104
+ rate: z.number(),
105
+ });
106
+
107
+ /**
108
+ * Accumulated rate state (percentage).
109
+ */
110
+ export type RateState = z.infer<typeof rateStateSchema>;
111
+
112
+ /**
113
+ * Incrementally merge a rate (percentage).
114
+ * Use for tracking success rates, availability percentages, etc.
115
+ *
116
+ * @param existing - Previous rate state (undefined for first run)
117
+ * @param success - Whether this run was successful (undefined skipped)
118
+ */
119
+ export function mergeRate(
120
+ existing: RateState | undefined,
121
+ success: boolean | undefined,
122
+ ): RateState {
123
+ if (success === undefined) {
124
+ // No new value, return existing or initial state
125
+ return existing ?? { _success: 0, _total: 0, rate: 0 };
126
+ }
127
+
128
+ const successCount = (existing?._success ?? 0) + (success ? 1 : 0);
129
+ const total = (existing?._total ?? 0) + 1;
130
+
131
+ return {
132
+ _success: successCount,
133
+ _total: total,
134
+ rate: Math.round((successCount / total) * 100),
135
+ };
136
+ }
137
+
138
+ // ===== MinMax Pattern =====
139
+
140
+ /**
141
+ * Zod schema for accumulated min/max state.
142
+ */
143
+ export const minMaxStateSchema = z.object({
144
+ min: z.number(),
145
+ max: z.number(),
146
+ });
147
+
148
+ /**
149
+ * Accumulated min/max state.
150
+ */
151
+ export type MinMaxState = z.infer<typeof minMaxStateSchema>;
152
+
153
+ /**
154
+ * Incrementally merge min/max values.
155
+ * Use for tracking min/max latency, memory, etc.
156
+ *
157
+ * @param existing - Previous min/max state (undefined for first run)
158
+ * @param value - New value to incorporate (undefined skipped)
159
+ */
160
+ export function mergeMinMax(
161
+ existing: MinMaxState | undefined,
162
+ value: number | undefined,
163
+ ): MinMaxState {
164
+ if (value === undefined) {
165
+ // No new value, return existing or initial state
166
+ return existing ?? { min: 0, max: 0 };
167
+ }
168
+
169
+ if (existing === undefined) {
170
+ // First value
171
+ return { min: value, max: value };
172
+ }
173
+
174
+ return {
175
+ min: Math.min(existing.min, value),
176
+ max: Math.max(existing.max, value),
177
+ };
178
+ }
package/src/index.ts CHANGED
@@ -24,3 +24,4 @@ export * from "./chart-metadata";
24
24
  export * from "./transport-client";
25
25
  export * from "./collector-strategy";
26
26
  export * from "./collector-registry";
27
+ export * from "./incremental-aggregation";