@checkstack/backend 0.0.2 → 0.1.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,50 @@
1
1
  # @checkstack/backend
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f5b1f49: Added collector registry lifecycle cleanup during plugin unloading.
8
+
9
+ - Added `unregisterByOwner(pluginId)` to remove collectors owned by unloading plugins
10
+ - Added `unregisterByMissingStrategies(loadedPluginIds)` for dependency-based pruning
11
+ - Integrated registry cleanup into `PluginManager.deregisterPlugin()`
12
+ - Updated `registerCoreServices` to return global registries for lifecycle management
13
+
14
+ ### Patch Changes
15
+
16
+ - f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
17
+
18
+ **JSONPath Assertions:**
19
+
20
+ - Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
21
+ - Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
22
+ - Added `jsonPath` field to `CollectorAssertionSchema` for persistence
23
+ - HTTP Request collector body field now supports JSONPath assertions
24
+
25
+ **Fully Qualified Strategy IDs:**
26
+
27
+ - HealthCheckRegistry now uses scoped factories like CollectorRegistry
28
+ - Strategies are stored with `pluginId.strategyId` format
29
+ - Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
30
+ - Router returns qualified IDs so frontend can correctly fetch collectors
31
+
32
+ **UI Improvements:**
33
+
34
+ - Save button disabled when collector configs have invalid required fields
35
+ - Fixed nested button warning in CollectorList accordion
36
+
37
+ - Updated dependencies [f5b1f49]
38
+ - Updated dependencies [f5b1f49]
39
+ - Updated dependencies [f5b1f49]
40
+ - @checkstack/backend-api@0.1.0
41
+ - @checkstack/common@0.0.3
42
+ - @checkstack/queue-api@0.0.3
43
+ - @checkstack/signal-backend@0.0.3
44
+ - @checkstack/api-docs-common@0.0.3
45
+ - @checkstack/auth-common@0.0.3
46
+ - @checkstack/signal-common@0.0.3
47
+
3
48
  ## 0.0.2
4
49
 
5
50
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "bun --env-file=../../.env --watch src/index.ts",
@@ -30,10 +30,13 @@ describe("HealthCheck Plugin Integration", () => {
30
30
  newResponse: mock(),
31
31
  } as never;
32
32
 
33
- // Define a mock execute function for the strategy
34
- const mockExecute = mock(async () => ({ status: "healthy" as const }));
33
+ // Define a mock createClient function for the strategy
34
+ const mockCreateClient = mock(async () => ({
35
+ client: { exec: async () => ({}) },
36
+ close: () => {},
37
+ }));
35
38
 
36
- // 1. Define a mock strategy
39
+ // 1. Define a mock strategy with createClient pattern
37
40
  const mockStrategy: HealthCheckStrategy = {
38
41
  id: "test-strategy",
39
42
  displayName: "Test Strategy",
@@ -42,11 +45,15 @@ describe("HealthCheck Plugin Integration", () => {
42
45
  version: 1,
43
46
  schema: z.object({}),
44
47
  }),
48
+ result: new Versioned({
49
+ version: 1,
50
+ schema: z.record(z.string(), z.unknown()),
51
+ }),
45
52
  aggregatedResult: new Versioned({
46
53
  version: 1,
47
54
  schema: z.record(z.string(), z.unknown()),
48
55
  }),
49
- execute: mockExecute,
56
+ createClient: mockCreateClient,
50
57
  aggregateResult: mock(() => ({})),
51
58
  };
52
59
 
@@ -88,6 +95,6 @@ describe("HealthCheck Plugin Integration", () => {
88
95
  expect(retrieved).toBe(mockStrategy);
89
96
  expect(retrieved?.displayName).toBe("Test Strategy");
90
97
  expect(retrieved?.id).toBe("test-strategy");
91
- expect(retrieved?.execute).toBe(mockExecute);
98
+ expect(retrieved?.createClient).toBe(mockCreateClient);
92
99
  });
93
100
  });
@@ -8,13 +8,11 @@ import {
8
8
  Logger,
9
9
  Fetch,
10
10
  HealthCheckRegistry,
11
+ CollectorRegistry,
11
12
  type EmitHookFn,
12
13
  type Hook,
13
14
  } from "@checkstack/backend-api";
14
- import type {
15
- QueuePluginRegistry,
16
- QueueManager,
17
- } from "@checkstack/queue-api";
15
+ import type { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
18
16
  import type { ServiceRegistry } from "../services/service-registry";
19
17
  import type { EventBus } from "@checkstack/backend-api";
20
18
  import type { PluginMetadata } from "@checkstack/common";
@@ -70,6 +68,7 @@ export function createApiRouteHandler({
70
68
  const healthCheckRegistry = await getService(
71
69
  coreServices.healthCheckRegistry
72
70
  );
71
+ const collectorRegistry = await getService(coreServices.collectorRegistry);
73
72
  const queuePluginRegistry = await getService(
74
73
  coreServices.queuePluginRegistry
75
74
  );
@@ -82,6 +81,7 @@ export function createApiRouteHandler({
82
81
  !db ||
83
82
  !fetch ||
84
83
  !healthCheckRegistry ||
84
+ !collectorRegistry ||
85
85
  !queuePluginRegistry ||
86
86
  !queueManager ||
87
87
  !eventBus
@@ -111,6 +111,7 @@ export function createApiRouteHandler({
111
111
  db: db as NodePgDatabase<Record<string, unknown>>,
112
112
  fetch: fetch as Fetch,
113
113
  healthCheckRegistry: healthCheckRegistry as HealthCheckRegistry,
114
+ collectorRegistry: collectorRegistry as CollectorRegistry,
114
115
  queuePluginRegistry: queuePluginRegistry as QueuePluginRegistry,
115
116
  queueManager: queueManager as QueueManager,
116
117
  user,
@@ -16,7 +16,14 @@ import type { ServiceRegistry } from "../services/service-registry";
16
16
  import { rootLogger } from "../logger";
17
17
  import { db } from "../db";
18
18
  import { jwtService } from "../services/jwt";
19
- import { CoreHealthCheckRegistry } from "../services/health-check-registry";
19
+ import {
20
+ CoreHealthCheckRegistry,
21
+ createScopedHealthCheckRegistry,
22
+ } from "../services/health-check-registry";
23
+ import {
24
+ CoreCollectorRegistry,
25
+ createScopedCollectorRegistry,
26
+ } from "../services/collector-registry";
20
27
  import { EventBus } from "../services/event-bus.js";
21
28
  import { getPluginSchemaName } from "@checkstack/drizzle-helper";
22
29
 
@@ -34,6 +41,7 @@ async function schemaExists(pool: Pool, schemaName: string): Promise<boolean> {
34
41
  /**
35
42
  * Registers all core services with the service registry.
36
43
  * Extracted from PluginManager for better organization.
44
+ * Returns the global registries for lifecycle cleanup.
37
45
  */
38
46
  export function registerCoreServices({
39
47
  registry,
@@ -47,7 +55,7 @@ export function registerCoreServices({
47
55
  pluginRpcRouters: Map<string, unknown>;
48
56
  pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
49
57
  pluginContractRegistry: Map<string, unknown>;
50
- }) {
58
+ }): { collectorRegistry: CoreCollectorRegistry } {
51
59
  // 1. Database Factory (Scoped)
52
60
  registry.registerFactory(coreServices.database, async (metadata) => {
53
61
  const { pluginId, previousPluginIds } = metadata;
@@ -258,11 +266,16 @@ export function registerCoreServices({
258
266
  return rpcClient;
259
267
  });
260
268
 
261
- // 6. Health Check Registry (Global Singleton)
262
- const healthCheckRegistry = new CoreHealthCheckRegistry();
263
- registry.registerFactory(
264
- coreServices.healthCheckRegistry,
265
- () => healthCheckRegistry
269
+ // 6. Health Check Registry (Scoped Factory - auto-prefixes strategy IDs with pluginId)
270
+ const globalHealthCheckRegistry = new CoreHealthCheckRegistry();
271
+ registry.registerFactory(coreServices.healthCheckRegistry, (metadata) =>
272
+ createScopedHealthCheckRegistry(globalHealthCheckRegistry, metadata)
273
+ );
274
+
275
+ // 6b. Collector Registry (Scoped Factory - injects ownerPlugin automatically)
276
+ const globalCollectorRegistry = new CoreCollectorRegistry();
277
+ registry.registerFactory(coreServices.collectorRegistry, (metadata) =>
278
+ createScopedCollectorRegistry(globalCollectorRegistry, metadata)
266
279
  );
267
280
 
268
281
  // 7. RPC Service (Scoped Factory - uses pluginId for path derivation)
@@ -309,4 +322,7 @@ export function registerCoreServices({
309
322
  }
310
323
  return eventBusInstance;
311
324
  });
325
+
326
+ // Return global registries for lifecycle cleanup
327
+ return { collectorRegistry: globalCollectorRegistry };
312
328
  }
@@ -1,6 +1,7 @@
1
1
  import type { Hono } from "hono";
2
2
  import { adminPool, db } from "./db";
3
3
  import { ServiceRegistry } from "./services/service-registry";
4
+ import type { CoreCollectorRegistry } from "./services/collector-registry";
4
5
  import {
5
6
  BackendPlugin,
6
7
  ServiceRef,
@@ -46,14 +47,18 @@ export class PluginManager {
46
47
  // Hook subscriptions per plugin (for bulk unsubscribe)
47
48
  private hookSubscriptions = new Map<string, HookUnsubscribe[]>();
48
49
 
50
+ // Global collector registry reference for cleanup
51
+ private collectorRegistry: CoreCollectorRegistry;
52
+
49
53
  constructor() {
50
- registerCoreServices({
54
+ const registries = registerCoreServices({
51
55
  registry: this.registry,
52
56
  adminPool,
53
57
  pluginRpcRouters: this.pluginRpcRouters,
54
58
  pluginHttpHandlers: this.pluginHttpHandlers,
55
59
  pluginContractRegistry: this.pluginContractRegistry,
56
60
  });
61
+ this.collectorRegistry = registries.collectorRegistry;
57
62
  }
58
63
 
59
64
  registerExtensionPoint<T>(ref: ExtensionPoint<T>, impl: T) {
@@ -163,6 +168,16 @@ export class PluginManager {
163
168
  this.pluginContractRegistry.delete(pluginId);
164
169
  rootLogger.debug(` -> Removed routers and contracts for ${pluginId}`);
165
170
 
171
+ // 4b. Cleanup collectors
172
+ // - Remove collectors owned by this plugin
173
+ this.collectorRegistry.unregisterByOwner(pluginId);
174
+ // - Remove collectors that have no loaded strategy plugins
175
+ // (exclude the current plugin being deregistered)
176
+ const loadedPluginIds = new Set(
177
+ [...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId)
178
+ );
179
+ this.collectorRegistry.unregisterByMissingStrategies(loadedPluginIds);
180
+
166
181
  // 5. Remove permissions from registry
167
182
  const beforeCount = this.registeredPermissions.length;
168
183
  this.registeredPermissions = this.registeredPermissions.filter(
@@ -0,0 +1,115 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+ import type {
3
+ CollectorStrategy,
4
+ TransportClient,
5
+ RegisteredCollector,
6
+ } from "@checkstack/backend-api";
7
+ import { rootLogger } from "../logger";
8
+
9
+ /**
10
+ * Core implementation of the CollectorRegistry storage.
11
+ * This is the global singleton that stores all collectors.
12
+ */
13
+ export class CoreCollectorRegistry {
14
+ private collectors = new Map<string, RegisteredCollector>();
15
+
16
+ registerWithOwner(
17
+ collector: CollectorStrategy<TransportClient<unknown, unknown>>,
18
+ ownerPlugin: PluginMetadata
19
+ ): void {
20
+ if (this.collectors.has(collector.id)) {
21
+ rootLogger.warn(
22
+ `CollectorStrategy '${collector.id}' is already registered. Overwriting.`
23
+ );
24
+ }
25
+ this.collectors.set(collector.id, { collector, ownerPlugin });
26
+ rootLogger.debug(
27
+ `✅ Registered CollectorStrategy: ${ownerPlugin.pluginId}.${collector.id}`
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Unregister all collectors owned by a specific plugin.
33
+ * Called when the collector-providing plugin is unloaded.
34
+ */
35
+ unregisterByOwner(ownerPluginId: string): void {
36
+ const toRemove: string[] = [];
37
+ for (const [id, entry] of this.collectors) {
38
+ if (entry.ownerPlugin.pluginId === ownerPluginId) {
39
+ toRemove.push(id);
40
+ }
41
+ }
42
+ for (const id of toRemove) {
43
+ this.collectors.delete(id);
44
+ rootLogger.debug(
45
+ `🗑️ Unregistered CollectorStrategy: ${id} (owner plugin unloaded)`
46
+ );
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Remove collectors that no longer have any supported strategies loaded.
52
+ * Called when a healthcheck strategy plugin is unloaded.
53
+ * @param loadedStrategyPluginIds - Set of plugin IDs that are still loaded
54
+ */
55
+ unregisterByMissingStrategies(loadedStrategyPluginIds: Set<string>): void {
56
+ const toRemove: string[] = [];
57
+ for (const [id, entry] of this.collectors) {
58
+ // Check if ANY of the collector's supported plugins are still loaded
59
+ const hasLoadedStrategy = entry.collector.supportedPlugins.some((p) =>
60
+ loadedStrategyPluginIds.has(p.pluginId)
61
+ );
62
+ // If none of the supported plugins are loaded, mark for removal
63
+ if (!hasLoadedStrategy) {
64
+ toRemove.push(id);
65
+ }
66
+ }
67
+ for (const id of toRemove) {
68
+ this.collectors.delete(id);
69
+ rootLogger.debug(
70
+ `🗑️ Unregistered CollectorStrategy: ${id} (no supported strategies loaded)`
71
+ );
72
+ }
73
+ }
74
+
75
+ getCollector(id: string): RegisteredCollector | undefined {
76
+ return this.collectors.get(id);
77
+ }
78
+
79
+ getCollectorsForPlugin(
80
+ pluginMetadata: PluginMetadata
81
+ ): RegisteredCollector[] {
82
+ return [...this.collectors.values()].filter((entry) =>
83
+ entry.collector.supportedPlugins.some(
84
+ (p) => p.pluginId === pluginMetadata.pluginId
85
+ )
86
+ );
87
+ }
88
+
89
+ getCollectors(): RegisteredCollector[] {
90
+ return [...this.collectors.values()];
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Creates a scoped CollectorRegistry that auto-injects owning plugin metadata.
96
+ */
97
+ export function createScopedCollectorRegistry(
98
+ globalRegistry: CoreCollectorRegistry,
99
+ ownerPlugin: PluginMetadata
100
+ ) {
101
+ return {
102
+ register(collector: CollectorStrategy<TransportClient<unknown, unknown>>) {
103
+ globalRegistry.registerWithOwner(collector, ownerPlugin);
104
+ },
105
+ getCollector(id: string) {
106
+ return globalRegistry.getCollector(id);
107
+ },
108
+ getCollectorsForPlugin(pluginMetadata: PluginMetadata) {
109
+ return globalRegistry.getCollectorsForPlugin(pluginMetadata);
110
+ },
111
+ getCollectors() {
112
+ return globalRegistry.getCollectors();
113
+ },
114
+ };
115
+ }
@@ -1,8 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, mock } from "bun:test";
2
- import { CoreHealthCheckRegistry } from "./health-check-registry";
2
+ import {
3
+ CoreHealthCheckRegistry,
4
+ createScopedHealthCheckRegistry,
5
+ } from "./health-check-registry";
3
6
  import { HealthCheckStrategy, Versioned } from "@checkstack/backend-api";
4
7
  import { createMockLogger } from "@checkstack/test-utils-backend";
5
8
  import { z } from "zod";
9
+ import type { PluginMetadata } from "@checkstack/common";
6
10
 
7
11
  // Mock logger
8
12
  const mockLogger = createMockLogger();
@@ -13,6 +17,8 @@ mock.module("../logger", () => ({
13
17
  describe("CoreHealthCheckRegistry", () => {
14
18
  let registry: CoreHealthCheckRegistry;
15
19
 
20
+ const mockOwner: PluginMetadata = { pluginId: "test-plugin" };
21
+
16
22
  const mockStrategy1: HealthCheckStrategy = {
17
23
  id: "test-strategy-1",
18
24
  displayName: "Test Strategy 1",
@@ -21,11 +27,17 @@ describe("CoreHealthCheckRegistry", () => {
21
27
  version: 1,
22
28
  schema: z.object({}),
23
29
  }),
30
+ result: new Versioned({
31
+ version: 1,
32
+ schema: z.record(z.string(), z.unknown()),
33
+ }),
24
34
  aggregatedResult: new Versioned({
25
35
  version: 1,
26
36
  schema: z.record(z.string(), z.unknown()),
27
37
  }),
28
- execute: mock(() => Promise.resolve({ status: "healthy" as const })),
38
+ createClient: mock(() =>
39
+ Promise.resolve({ client: { exec: async () => ({}) }, close: () => {} })
40
+ ),
29
41
  aggregateResult: mock(() => ({})),
30
42
  };
31
43
 
@@ -37,12 +49,16 @@ describe("CoreHealthCheckRegistry", () => {
37
49
  version: 1,
38
50
  schema: z.object({}),
39
51
  }),
52
+ result: new Versioned({
53
+ version: 1,
54
+ schema: z.record(z.string(), z.unknown()),
55
+ }),
40
56
  aggregatedResult: new Versioned({
41
57
  version: 1,
42
58
  schema: z.record(z.string(), z.unknown()),
43
59
  }),
44
- execute: mock(() =>
45
- Promise.resolve({ status: "unhealthy" as const, message: "Failed" })
60
+ createClient: mock(() =>
61
+ Promise.resolve({ client: { exec: async () => ({}) }, close: () => {} })
46
62
  ),
47
63
  aggregateResult: mock(() => ({})),
48
64
  };
@@ -51,31 +67,33 @@ describe("CoreHealthCheckRegistry", () => {
51
67
  registry = new CoreHealthCheckRegistry();
52
68
  });
53
69
 
54
- describe("register", () => {
55
- it("should register a new health check strategy", () => {
56
- registry.register(mockStrategy1);
57
- expect(registry.getStrategy(mockStrategy1.id)).toBe(mockStrategy1);
70
+ describe("registerWithOwner", () => {
71
+ it("should register a new health check strategy with qualified ID", () => {
72
+ registry.registerWithOwner(mockStrategy1, mockOwner);
73
+ // Should be stored with qualified ID: ownerPluginId.strategyId
74
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
75
+ expect(registry.getStrategy(qualifiedId)).toBe(mockStrategy1);
58
76
  });
59
77
 
60
- it("should overwrite an existing strategy with the same ID", () => {
61
- const overwritingStrategy: HealthCheckStrategy<any> = {
78
+ it("should overwrite an existing strategy with the same qualified ID", () => {
79
+ const overwritingStrategy: HealthCheckStrategy = {
62
80
  ...mockStrategy1,
63
81
  displayName: "New Name",
64
82
  };
65
- registry.register(mockStrategy1);
66
- registry.register(overwritingStrategy);
83
+ registry.registerWithOwner(mockStrategy1, mockOwner);
84
+ registry.registerWithOwner(overwritingStrategy, mockOwner);
67
85
 
68
- expect(registry.getStrategy(mockStrategy1.id)).toBe(overwritingStrategy);
69
- expect(registry.getStrategy(mockStrategy1.id)?.displayName).toBe(
70
- "New Name"
71
- );
86
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
87
+ expect(registry.getStrategy(qualifiedId)).toBe(overwritingStrategy);
88
+ expect(registry.getStrategy(qualifiedId)?.displayName).toBe("New Name");
72
89
  });
73
90
  });
74
91
 
75
- describe("getStrategySection", () => {
92
+ describe("getStrategy", () => {
76
93
  it("should return the strategy if it exists", () => {
77
- registry.register(mockStrategy1);
78
- expect(registry.getStrategy(mockStrategy1.id)).toBe(mockStrategy1);
94
+ registry.registerWithOwner(mockStrategy1, mockOwner);
95
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
96
+ expect(registry.getStrategy(qualifiedId)).toBe(mockStrategy1);
79
97
  });
80
98
 
81
99
  it("should return undefined if the strategy does not exist", () => {
@@ -85,8 +103,8 @@ describe("CoreHealthCheckRegistry", () => {
85
103
 
86
104
  describe("getStrategies", () => {
87
105
  it("should return all registered strategies", () => {
88
- registry.register(mockStrategy1);
89
- registry.register(mockStrategy2);
106
+ registry.registerWithOwner(mockStrategy1, mockOwner);
107
+ registry.registerWithOwner(mockStrategy2, mockOwner);
90
108
 
91
109
  const strategies = registry.getStrategies();
92
110
  expect(strategies).toHaveLength(2);
@@ -98,4 +116,27 @@ describe("CoreHealthCheckRegistry", () => {
98
116
  expect(registry.getStrategies()).toEqual([]);
99
117
  });
100
118
  });
119
+
120
+ describe("createScopedHealthCheckRegistry", () => {
121
+ it("should auto-qualify strategy IDs on register", () => {
122
+ const scoped = createScopedHealthCheckRegistry(registry, mockOwner);
123
+ scoped.register(mockStrategy1);
124
+
125
+ // The scoped registry should auto-prefix with pluginId
126
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
127
+ expect(registry.getStrategy(qualifiedId)).toBe(mockStrategy1);
128
+ });
129
+
130
+ it("should lookup strategies by both qualified and unqualified ID", () => {
131
+ const scoped = createScopedHealthCheckRegistry(registry, mockOwner);
132
+ scoped.register(mockStrategy1);
133
+
134
+ // Should be able to lookup by unqualified ID in scoped registry
135
+ expect(scoped.getStrategy(mockStrategy1.id)).toBe(mockStrategy1);
136
+
137
+ // Should also work with qualified ID
138
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
139
+ expect(scoped.getStrategy(qualifiedId)).toBe(mockStrategy1);
140
+ });
141
+ });
101
142
  });
@@ -1,27 +1,98 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
1
2
  import {
2
3
  HealthCheckRegistry,
3
4
  HealthCheckStrategy,
4
5
  } from "@checkstack/backend-api";
5
6
  import { rootLogger } from "../logger";
6
7
 
7
- export class CoreHealthCheckRegistry implements HealthCheckRegistry {
8
- private strategies = new Map<string, HealthCheckStrategy>();
8
+ /**
9
+ * Registered strategy with its fully qualified ID and owner info.
10
+ */
11
+ interface RegisteredStrategy {
12
+ strategy: HealthCheckStrategy;
13
+ ownerPlugin: PluginMetadata;
14
+ qualifiedId: string;
15
+ }
16
+
17
+ /**
18
+ * Core implementation of the HealthCheckRegistry storage.
19
+ * This is the global singleton that stores all strategies with fully qualified IDs.
20
+ */
21
+ export class CoreHealthCheckRegistry {
22
+ private strategies = new Map<string, RegisteredStrategy>();
9
23
 
10
- register(strategy: HealthCheckStrategy) {
11
- if (this.strategies.has(strategy.id)) {
24
+ /**
25
+ * Register a strategy with its owning plugin.
26
+ * The strategy ID is stored as: ownerPluginId.strategyId
27
+ */
28
+ registerWithOwner(
29
+ strategy: HealthCheckStrategy,
30
+ ownerPlugin: PluginMetadata
31
+ ): void {
32
+ const qualifiedId = `${ownerPlugin.pluginId}.${strategy.id}`;
33
+ if (this.strategies.has(qualifiedId)) {
12
34
  rootLogger.warn(
13
- `HealthCheckStrategy '${strategy.id}' is already registered. Overwriting.`
35
+ `HealthCheckStrategy '${qualifiedId}' is already registered. Overwriting.`
14
36
  );
15
37
  }
16
- this.strategies.set(strategy.id, strategy);
17
- rootLogger.debug(`✅ Registered HealthCheckStrategy: ${strategy.id}`);
38
+ this.strategies.set(qualifiedId, { strategy, ownerPlugin, qualifiedId });
39
+ rootLogger.debug(`✅ Registered HealthCheckStrategy: ${qualifiedId}`);
18
40
  }
19
41
 
20
- getStrategy(id: string) {
21
- return this.strategies.get(id);
42
+ getStrategy(qualifiedId: string) {
43
+ return this.strategies.get(qualifiedId)?.strategy;
22
44
  }
23
45
 
24
46
  getStrategies() {
25
- return [...this.strategies.values()];
47
+ return [...this.strategies.values()].map((r) => r.strategy);
26
48
  }
49
+
50
+ /**
51
+ * Get the owner plugin ID for a strategy.
52
+ */
53
+ getOwnerPluginId(qualifiedId: string): string | undefined {
54
+ return this.strategies.get(qualifiedId)?.ownerPlugin.pluginId;
55
+ }
56
+
57
+ /**
58
+ * Get all registered strategies with their metadata.
59
+ */
60
+ getStrategiesWithMeta(): Array<{
61
+ strategy: HealthCheckStrategy;
62
+ ownerPluginId: string;
63
+ qualifiedId: string;
64
+ }> {
65
+ return [...this.strategies.values()].map((r) => ({
66
+ strategy: r.strategy,
67
+ ownerPluginId: r.ownerPlugin.pluginId,
68
+ qualifiedId: r.qualifiedId,
69
+ }));
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Creates a scoped HealthCheckRegistry that auto-prefixes strategy IDs with plugin ID.
75
+ */
76
+ export function createScopedHealthCheckRegistry(
77
+ globalRegistry: CoreHealthCheckRegistry,
78
+ ownerPlugin: PluginMetadata
79
+ ): HealthCheckRegistry {
80
+ return {
81
+ register(strategy: HealthCheckStrategy) {
82
+ globalRegistry.registerWithOwner(strategy, ownerPlugin);
83
+ },
84
+ getStrategy(id: string) {
85
+ // Support both qualified and unqualified lookups
86
+ return (
87
+ globalRegistry.getStrategy(id) ||
88
+ globalRegistry.getStrategy(`${ownerPlugin.pluginId}.${id}`)
89
+ );
90
+ },
91
+ getStrategies() {
92
+ return globalRegistry.getStrategies();
93
+ },
94
+ getStrategiesWithMeta() {
95
+ return globalRegistry.getStrategiesWithMeta();
96
+ },
97
+ };
27
98
  }
@@ -110,5 +110,20 @@ mock.module(coreServicesPath, () => ({
110
110
  // eslint-disable-next-line unicorn/consistent-function-scoping
111
111
  subscribe: async () => () => {},
112
112
  }));
113
+
114
+ // Return the registries object to match actual function signature
115
+ // Create a mock collector registry
116
+ const collectors = new Map<string, unknown>();
117
+ return {
118
+ collectorRegistry: {
119
+ register: (collector: { id: string }) => {
120
+ collectors.set(collector.id, collector);
121
+ },
122
+ getCollector: (id: string) => collectors.get(id),
123
+ getAllCollectors: () => [...collectors.values()],
124
+ unregisterByOwner: () => {},
125
+ unregisterByMissingStrategies: () => {},
126
+ },
127
+ };
113
128
  },
114
129
  }));