@checkstack/healthcheck-tcp-backend 0.2.23 → 0.3.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,35 @@
1
1
  # @checkstack/healthcheck-tcp-backend
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9dcc848: Health-check strategy and collector configs now migrate-then-validate when loaded, instead of being cast/rendered raw.
8
+
9
+ These configs declared `version: 2`/`3` migrations but the load path never ran them: stored values are persisted UNVERSIONED, and the executor cast them straight to the strategy/collector type. Both the execution path (`queue-executor`) and the read API (`mapConfig`, feeding router / frontend / gitops `getConfiguration`) now use assume-v1-on-read (`Versioned.parseAssumingV1`): wrap as version 1, run the declared chain, then validate. Order is preserved: migrate -> secret resolve -> template render -> execute. An unregistered strategy/collector or a failed migrate falls back to the raw stored blob rather than dropping the configuration. Every reshaper migration is now IDEMPOTENT, guarding on its legacy discriminator so already-current data passes through untouched.
10
+
11
+ BREAKING CHANGE: for any config GENUINELY at version 1 in the database (e.g. an HTTP strategy still carrying `url`/`method`, or an execute collector still carrying `command`/`args`), the declared migrations now actually RUN on load, so the loaded/returned shape changes for such rows. This is the intended fix - those fields were already supposed to have been migrated away. Configs already at the current shape are unaffected. No data backfill is performed; migration is applied on every read.
12
+
13
+ This is a beta minor.
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [9dcc848]
18
+ - Updated dependencies [9dcc848]
19
+ - Updated dependencies [9dcc848]
20
+ - Updated dependencies [9dcc848]
21
+ - Updated dependencies [9dcc848]
22
+ - Updated dependencies [9dcc848]
23
+ - Updated dependencies [9dcc848]
24
+ - Updated dependencies [9dcc848]
25
+ - Updated dependencies [9dcc848]
26
+ - Updated dependencies [9dcc848]
27
+ - Updated dependencies [9dcc848]
28
+ - Updated dependencies [9dcc848]
29
+ - @checkstack/backend-api@0.21.0
30
+ - @checkstack/healthcheck-common@1.5.0
31
+ - @checkstack/common@0.13.0
32
+
3
33
  ## 0.2.23
4
34
 
5
35
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-tcp-backend",
3
- "version": "0.2.23",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,9 +14,9 @@
14
14
  "pack": "bunx @checkstack/scripts plugin-pack"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.18.0",
17
+ "@checkstack/backend-api": "0.20.0",
18
18
  "@checkstack/common": "0.12.0",
19
- "@checkstack/healthcheck-common": "1.3.0"
19
+ "@checkstack/healthcheck-common": "1.4.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "^1.0.0",
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Contract test: every TCP health-check Versioned schema stored UNVERSIONED
3
+ * and read back via assume-v1-on-read MUST have a COMPLETE, contiguous
4
+ * migration chain from version 1 to its current `version`. Pure STRUCTURAL
5
+ * check (`validateMigrationChainFromV1`); enumerates every Versioned field
6
+ * (config + result + aggregatedResult) of this plugin's strategy + collectors
7
+ * so a new collector or a bumped result schema is covered automatically. See
8
+ * the HTTP plugin's equivalent test for the full rationale.
9
+ */
10
+ import { describe, expect, it } from "bun:test";
11
+ import { TcpHealthCheckStrategy } from "./strategy";
12
+ import { BannerCollector } from "./banner-collector";
13
+
14
+ describe("tcp health-check migration-chain contract", () => {
15
+ const strategy = new TcpHealthCheckStrategy();
16
+ const collector = new BannerCollector();
17
+ const configs = [
18
+ { name: "tcp strategy config", config: strategy.config },
19
+ { name: "tcp strategy result", config: strategy.result },
20
+ { name: "tcp strategy aggregatedResult", config: strategy.aggregatedResult },
21
+ { name: "banner collector config", config: collector.config },
22
+ { name: "banner collector result", config: collector.result },
23
+ {
24
+ name: "banner collector aggregatedResult",
25
+ config: collector.aggregatedResult,
26
+ },
27
+ ];
28
+
29
+ it("every registered Versioned schema has a complete v1->version chain", () => {
30
+ for (const { name, config } of configs) {
31
+ const problem = config.validateMigrationChainFromV1();
32
+ expect(
33
+ problem,
34
+ `${name} (version ${config.version}) has a broken migration chain: ${problem}`,
35
+ ).toBeUndefined();
36
+ }
37
+ });
38
+ });
@@ -161,4 +161,42 @@ describe("TcpHealthCheckStrategy", () => {
161
161
  expect(aggregated.errorCount.count).toBe(1);
162
162
  });
163
163
  });
164
+
165
+ describe("config migration (assume-v1-on-read)", () => {
166
+ const strategy = new TcpHealthCheckStrategy();
167
+
168
+ it("migrates a genuine v1 blob (readBanner present) to {host, port, timeout}", async () => {
169
+ const migrated = await strategy.config.parseAssumingV1({
170
+ host: "example.com",
171
+ port: 443,
172
+ timeout: 5000,
173
+ readBanner: true,
174
+ });
175
+ expect(migrated).toEqual({ host: "example.com", port: 443, timeout: 5000 });
176
+ });
177
+
178
+ it("is idempotent: an already-current {host, port, timeout} blob is unchanged", async () => {
179
+ const migrated = await strategy.config.parseAssumingV1({
180
+ host: "db.internal",
181
+ port: 5432,
182
+ timeout: 3000,
183
+ });
184
+ expect(migrated).toEqual({ host: "db.internal", port: 5432, timeout: 3000 });
185
+ });
186
+
187
+ it("passes through a v1 row that omitted readBanner (already v2-shaped)", async () => {
188
+ // A v1 config that never carried readBanner already matches the v2 shape,
189
+ // so the guard correctly treats it as a no-op passthrough.
190
+ const migrated = await strategy.config.parseAssumingV1({
191
+ host: "host.local",
192
+ port: 22,
193
+ timeout: 2000,
194
+ });
195
+ expect(migrated).toEqual({ host: "host.local", port: 22, timeout: 2000 });
196
+ });
197
+
198
+ it("has a complete v1->version migration chain", () => {
199
+ expect(strategy.config.validateMigrationChainFromV1()).toBeUndefined();
200
+ });
201
+ });
164
202
  });
package/src/strategy.ts CHANGED
@@ -42,12 +42,26 @@ export const tcpConfigSchema = baseStrategyConfigSchema.extend({
42
42
 
43
43
  export type TcpConfig = z.infer<typeof tcpConfigSchema>;
44
44
 
45
- // Legacy config type for migrations
46
- interface TcpConfigV1 {
47
- host: string;
48
- port: number;
49
- timeout: number;
50
- readBanner: boolean;
45
+ // The migrate input is `unknown` per the versioning chain, so narrowing is
46
+ // done with `typeof`/`in` guards (no casts).
47
+
48
+ /** Type guard: the migrate input is a plain object whose keys can be probed. */
49
+ function isRecord(data: unknown): data is Record<string, unknown> {
50
+ return typeof data === "object" && data !== null;
51
+ }
52
+
53
+ /** Read a string field from a config blob. */
54
+ function readString(data: unknown, key: string): string | undefined {
55
+ if (!isRecord(data)) return undefined;
56
+ const value = data[key];
57
+ return typeof value === "string" ? value : undefined;
58
+ }
59
+
60
+ /** Read a numeric field from a config blob. */
61
+ function readNumber(data: unknown, key: string): number | undefined {
62
+ if (!isRecord(data)) return undefined;
63
+ const value = data[key];
64
+ return typeof value === "number" ? value : undefined;
51
65
  }
52
66
 
53
67
  /**
@@ -203,11 +217,19 @@ export class TcpHealthCheckStrategy implements HealthCheckStrategy<
203
217
  fromVersion: 1,
204
218
  toVersion: 2,
205
219
  description: "Remove readBanner (moved to BannerCollector)",
206
- migrate: (data: TcpConfigV1): TcpConfig => ({
207
- host: data.host,
208
- port: data.port,
209
- timeout: data.timeout,
210
- }),
220
+ // IDEMPOTENT: only a genuine v1 blob still carries `readBanner`. A v1
221
+ // row that happened to omit `readBanner` already matches the v2 shape
222
+ // (`{ host, port, timeout }`), so passthrough is correct there too.
223
+ migrate: (data: unknown): unknown => {
224
+ if (isRecord(data) && "readBanner" in data) {
225
+ return {
226
+ host: readString(data, "host"),
227
+ port: readNumber(data, "port"),
228
+ timeout: readNumber(data, "timeout"),
229
+ };
230
+ }
231
+ return data;
232
+ },
211
233
  },
212
234
  ],
213
235
  });