@checkstack/backend-api 0.6.0 → 0.8.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.
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Type-safe aggregated result schema factories.
3
+ *
4
+ * These factories create aggregated result fields that automatically pair
5
+ * display fields with their internal state, enabling proper merging when
6
+ * combining buckets.
7
+ */
8
+
9
+ import { z, type ZodTypeAny } from "zod";
10
+ import type { HealthResultMeta } from "@checkstack/common";
11
+ import { healthResultRegistry } from "@checkstack/healthcheck-common";
12
+ import {
13
+ mergeAverageStates,
14
+ mergeRateStates,
15
+ mergeCounterStates,
16
+ mergeMinMaxStates,
17
+ type AverageState,
18
+ type AverageStateInput,
19
+ type RateState,
20
+ type RateStateInput,
21
+ type CounterState,
22
+ type CounterStateInput,
23
+ type MinMaxState,
24
+ type MinMaxStateInput,
25
+ } from "./incremental-aggregation";
26
+ import { Versioned, type Migration } from "./config-versioning";
27
+
28
+ // =============================================================================
29
+ // AGGREGATION TYPE ENUM
30
+ // =============================================================================
31
+
32
+ /**
33
+ * Types of aggregation supported by the system.
34
+ * Each type has a corresponding internal state schema and merge function.
35
+ */
36
+ export type AggregationType = "average" | "rate" | "counter" | "minmax";
37
+
38
+ // =============================================================================
39
+ // AGGREGATED FIELD TYPES
40
+ // =============================================================================
41
+
42
+ /** Base metadata for chart annotations (excludes x-jsonpath) */
43
+ type ChartMeta = Omit<HealthResultMeta, "x-jsonpath">;
44
+
45
+ /**
46
+ * Base interface for aggregated field definitions.
47
+ * TState is the output type (with _type), TStateInput is the input type (without _type).
48
+ */
49
+ interface AggregatedFieldBase<TState, TStateInput> {
50
+ type: AggregationType;
51
+ stateSchema: ZodTypeAny;
52
+ meta: ChartMeta;
53
+ mergeStates: (a: TStateInput, b: TStateInput) => TState;
54
+ getDisplayValue: (state: TStateInput) => number;
55
+ getInitialState: () => TState;
56
+ }
57
+
58
+ export interface AggregatedAverageField extends AggregatedFieldBase<AverageState, AverageStateInput> {
59
+ type: "average";
60
+ }
61
+
62
+ export interface AggregatedRateField extends AggregatedFieldBase<RateState, RateStateInput> {
63
+ type: "rate";
64
+ }
65
+
66
+ export interface AggregatedCounterField extends AggregatedFieldBase<CounterState, CounterStateInput> {
67
+ type: "counter";
68
+ }
69
+
70
+ export interface AggregatedMinMaxField extends AggregatedFieldBase<MinMaxState, MinMaxStateInput> {
71
+ type: "minmax";
72
+ getMinValue: (state: MinMaxStateInput) => number;
73
+ getMaxValue: (state: MinMaxStateInput) => number;
74
+ }
75
+
76
+ export type AggregatedField =
77
+ | AggregatedAverageField
78
+ | AggregatedRateField
79
+ | AggregatedCounterField
80
+ | AggregatedMinMaxField;
81
+
82
+ // =============================================================================
83
+ // TYPE INFERENCE UTILITIES
84
+ // =============================================================================
85
+
86
+ /**
87
+ * Get the internal state type for a field.
88
+ */
89
+ type InferFieldState<T extends AggregatedField> =
90
+ T extends AggregatedAverageField
91
+ ? AverageState
92
+ : T extends AggregatedRateField
93
+ ? RateState
94
+ : T extends AggregatedCounterField
95
+ ? CounterState
96
+ : T extends AggregatedMinMaxField
97
+ ? MinMaxState
98
+ : never;
99
+
100
+ /**
101
+ * Infer the aggregated result type from field definitions.
102
+ * Each field key maps directly to its state type.
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * const fields = {
107
+ * avgResponseTimeMs: aggregatedAverage({...}),
108
+ * successRate: aggregatedRate({...}),
109
+ * };
110
+ * type Result = InferAggregatedResult<typeof fields>;
111
+ * // Result = {
112
+ * // avgResponseTimeMs: AverageState;
113
+ * // successRate: RateState;
114
+ * // }
115
+ * ```
116
+ */
117
+ export type InferAggregatedResult<T extends AggregatedResultShape> = {
118
+ [K in keyof T]: InferFieldState<T[K]>;
119
+ };
120
+
121
+ /**
122
+ * Create an aggregated average field.
123
+ *
124
+ * @example
125
+ * ```typescript
126
+ * const fields = {
127
+ * avgResponseTimeMs: aggregatedAverage({
128
+ * "x-chart-type": "line",
129
+ * "x-chart-label": "Avg Response Time",
130
+ * "x-chart-unit": "ms",
131
+ * }),
132
+ * };
133
+ * ```
134
+ */
135
+ export function aggregatedAverage(meta: ChartMeta): AggregatedAverageField {
136
+ // Create fresh schema instance so each field gets its own chart metadata
137
+ const stateSchema = z.object({
138
+ _type: z.literal("average"),
139
+ _sum: z.number(),
140
+ _count: z.number(),
141
+ avg: z.number(),
142
+ });
143
+ // Register chart metadata for this field
144
+ stateSchema.register(healthResultRegistry, meta);
145
+
146
+ return {
147
+ type: "average",
148
+ stateSchema,
149
+ meta,
150
+ mergeStates: mergeAverageStates,
151
+ getDisplayValue: (state) => state.avg,
152
+ getInitialState: () => ({
153
+ _type: "average" as const,
154
+ _sum: 0,
155
+ _count: 0,
156
+ avg: 0,
157
+ }),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Create an aggregated rate/percentage field.
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * const fields = {
167
+ * successRate: aggregatedRate({
168
+ * "x-chart-type": "gauge",
169
+ * "x-chart-label": "Success Rate",
170
+ * "x-chart-unit": "%",
171
+ * }),
172
+ * };
173
+ * ```
174
+ */
175
+ export function aggregatedRate(meta: ChartMeta): AggregatedRateField {
176
+ // Create fresh schema instance so each field gets its own chart metadata
177
+ const stateSchema = z.object({
178
+ _type: z.literal("rate"),
179
+ _success: z.number(),
180
+ _total: z.number(),
181
+ rate: z.number(),
182
+ });
183
+ // Register chart metadata for this field
184
+ stateSchema.register(healthResultRegistry, meta);
185
+
186
+ return {
187
+ type: "rate",
188
+ stateSchema,
189
+ meta,
190
+ mergeStates: mergeRateStates,
191
+ getDisplayValue: (state) => state.rate,
192
+ getInitialState: () => ({
193
+ _type: "rate" as const,
194
+ _success: 0,
195
+ _total: 0,
196
+ rate: 0,
197
+ }),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Create an aggregated counter field.
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * const fields = {
207
+ * errorCount: aggregatedCounter({
208
+ * "x-chart-type": "counter",
209
+ * "x-chart-label": "Errors",
210
+ * }),
211
+ * };
212
+ * ```
213
+ */
214
+ export function aggregatedCounter(meta: ChartMeta): AggregatedCounterField {
215
+ // Create fresh schema instance so each field gets its own chart metadata
216
+ const stateSchema = z.object({
217
+ _type: z.literal("counter"),
218
+ count: z.number(),
219
+ });
220
+ // Register chart metadata for this field
221
+ stateSchema.register(healthResultRegistry, meta);
222
+
223
+ return {
224
+ type: "counter",
225
+ stateSchema,
226
+ meta,
227
+ mergeStates: mergeCounterStates,
228
+ getDisplayValue: (state) => state.count,
229
+ getInitialState: () => ({ _type: "counter" as const, count: 0 }),
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Create an aggregated min/max field.
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * const fields = {
239
+ * latencyRange: aggregatedMinMax({
240
+ * "x-chart-type": "line",
241
+ * "x-chart-label": "Latency Range",
242
+ * "x-chart-unit": "ms",
243
+ * }),
244
+ * };
245
+ * ```
246
+ */
247
+ export function aggregatedMinMax(meta: ChartMeta): AggregatedMinMaxField {
248
+ // Create fresh schema instance so each field gets its own chart metadata
249
+ const stateSchema = z.object({
250
+ _type: z.literal("minmax"),
251
+ min: z.number(),
252
+ max: z.number(),
253
+ });
254
+ // Register chart metadata for this field
255
+ stateSchema.register(healthResultRegistry, meta);
256
+
257
+ return {
258
+ type: "minmax",
259
+ stateSchema,
260
+ meta,
261
+ mergeStates: mergeMinMaxStates,
262
+ getDisplayValue: (state) => state.max, // Default to max
263
+ getMinValue: (state) => state.min,
264
+ getMaxValue: (state) => state.max,
265
+ getInitialState: () => ({ _type: "minmax" as const, min: 0, max: 0 }),
266
+ };
267
+ }
268
+
269
+ // =============================================================================
270
+ // AGGREGATED RESULT SCHEMA BUILDER
271
+ // =============================================================================
272
+
273
+ /**
274
+ * Definition for aggregated result schema.
275
+ * Maps display field names to their aggregated field configurations.
276
+ */
277
+ export type AggregatedResultShape = Record<string, AggregatedField>;
278
+
279
+ /**
280
+ * Registry to store field definitions for lookup during merge operations.
281
+ */
282
+ const fieldRegistry = new WeakMap<ZodTypeAny, AggregatedResultShape>();
283
+
284
+ /**
285
+ * Build a Zod schema for aggregated results.
286
+ * Each field key maps directly to its state type.
287
+ *
288
+ * @param fields - Map of field names to aggregated field definitions
289
+ * @returns Object containing schema, fields, and merge function
290
+ *
291
+ * @example
292
+ * ```typescript
293
+ * const { schema, mergeAggregatedResults } = buildAggregatedResultSchema({
294
+ * avgResponseTimeMs: aggregatedAverage({ ... }),
295
+ * successRate: aggregatedRate({ ... }),
296
+ * });
297
+ * ```
298
+ */
299
+ export function buildAggregatedResultSchema<T extends AggregatedResultShape>(
300
+ fields: T,
301
+ ): {
302
+ schema: z.ZodType<InferAggregatedResult<T>>;
303
+ fields: T;
304
+ mergeAggregatedResults: (
305
+ a: InferAggregatedResult<T> | undefined,
306
+ b: InferAggregatedResult<T>,
307
+ ) => InferAggregatedResult<T>;
308
+ } {
309
+ // Build schema shape - each field key maps to its state schema
310
+ // Schema instances are already registered with chart metadata by the factory functions
311
+ const shape: Record<string, ZodTypeAny> = {};
312
+
313
+ for (const [key, field] of Object.entries(fields)) {
314
+ shape[key] = field.stateSchema;
315
+ }
316
+
317
+ const schema = z.object(shape);
318
+
319
+ // Store field definitions for later lookup
320
+ fieldRegistry.set(schema, fields);
321
+
322
+ // Create merge function
323
+ const mergeAggregatedResults = (
324
+ a: Record<string, unknown> | undefined,
325
+ b: Record<string, unknown>,
326
+ ): Record<string, unknown> => {
327
+ const result: Record<string, unknown> = {};
328
+
329
+ for (const [key, field] of Object.entries(fields)) {
330
+ // Get states from both results using the field key
331
+ const stateA = a?.[key] as Record<string, unknown> | undefined;
332
+ const stateB = b[key] as Record<string, unknown> | undefined;
333
+
334
+ let mergedState: Record<string, unknown>;
335
+
336
+ if (stateA && stateB) {
337
+ // Both exist, merge based on type
338
+ switch (field.type) {
339
+ case "average": {
340
+ mergedState = mergeAverageStates(
341
+ stateA as AverageState,
342
+ stateB as AverageState,
343
+ );
344
+ break;
345
+ }
346
+ case "rate": {
347
+ mergedState = mergeRateStates(
348
+ stateA as RateState,
349
+ stateB as RateState,
350
+ );
351
+ break;
352
+ }
353
+ case "counter": {
354
+ mergedState = mergeCounterStates(
355
+ stateA as CounterState,
356
+ stateB as CounterState,
357
+ );
358
+ break;
359
+ }
360
+ case "minmax": {
361
+ mergedState = mergeMinMaxStates(
362
+ stateA as MinMaxState,
363
+ stateB as MinMaxState,
364
+ );
365
+ break;
366
+ }
367
+ }
368
+ } else if (stateA) {
369
+ // Only A exists
370
+ mergedState = stateA;
371
+ } else if (stateB) {
372
+ // Only B exists
373
+ mergedState = stateB as Record<string, unknown>;
374
+ } else {
375
+ // Both missing, use initial state
376
+ mergedState = field.getInitialState() as Record<string, unknown>;
377
+ }
378
+
379
+ // Store state directly at field key
380
+ result[key] = mergedState;
381
+ }
382
+
383
+ return result;
384
+ };
385
+
386
+ return {
387
+ schema: schema as unknown as z.ZodType<InferAggregatedResult<T>>,
388
+ fields,
389
+ mergeAggregatedResults: mergeAggregatedResults as (
390
+ a: InferAggregatedResult<T> | undefined,
391
+ b: InferAggregatedResult<T>,
392
+ ) => InferAggregatedResult<T>,
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Get field definitions from a schema.
398
+ */
399
+ export function getAggregatedFields(
400
+ schema: ZodTypeAny,
401
+ ): AggregatedResultShape | undefined {
402
+ return fieldRegistry.get(schema);
403
+ }
404
+
405
+ // =============================================================================
406
+ // VERSIONED AGGREGATED
407
+ // =============================================================================
408
+
409
+ /**
410
+ * Options for creating a VersionedAggregated instance.
411
+ */
412
+ export interface VersionedAggregatedOptions<T extends AggregatedResultShape> {
413
+ /** Current schema version */
414
+ version: number;
415
+ /** Aggregated result field definitions */
416
+ fields: T;
417
+ /** Optional migrations for backward compatibility */
418
+ migrations?: Migration<unknown, unknown>[];
419
+ }
420
+
421
+ /**
422
+ * Versioned schema for aggregated results that bundles the merge function.
423
+ * Use this instead of Versioned for collector aggregatedResult fields.
424
+ *
425
+ * @example
426
+ * ```typescript
427
+ * const aggregatedResult = new VersionedAggregated({
428
+ * version: 1,
429
+ * fields: {
430
+ * avgResponseTimeMs: aggregatedAverage("_responseTime", {...}),
431
+ * successRate: aggregatedRate("_success", {...}),
432
+ * },
433
+ * });
434
+ * ```
435
+ */
436
+ export class VersionedAggregated<
437
+ T extends AggregatedResultShape,
438
+ > extends Versioned<Record<string, unknown>> {
439
+ readonly fields: T;
440
+ private readonly _mergeAggregatedStates: (
441
+ a: Record<string, unknown> | undefined,
442
+ b: Record<string, unknown>,
443
+ ) => Record<string, unknown>;
444
+
445
+ constructor(options: VersionedAggregatedOptions<T>) {
446
+ const { schema, mergeAggregatedResults } = buildAggregatedResultSchema(
447
+ options.fields,
448
+ );
449
+
450
+ super({
451
+ version: options.version,
452
+ schema: schema as z.ZodType<Record<string, unknown>>,
453
+ migrations: options.migrations,
454
+ });
455
+
456
+ this.fields = options.fields;
457
+ this._mergeAggregatedStates = mergeAggregatedResults as (
458
+ a: Record<string, unknown> | undefined,
459
+ b: Record<string, unknown>,
460
+ ) => Record<string, unknown>;
461
+ }
462
+
463
+ /**
464
+ * Merge two pre-aggregated states.
465
+ * Used when combining buckets (e.g., during re-aggregation for chart display).
466
+ */
467
+ mergeAggregatedStates(
468
+ a: Record<string, unknown> | undefined,
469
+ b: Record<string, unknown>,
470
+ ): Record<string, unknown> {
471
+ return this._mergeAggregatedStates(a, b);
472
+ }
473
+ }
@@ -0,0 +1,26 @@
1
+ import z from "zod";
2
+ import { configNumber } from "./zod-config";
3
+
4
+ /**
5
+ * Base configuration schema that all strategy configs should extend.
6
+ * Provides the required `timeout` field with a sensible default.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * const myConfigSchema = baseStrategyConfigSchema.extend({
11
+ * host: z.string().describe("Server hostname"),
12
+ * port: z.number().default(22),
13
+ * });
14
+ * ```
15
+ */
16
+ export const baseStrategyConfigSchema = z.object({
17
+ timeout: configNumber({})
18
+ .min(100)
19
+ .default(30_000)
20
+ .describe("Execution timeout in milliseconds"),
21
+ });
22
+
23
+ /**
24
+ * Base config type that all strategy configs must satisfy.
25
+ */
26
+ export type BaseStrategyConfig = z.infer<typeof baseStrategyConfigSchema>;
@@ -2,6 +2,10 @@ import type { PluginMetadata } from "@checkstack/common";
2
2
  import type { TransportClient } from "./transport-client";
3
3
  import type { Versioned } from "./config-versioning";
4
4
  import type { HealthCheckRunForAggregation } from "./health-check";
5
+ import type {
6
+ VersionedAggregated,
7
+ AggregatedResultShape,
8
+ } from "./aggregated-result";
5
9
 
6
10
  /**
7
11
  * Result from a collector execution.
@@ -58,8 +62,8 @@ export interface CollectorStrategy<
58
62
  /** Per-execution result schema (with x-chart-* metadata) */
59
63
  result: Versioned<TResult>;
60
64
 
61
- /** Aggregated result schema for bucket storage */
62
- aggregatedResult: Versioned<TAggregated>;
65
+ /** Aggregated result schema for bucket storage with merge function */
66
+ aggregatedResult: VersionedAggregated<AggregatedResultShape>;
63
67
 
64
68
  /**
65
69
  * Execute the collector using the provided transport client.
@@ -1,5 +1,10 @@
1
1
  import { Versioned } from "./config-versioning";
2
2
  import type { TransportClient } from "./transport-client";
3
+ import type {
4
+ VersionedAggregated,
5
+ AggregatedResultShape,
6
+ } from "./aggregated-result";
7
+ import type { BaseStrategyConfig } from "./base-strategy-config";
3
8
 
4
9
  /**
5
10
  * Health check result with typed metadata.
@@ -27,7 +32,7 @@ export interface HealthCheckRunForAggregation<
27
32
  * Connected transport client with cleanup capability.
28
33
  */
29
34
  export interface ConnectedClient<
30
- TClient extends TransportClient<unknown, unknown>,
35
+ TClient extends TransportClient<never, unknown>,
31
36
  > {
32
37
  /** The connected transport client */
33
38
  client: TClient;
@@ -42,19 +47,19 @@ export interface ConnectedClient<
42
47
  * and returns a transport client. The platform executor handles running
43
48
  * collectors and basic health check logic (connectivity test, latency measurement).
44
49
  *
45
- * @template TConfig - Configuration type for this strategy
50
+ * @template TConfig - Configuration type for this strategy (must include timeout)
46
51
  * @template TClient - Transport client type (e.g., SshTransportClient)
47
52
  * @template TResult - Per-run result type (for aggregation)
48
- * @template TAggregatedResult - Aggregated result type for buckets
53
+ * @template TAggregatedFields - Aggregated field definitions for VersionedAggregated
49
54
  */
50
55
  export interface HealthCheckStrategy<
51
- TConfig = unknown,
52
- TClient extends TransportClient<unknown, unknown> = TransportClient<
53
- unknown,
56
+ TConfig extends BaseStrategyConfig = BaseStrategyConfig,
57
+ TClient extends TransportClient<never, unknown> = TransportClient<
58
+ never,
54
59
  unknown
55
60
  >,
56
- TResult = Record<string, unknown>,
57
- TAggregatedResult = Record<string, unknown>,
61
+ TResult = unknown,
62
+ TAggregatedFields extends AggregatedResultShape = AggregatedResultShape,
58
63
  > {
59
64
  id: string;
60
65
  displayName: string;
@@ -66,18 +71,18 @@ export interface HealthCheckStrategy<
66
71
  /** Optional result schema with versioning and migrations */
67
72
  result?: Versioned<TResult>;
68
73
 
69
- /** Aggregated result schema for long-term bucket storage */
70
- aggregatedResult: Versioned<TAggregatedResult>;
74
+ /** Aggregated result schema for long-term bucket storage with automatic merging */
75
+ aggregatedResult: VersionedAggregated<TAggregatedFields>;
71
76
 
72
77
  /**
73
78
  * Create a connected transport client from the configuration.
74
79
  * The platform will use this client to execute collectors.
75
80
  *
76
- * @param config - Validated strategy configuration
81
+ * @param config - Strategy configuration (use config.validate() to narrow type)
77
82
  * @returns Connected client wrapper with close() method
78
83
  * @throws Error if connection fails (will be caught by executor)
79
84
  */
80
- createClient(config: TConfig): Promise<ConnectedClient<TClient>>;
85
+ createClient(config: unknown): Promise<ConnectedClient<TClient>>;
81
86
 
82
87
  /**
83
88
  * Incrementally merge new run data into an existing aggregate.
@@ -90,50 +95,24 @@ export interface HealthCheckStrategy<
90
95
  * @returns Updated aggregated result
91
96
  */
92
97
  mergeResult(
93
- existing: TAggregatedResult | undefined,
98
+ existing: Record<string, unknown> | undefined,
94
99
  newRun: HealthCheckRunForAggregation<TResult>,
95
- ): TAggregatedResult;
100
+ ): Record<string, unknown>;
96
101
  }
97
102
 
98
103
  /**
99
104
  * A registered strategy with its owning plugin metadata and qualified ID.
100
105
  */
101
106
  export interface RegisteredStrategy {
102
- strategy: HealthCheckStrategy<
103
- unknown,
104
- TransportClient<unknown, unknown>,
105
- unknown,
106
- unknown
107
- >;
107
+ strategy: HealthCheckStrategy;
108
108
  ownerPluginId: string;
109
109
  qualifiedId: string;
110
110
  }
111
111
 
112
112
  export interface HealthCheckRegistry {
113
- register(
114
- strategy: HealthCheckStrategy<
115
- unknown,
116
- TransportClient<unknown, unknown>,
117
- unknown,
118
- unknown
119
- >,
120
- ): void;
121
- getStrategy(
122
- id: string,
123
- ):
124
- | HealthCheckStrategy<
125
- unknown,
126
- TransportClient<unknown, unknown>,
127
- unknown,
128
- unknown
129
- >
130
- | undefined;
131
- getStrategies(): HealthCheckStrategy<
132
- unknown,
133
- TransportClient<unknown, unknown>,
134
- unknown,
135
- unknown
136
- >[];
113
+ register<S extends HealthCheckStrategy>(strategy: S): void;
114
+ getStrategy(id: string): HealthCheckStrategy | undefined;
115
+ getStrategies(): HealthCheckStrategy[];
137
116
  /**
138
117
  * Get all registered strategies with their metadata (qualified ID, owner plugin).
139
118
  */