@checkstack/healthcheck-backend 1.18.0 → 1.20.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/drizzle/0019_chemical_frightful_four.sql +8 -0
  3. package/drizzle/0020_certain_mordo.sql +2 -0
  4. package/drizzle/meta/0019_snapshot.json +661 -0
  5. package/drizzle/meta/0020_snapshot.json +711 -0
  6. package/drizzle/meta/_journal.json +14 -0
  7. package/package.json +23 -21
  8. package/src/ai/system-signals-contributor.test.ts +33 -9
  9. package/src/ai/system-signals-contributor.ts +38 -16
  10. package/src/cache-test-stub.ts +26 -0
  11. package/src/cache.test.ts +291 -0
  12. package/src/cache.ts +204 -34
  13. package/src/health-notification-content.test.ts +111 -0
  14. package/src/health-notification-content.ts +145 -0
  15. package/src/healthcheck-gitops-kinds.test.ts +14 -0
  16. package/src/healthcheck-gitops-kinds.ts +27 -0
  17. package/src/index.ts +31 -12
  18. package/src/queue-executor.test.ts +13 -26
  19. package/src/queue-executor.ts +125 -112
  20. package/src/retention-job.ts +8 -0
  21. package/src/rollup-consumer.test.ts +19 -8
  22. package/src/router-config-secrets.test.ts +2 -7
  23. package/src/router-create-and-assign.test.ts +2 -7
  24. package/src/router-pause-recompute.test.ts +2 -7
  25. package/src/router.test.ts +3 -8
  26. package/src/router.ts +43 -15
  27. package/src/schema.ts +74 -31
  28. package/src/service-batching.test.ts +8 -0
  29. package/src/service-bulk-counts.it.test.ts +144 -0
  30. package/src/service-bulk-run-stats.it.test.ts +197 -0
  31. package/src/service-ordering.test.ts +6 -2
  32. package/src/service-paused-filter.test.ts +13 -0
  33. package/src/service-rollup-worst-wins.test.ts +209 -145
  34. package/src/service.ts +408 -284
  35. package/src/status-fingerprint.test.ts +92 -0
  36. package/src/status-fingerprint.ts +66 -0
  37. package/src/status-page/rollup.test.ts +40 -0
  38. package/src/status-page/rollup.ts +27 -0
  39. package/src/status-page/widgets.test.ts +387 -0
  40. package/src/status-page/widgets.ts +236 -39
@@ -134,6 +134,20 @@
134
134
  "when": 1780357035712,
135
135
  "tag": "0018_abnormal_preak",
136
136
  "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1783585832198,
142
+ "tag": "0019_chemical_frightful_four",
143
+ "breakpoints": true
144
+ },
145
+ {
146
+ "idx": 20,
147
+ "version": "7",
148
+ "when": 1783607735562,
149
+ "tag": "0020_certain_mordo",
150
+ "breakpoints": true
137
151
  }
138
152
  ]
139
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,32 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.10.9",
17
+ "@checkstack/ai-backend": "0.10.11",
18
18
  "@checkstack/ai-common": "0.6.6",
19
- "@checkstack/automation-backend": "0.11.0",
20
- "@checkstack/backend-api": "0.31.0",
19
+ "@checkstack/automation-backend": "0.11.2",
20
+ "@checkstack/backend-api": "0.32.0",
21
21
  "@checkstack/cache-api": "0.3.19",
22
- "@checkstack/cache-utils": "0.2.24",
23
- "@checkstack/catalog-backend": "1.6.9",
24
- "@checkstack/catalog-common": "2.6.3",
25
- "@checkstack/command-backend": "0.2.21",
22
+ "@checkstack/cache-utils": "0.3.0",
23
+ "@checkstack/catalog-backend": "1.8.0",
24
+ "@checkstack/catalog-common": "2.7.1",
25
+ "@checkstack/command-backend": "0.2.23",
26
26
  "@checkstack/common": "0.22.0",
27
- "@checkstack/gitops-backend": "0.5.21",
27
+ "@checkstack/gitops-backend": "0.5.23",
28
28
  "@checkstack/gitops-common": "0.7.3",
29
- "@checkstack/healthcheck-common": "1.15.0",
30
- "@checkstack/incident-backend": "1.11.0",
31
- "@checkstack/incident-common": "1.9.0",
32
- "@checkstack/maintenance-common": "1.9.0",
33
- "@checkstack/notification-common": "1.5.3",
29
+ "@checkstack/healthcheck-common": "1.16.1",
30
+ "@checkstack/incident-backend": "1.13.0",
31
+ "@checkstack/incident-common": "1.10.1",
32
+ "@checkstack/maintenance-common": "1.10.1",
33
+ "@checkstack/notification-common": "1.7.0",
34
34
  "@checkstack/queue-api": "0.3.19",
35
- "@checkstack/satellite-backend": "0.8.3",
36
- "@checkstack/script-packages-backend": "0.4.0",
37
- "@checkstack/sdk": "0.126.1",
38
- "@checkstack/secrets-backend": "0.3.3",
35
+ "@checkstack/satellite-backend": "0.8.5",
36
+ "@checkstack/script-packages-backend": "0.4.2",
37
+ "@checkstack/sdk": "0.128.1",
38
+ "@checkstack/secrets-backend": "0.3.5",
39
39
  "@checkstack/secrets-common": "0.3.2",
40
40
  "@checkstack/signal-common": "0.2.17",
41
- "@checkstack/status-page-backend": "0.4.8",
42
- "@checkstack/status-page-common": "0.5.3",
41
+ "@checkstack/status-page-backend": "0.6.0",
42
+ "@checkstack/status-page-common": "0.6.1",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@orpc/contract": "^1.14.4",
45
45
  "@orpc/server": "^1.14.4",
@@ -53,13 +53,15 @@
53
53
  "devDependencies": {
54
54
  "@checkstack/drizzle-helper": "0.0.6",
55
55
  "@checkstack/scripts": "0.7.3",
56
- "@checkstack/test-utils-backend": "0.1.55",
56
+ "@checkstack/test-utils-backend": "0.1.57",
57
57
  "@checkstack/tsconfig": "0.0.7",
58
58
  "@types/bun": "^1.0.0",
59
+ "@types/pg": "^8.20.0",
59
60
  "@types/tdigest": "^0.1.5",
60
61
  "bullmq": "^5.66.4",
61
62
  "date-fns": "^4.4.0",
62
63
  "drizzle-kit": "^0.31.10",
64
+ "pg": "^8.21.0",
63
65
  "typescript": "^5.0.0"
64
66
  }
65
67
  }
@@ -7,7 +7,8 @@ import type { AuthUser } from "@checkstack/backend-api";
7
7
  import type { SystemAccessResolver } from "@checkstack/ai-backend";
8
8
  import {
9
9
  createHealthcheckSignalsContributor,
10
- type HealthcheckSignalsSource,
10
+ type HealthcheckCandidateSource,
11
+ type HealthcheckStatusCacheReader,
11
12
  } from "./system-signals-contributor";
12
13
 
13
14
  const unhealthyStatuses: HealthcheckSignalStatuses = {
@@ -27,10 +28,30 @@ const unhealthyStatuses: HealthcheckSignalStatuses = {
27
28
  },
28
29
  };
29
30
 
31
+ /**
32
+ * Build a candidate source + cache reader pair from a fixed status map: the
33
+ * candidate ids are the map's keys, and `readBulk` returns the requested subset.
34
+ * This mirrors the real wiring (service scans candidates, cache serves statuses).
35
+ */
30
36
  function sourceReturning(statuses: HealthcheckSignalStatuses): {
31
- source: HealthcheckSignalsSource;
37
+ candidateSource: HealthcheckCandidateSource;
38
+ cache: HealthcheckStatusCacheReader;
32
39
  } {
33
- return { source: { getAllUnhealthySystemStatuses: async () => statuses } };
40
+ return {
41
+ candidateSource: {
42
+ getUnhealthyCandidateSystemIds: async () => Object.keys(statuses),
43
+ },
44
+ cache: {
45
+ readBulk: async (systemIds) => {
46
+ const out: HealthcheckSignalStatuses = {};
47
+ for (const id of systemIds) {
48
+ const s = statuses[id];
49
+ if (s) out[id] = s;
50
+ }
51
+ return out;
52
+ },
53
+ },
54
+ };
34
55
  }
35
56
 
36
57
  // The per-source gate is owned/tested by createGatedSystemSignalsContributor;
@@ -47,18 +68,20 @@ const userWith = (accessRules: string[]): AuthUser => ({
47
68
 
48
69
  describe("createHealthcheckSignalsContributor", () => {
49
70
  it("exposes the shared source id", () => {
50
- const { source } = sourceReturning({});
71
+ const { candidateSource, cache } = sourceReturning({});
51
72
  const contributor = createHealthcheckSignalsContributor({
52
- service: source,
73
+ candidateSource,
74
+ cache,
53
75
  resolver: allowAll,
54
76
  });
55
77
  expect(contributor.sourceId).toBe(HEALTHCHECK_SIGNAL_SOURCE_ID);
56
78
  });
57
79
 
58
80
  it("wires the service + shared deriver for an authorized principal", async () => {
59
- const { source } = sourceReturning(unhealthyStatuses);
81
+ const { candidateSource, cache } = sourceReturning(unhealthyStatuses);
60
82
  const contributor = createHealthcheckSignalsContributor({
61
- service: source,
83
+ candidateSource,
84
+ cache,
62
85
  resolver: allowAll,
63
86
  });
64
87
 
@@ -75,9 +98,10 @@ describe("createHealthcheckSignalsContributor", () => {
75
98
  });
76
99
 
77
100
  it("routes a non-global user through the team gate (no grants -> nothing)", async () => {
78
- const { source } = sourceReturning(unhealthyStatuses);
101
+ const { candidateSource, cache } = sourceReturning(unhealthyStatuses);
79
102
  const contributor = createHealthcheckSignalsContributor({
80
- service: source,
103
+ candidateSource,
104
+ cache,
81
105
  resolver: denyAll,
82
106
  });
83
107
 
@@ -11,36 +11,58 @@ import {
11
11
  } from "@checkstack/ai-backend";
12
12
 
13
13
  /**
14
- * The slice of `HealthCheckService` this contributor needs: a global,
15
- * pod-independent scan of every currently-degraded/unhealthy system. Narrowed
16
- * to an interface so the contributor is unit-testable without constructing the
17
- * full service (or a database).
14
+ * The slice of `HealthCheckService` this contributor needs: the candidate set
15
+ * for the global problem scan (systems with at least one enabled check
16
+ * association). Narrowed to an interface so the contributor is unit-testable
17
+ * without constructing the full service (or a database).
18
18
  */
19
- export interface HealthcheckSignalsSource {
20
- getAllUnhealthySystemStatuses(): Promise<HealthcheckSignalStatuses>;
19
+ export interface HealthcheckCandidateSource {
20
+ getUnhealthyCandidateSystemIds(): Promise<string[]>;
21
21
  }
22
22
 
23
23
  /**
24
- * Build the healthcheck contributor for the AI `system.issues` aggregator. Reads
25
- * the global durable scan of unhealthy systems and runs the SAME deriver the
26
- * dashboard filler uses. The per-source access gate (global `healthcheck.status`
27
- * plus per-system team grants) is applied by
24
+ * The slice of `HealthCheckCache` this contributor reads through: the per-entity
25
+ * bulk status read. Routing the scan through the cache (rather than the service's
26
+ * uncached N+1 `getAllUnhealthySystemStatuses`) means the AI signals poll reuses
27
+ * the warm badge/dashboard cache and does not fan out one DB derivation per
28
+ * system on every poll.
29
+ */
30
+ export interface HealthcheckStatusCacheReader {
31
+ readBulk(systemIds: string[]): Promise<HealthcheckSignalStatuses>;
32
+ }
33
+
34
+ /**
35
+ * Build the healthcheck contributor for the AI `system.issues` aggregator. Scans
36
+ * the candidate systems (durable, pod-independent) and resolves each one's status
37
+ * through the SHARED cache, then runs the SAME deriver the dashboard filler uses
38
+ * (problems only - healthy systems are dropped). The per-source access gate
39
+ * (global `healthcheck.status` plus per-system team grants) is applied by
28
40
  * {@link createGatedSystemSignalsContributor}.
29
41
  */
30
42
  export function createHealthcheckSignalsContributor({
31
- service,
43
+ candidateSource,
44
+ cache,
32
45
  resolver,
33
46
  }: {
34
- service: HealthcheckSignalsSource;
47
+ candidateSource: HealthcheckCandidateSource;
48
+ cache: HealthcheckStatusCacheReader;
35
49
  resolver: SystemAccessResolver;
36
50
  }): SystemSignalsContributor {
37
51
  return createGatedSystemSignalsContributor({
38
52
  sourceId: HEALTHCHECK_SIGNAL_SOURCE_ID,
39
53
  accessRule: healthCheckAccess.status,
40
54
  resolver,
41
- readSignals: async () =>
42
- deriveHealthcheckSignals({
43
- statuses: await service.getAllUnhealthySystemStatuses(),
44
- }),
55
+ readSignals: async () => {
56
+ const systemIds = await candidateSource.getUnhealthyCandidateSystemIds();
57
+ const all = await cache.readBulk(systemIds);
58
+ // Keep only degraded/unhealthy systems (the same filter the uncached scan
59
+ // applied); the deriver drops healthy/unknown anyway, but filtering here
60
+ // keeps the payload small.
61
+ const statuses: HealthcheckSignalStatuses = {};
62
+ for (const [systemId, status] of Object.entries(all)) {
63
+ if (status.status !== "healthy") statuses[systemId] = status;
64
+ }
65
+ return deriveHealthcheckSignals({ statuses });
66
+ },
45
67
  });
46
68
  }
@@ -0,0 +1,26 @@
1
+ import { mock } from "bun:test";
2
+ import type { HealthCheckCache } from "./cache";
3
+
4
+ /**
5
+ * A no-op {@link HealthCheckCache} for tests that need to satisfy the router /
6
+ * executor `cache` dependency without exercising real caching. Every method is a
7
+ * `bun:test` mock so a test can assert calls or override behavior via
8
+ * `overrides`. `read` throws by default (a test that reaches the read path must
9
+ * opt in by overriding it), while the invalidators/reconcile no-op so mutation
10
+ * and run paths run unimpeded.
11
+ */
12
+ export function createStubHealthCheckCache(
13
+ overrides: Partial<HealthCheckCache> = {},
14
+ ): HealthCheckCache {
15
+ return {
16
+ read: mock(async () => {
17
+ throw new Error("stub HealthCheckCache.read not configured");
18
+ }),
19
+ readBulk: mock(async () => ({})),
20
+ readMatrix: mock(async () => ({})),
21
+ reconcile: mock(async () => {}),
22
+ invalidateSystem: mock(async () => {}),
23
+ invalidateAllSystems: mock(async () => 0),
24
+ ...overrides,
25
+ };
26
+ }
@@ -0,0 +1,291 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import type { CacheManager, CacheProvider } from "@checkstack/cache-api";
3
+ import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
4
+ import { createHealthCheckCache, type HealthStatusReader } from "./cache";
5
+
6
+ function createMemoryProvider(): CacheProvider {
7
+ const store = new Map<string, unknown>();
8
+ return {
9
+ get: async <T>(key: string) => store.get(key) as T | undefined,
10
+ set: async (key, value) => {
11
+ store.set(key, value);
12
+ },
13
+ delete: async (key) => {
14
+ store.delete(key);
15
+ },
16
+ deleteByPrefix: async (prefix) => {
17
+ let removed = 0;
18
+ for (const key of [...store.keys()]) {
19
+ if (key.startsWith(prefix)) {
20
+ store.delete(key);
21
+ removed++;
22
+ }
23
+ }
24
+ return removed;
25
+ },
26
+ has: async (key) => store.has(key),
27
+ };
28
+ }
29
+
30
+ function createManager(provider: CacheProvider): CacheManager {
31
+ return {
32
+ getProvider: () => provider,
33
+ getActivePlugin: () => "test",
34
+ getActiveConfig: () => ({}),
35
+ setActiveBackend: async () => {},
36
+ shutdown: async () => {},
37
+ } as unknown as CacheManager;
38
+ }
39
+
40
+ const silentLogger = {
41
+ debug: () => {},
42
+ info: () => {},
43
+ warn: () => {},
44
+ error: () => {},
45
+ } as never;
46
+
47
+ function response(
48
+ status: SystemHealthStatusResponse["status"],
49
+ checks: {
50
+ configurationId: string;
51
+ status: SystemHealthStatusResponse["status"];
52
+ failingSliceCount?: number;
53
+ sliceCount?: number;
54
+ }[] = [],
55
+ ): SystemHealthStatusResponse {
56
+ return {
57
+ status,
58
+ evaluatedAt: new Date(0),
59
+ checkStatuses: checks.map((c) => ({
60
+ configurationId: c.configurationId,
61
+ configurationName: c.configurationId,
62
+ status: c.status,
63
+ runsConsidered: 1,
64
+ sliceCount: c.sliceCount ?? 1,
65
+ failingSliceCount: c.failingSliceCount ?? 0,
66
+ })),
67
+ };
68
+ }
69
+
70
+ function setup(
71
+ opts: {
72
+ reader?: Partial<HealthStatusReader>;
73
+ } = {},
74
+ ) {
75
+ const provider = createMemoryProvider();
76
+ const reader: HealthStatusReader = {
77
+ getSystemHealthStatus:
78
+ opts.reader?.getSystemHealthStatus ?? (async () => response("healthy")),
79
+ getSystemEnvironmentIds:
80
+ opts.reader?.getSystemEnvironmentIds ?? (async () => []),
81
+ };
82
+ const cache = createHealthCheckCache({
83
+ cacheManager: createManager(provider),
84
+ logger: silentLogger,
85
+ service: reader,
86
+ });
87
+ return { cache, provider };
88
+ }
89
+
90
+ describe("HealthCheckCache.read", () => {
91
+ it("caches the loader value (second read does not re-hit the service)", async () => {
92
+ const getSystemHealthStatus = mock(async () => response("degraded"));
93
+ const { cache } = setup({ reader: { getSystemHealthStatus } });
94
+
95
+ const first = await cache.read("sys-1");
96
+ const second = await cache.read("sys-1");
97
+
98
+ expect(first.status).toBe("degraded");
99
+ expect(second.status).toBe("degraded");
100
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ it("keys per (system, environment)", async () => {
104
+ const getSystemHealthStatus = mock(
105
+ async (_systemId: string, environmentId?: string | null) =>
106
+ response(environmentId === "prod" ? "unhealthy" : "healthy"),
107
+ );
108
+ const { cache } = setup({ reader: { getSystemHealthStatus } });
109
+
110
+ const rollup = await cache.read("sys-1");
111
+ const prod = await cache.read("sys-1", "prod");
112
+
113
+ expect(rollup.status).toBe("healthy");
114
+ expect(prod.status).toBe("unhealthy");
115
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
116
+ });
117
+ });
118
+
119
+ describe("HealthCheckCache.reconcile", () => {
120
+ it("evicts only when the per-check vector changed", async () => {
121
+ let current = response("healthy", [
122
+ { configurationId: "c1", status: "healthy" },
123
+ ]);
124
+ const getSystemHealthStatus = mock(async () => current);
125
+ const { cache } = setup({ reader: { getSystemHealthStatus } });
126
+
127
+ // Warm the cache.
128
+ await cache.read("sys-1");
129
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(1);
130
+
131
+ // A run whose vector is unchanged: no evict, cache stays warm.
132
+ await cache.reconcile({
133
+ systemId: "sys-1",
134
+ previous: response("healthy", [
135
+ { configurationId: "c1", status: "healthy" },
136
+ ]),
137
+ next: response("healthy", [
138
+ { configurationId: "c1", status: "healthy" },
139
+ ]),
140
+ });
141
+ await cache.read("sys-1");
142
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(1); // served from cache
143
+
144
+ // A run that flips a check: evict; next read reloads.
145
+ current = response("degraded", [
146
+ { configurationId: "c1", status: "degraded" },
147
+ ]);
148
+ await cache.reconcile({
149
+ systemId: "sys-1",
150
+ previous: response("healthy", [
151
+ { configurationId: "c1", status: "healthy" },
152
+ ]),
153
+ next: current,
154
+ });
155
+ const reloaded = await cache.read("sys-1");
156
+ expect(reloaded.status).toBe("degraded");
157
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
158
+ });
159
+
160
+ it("a per-env change evicts BOTH its env key and the rollup key", async () => {
161
+ const getSystemHealthStatus = mock(
162
+ async (_systemId: string, environmentId?: string | null) =>
163
+ response(environmentId === "prod" ? "unhealthy" : "healthy"),
164
+ );
165
+ const { cache } = setup({ reader: { getSystemHealthStatus } });
166
+
167
+ await cache.read("sys-1"); // warm rollup
168
+ await cache.read("sys-1", "prod"); // warm env
169
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
170
+
171
+ await cache.reconcile({
172
+ systemId: "sys-1",
173
+ environmentId: "prod",
174
+ previous: response("healthy", [
175
+ { configurationId: "c1", status: "healthy" },
176
+ ]),
177
+ next: response("unhealthy", [
178
+ { configurationId: "c1", status: "unhealthy" },
179
+ ]),
180
+ });
181
+
182
+ // Both the rollup and the prod key reload from the service (sibling env
183
+ // keys would stay warm, but there are none here).
184
+ await cache.read("sys-1");
185
+ await cache.read("sys-1", "prod");
186
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(4);
187
+ });
188
+
189
+ it("handles a simultaneous slice swap the rollup fingerprint is blind to", async () => {
190
+ // A: unhealthy->healthy, B: healthy->unhealthy. The ROLLUP fingerprint does
191
+ // not move (failingSliceCount stays 1, worst status stays put), so the
192
+ // rollup is kept fresh by the per-env reconciles, not by a rollup diff.
193
+ let rollupCall = 0;
194
+ const getSystemHealthStatus = mock(
195
+ async (_systemId: string, environmentId?: string | null) => {
196
+ if (environmentId === undefined || environmentId === null) {
197
+ rollupCall++;
198
+ return response("unhealthy", [
199
+ {
200
+ configurationId: "c1",
201
+ status: "unhealthy",
202
+ sliceCount: 2,
203
+ failingSliceCount: 1,
204
+ },
205
+ ]);
206
+ }
207
+ return response("healthy");
208
+ },
209
+ );
210
+ const { cache } = setup({ reader: { getSystemHealthStatus } });
211
+
212
+ await cache.read("sys-1"); // warm rollup (rollupCall -> 1)
213
+ await cache.reconcile({
214
+ systemId: "sys-1",
215
+ environmentId: "a",
216
+ previous: response("unhealthy", [
217
+ { configurationId: "c1", status: "unhealthy" },
218
+ ]),
219
+ next: response("healthy", [
220
+ { configurationId: "c1", status: "healthy" },
221
+ ]),
222
+ });
223
+ await cache.reconcile({
224
+ systemId: "sys-1",
225
+ environmentId: "b",
226
+ previous: response("healthy", [
227
+ { configurationId: "c1", status: "healthy" },
228
+ ]),
229
+ next: response("unhealthy", [
230
+ { configurationId: "c1", status: "unhealthy" },
231
+ ]),
232
+ });
233
+
234
+ await cache.read("sys-1");
235
+ expect(rollupCall).toBe(2); // rollup was evicted + reloaded despite a stable fingerprint
236
+ });
237
+ });
238
+
239
+ describe("HealthCheckCache invalidators", () => {
240
+ it("invalidateSystem evicts the rollup + every per-env key", async () => {
241
+ const getSystemHealthStatus = mock(async () => response("healthy"));
242
+ const { cache } = setup({ reader: { getSystemHealthStatus } });
243
+
244
+ await cache.read("sys-1"); // warm rollup
245
+ await cache.read("sys-1", "prod"); // warm env
246
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
247
+
248
+ await cache.invalidateSystem("sys-1");
249
+
250
+ await cache.read("sys-1");
251
+ await cache.read("sys-1", "prod");
252
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(4); // both reloaded
253
+ });
254
+
255
+ it("invalidateAllSystems evicts every system's status", async () => {
256
+ const getSystemHealthStatus = mock(async () => response("healthy"));
257
+ const { cache } = setup({ reader: { getSystemHealthStatus } });
258
+
259
+ await cache.read("sys-1");
260
+ await cache.read("sys-2");
261
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
262
+
263
+ const removed = await cache.invalidateAllSystems();
264
+ expect(removed).toBe(2);
265
+
266
+ await cache.read("sys-1");
267
+ await cache.read("sys-2");
268
+ expect(getSystemHealthStatus).toHaveBeenCalledTimes(4); // both reloaded
269
+ });
270
+ });
271
+
272
+ describe("HealthCheckCache.readMatrix", () => {
273
+ it("assembles rollup + per-env slices from the cache", async () => {
274
+ const getSystemHealthStatus = mock(
275
+ async (_systemId: string, environmentId?: string | null) =>
276
+ response(environmentId === "prod" ? "unhealthy" : "healthy", [
277
+ { configurationId: "c1", status: "healthy" },
278
+ ]),
279
+ );
280
+ const { cache } = setup({
281
+ reader: {
282
+ getSystemHealthStatus,
283
+ getSystemEnvironmentIds: async () => ["prod"],
284
+ },
285
+ });
286
+
287
+ const matrix = await cache.readMatrix(["sys-1"]);
288
+ expect(matrix["sys-1"]!.status).toBe("healthy");
289
+ expect(matrix["sys-1"]!.environments["prod"]!.status).toBe("unhealthy");
290
+ });
291
+ });