@checkstack/integration-backend 0.0.2

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,396 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { z } from "zod";
3
+ import {
4
+ createIntegrationEventRegistry,
5
+ type IntegrationEventRegistry,
6
+ } from "./event-registry";
7
+ import type { IntegrationEventDefinition } from "./provider-types";
8
+ import { createHook } from "@checkstack/backend-api";
9
+
10
+ /**
11
+ * Unit tests for IntegrationEventRegistry.
12
+ *
13
+ * Tests cover:
14
+ * - Event registration with proper namespacing
15
+ * - Event retrieval by ID and category
16
+ * - JSON Schema generation from Zod
17
+ * - Transform function preservation
18
+ */
19
+
20
+ // Test plugin metadata
21
+ const testPluginMetadata = {
22
+ pluginId: "test-plugin",
23
+ displayName: "Test Plugin",
24
+ description: "A test plugin",
25
+ } as const;
26
+
27
+ // Test hook definitions
28
+ const testHook1 = createHook<{ incidentId: string; severity: string }>(
29
+ "incident.created"
30
+ );
31
+ const testHook2 = createHook<{ systemId: string; status: string }>(
32
+ "system.updated"
33
+ );
34
+ const testHook3 = createHook<{ maintenanceId: string }>("maintenance.started");
35
+
36
+ // Test payload schemas
37
+ const incidentPayloadSchema = z.object({
38
+ incidentId: z.string(),
39
+ severity: z.string(),
40
+ });
41
+
42
+ const systemPayloadSchema = z.object({
43
+ systemId: z.string(),
44
+ status: z.string(),
45
+ });
46
+
47
+ const maintenancePayloadSchema = z.object({
48
+ maintenanceId: z.string(),
49
+ });
50
+
51
+ describe("IntegrationEventRegistry", () => {
52
+ let registry: IntegrationEventRegistry;
53
+
54
+ beforeEach(() => {
55
+ registry = createIntegrationEventRegistry();
56
+ });
57
+
58
+ // ─────────────────────────────────────────────────────────────────────────
59
+ // Event Registration
60
+ // ─────────────────────────────────────────────────────────────────────────
61
+
62
+ describe("register", () => {
63
+ it("registers an event with a fully qualified ID", () => {
64
+ const definition: IntegrationEventDefinition<{
65
+ incidentId: string;
66
+ severity: string;
67
+ }> = {
68
+ hook: testHook1,
69
+ displayName: "Incident Created",
70
+ description: "Fired when an incident is created",
71
+ category: "Incidents",
72
+ payloadSchema: incidentPayloadSchema,
73
+ };
74
+
75
+ registry.register(definition, testPluginMetadata);
76
+
77
+ expect(registry.hasEvent("test-plugin.incident.created")).toBe(true);
78
+ });
79
+
80
+ it("generates correct fully qualified event ID", () => {
81
+ registry.register(
82
+ {
83
+ hook: testHook1,
84
+ displayName: "Incident Created",
85
+ payloadSchema: incidentPayloadSchema,
86
+ },
87
+ testPluginMetadata
88
+ );
89
+
90
+ const event = registry.getEvent("test-plugin.incident.created");
91
+ expect(event?.eventId).toBe("test-plugin.incident.created");
92
+ expect(event?.ownerPluginId).toBe("test-plugin");
93
+ });
94
+
95
+ it("preserves display metadata", () => {
96
+ registry.register(
97
+ {
98
+ hook: testHook1,
99
+ displayName: "Incident Created",
100
+ description: "Fired when an incident is created",
101
+ category: "Incidents",
102
+ payloadSchema: incidentPayloadSchema,
103
+ },
104
+ testPluginMetadata
105
+ );
106
+
107
+ const event = registry.getEvent("test-plugin.incident.created");
108
+ expect(event?.displayName).toBe("Incident Created");
109
+ expect(event?.description).toBe("Fired when an incident is created");
110
+ expect(event?.category).toBe("Incidents");
111
+ });
112
+
113
+ it("defaults category to Uncategorized", () => {
114
+ registry.register(
115
+ {
116
+ hook: testHook1,
117
+ displayName: "Incident Created",
118
+ payloadSchema: incidentPayloadSchema,
119
+ },
120
+ testPluginMetadata
121
+ );
122
+
123
+ const event = registry.getEvent("test-plugin.incident.created");
124
+ expect(event?.category).toBe("Uncategorized");
125
+ });
126
+
127
+ it("generates JSON Schema from Zod schema", () => {
128
+ registry.register(
129
+ {
130
+ hook: testHook1,
131
+ displayName: "Incident Created",
132
+ payloadSchema: incidentPayloadSchema,
133
+ },
134
+ testPluginMetadata
135
+ );
136
+
137
+ const event = registry.getEvent("test-plugin.incident.created");
138
+ expect(event?.payloadJsonSchema).toBeDefined();
139
+ expect(typeof event?.payloadJsonSchema).toBe("object");
140
+
141
+ // JSON Schema should have properties for the payload fields
142
+ const schema = event?.payloadJsonSchema as Record<string, unknown>;
143
+ expect(schema.type).toBe("object");
144
+ });
145
+
146
+ it("preserves transform function", () => {
147
+ const transform = (payload: {
148
+ incidentId: string;
149
+ severity: string;
150
+ }) => ({
151
+ id: payload.incidentId,
152
+ level: payload.severity.toUpperCase(),
153
+ });
154
+
155
+ registry.register(
156
+ {
157
+ hook: testHook1,
158
+ displayName: "Incident Created",
159
+ payloadSchema: incidentPayloadSchema,
160
+ transformPayload: transform,
161
+ },
162
+ testPluginMetadata
163
+ );
164
+
165
+ const event = registry.getEvent("test-plugin.incident.created");
166
+ expect(event?.transformPayload).toBeDefined();
167
+
168
+ const transformed = event?.transformPayload?.({
169
+ incidentId: "inc-123",
170
+ severity: "critical",
171
+ });
172
+ expect(transformed).toEqual({ id: "inc-123", level: "CRITICAL" });
173
+ });
174
+
175
+ it("preserves hook reference", () => {
176
+ registry.register(
177
+ {
178
+ hook: testHook1,
179
+ displayName: "Incident Created",
180
+ payloadSchema: incidentPayloadSchema,
181
+ },
182
+ testPluginMetadata
183
+ );
184
+
185
+ const event = registry.getEvent("test-plugin.incident.created");
186
+ expect(event?.hook.id).toBe("incident.created");
187
+ });
188
+ });
189
+
190
+ // ─────────────────────────────────────────────────────────────────────────
191
+ // Event Retrieval
192
+ // ─────────────────────────────────────────────────────────────────────────
193
+
194
+ describe("getEvents", () => {
195
+ it("returns empty array when no events registered", () => {
196
+ expect(registry.getEvents()).toEqual([]);
197
+ });
198
+
199
+ it("returns all registered events", () => {
200
+ registry.register(
201
+ {
202
+ hook: testHook1,
203
+ displayName: "Incident Created",
204
+ payloadSchema: incidentPayloadSchema,
205
+ },
206
+ testPluginMetadata
207
+ );
208
+ registry.register(
209
+ {
210
+ hook: testHook2,
211
+ displayName: "System Updated",
212
+ payloadSchema: systemPayloadSchema,
213
+ },
214
+ testPluginMetadata
215
+ );
216
+
217
+ const events = registry.getEvents();
218
+ expect(events.length).toBe(2);
219
+ expect(events.map((e) => e.displayName).sort()).toEqual([
220
+ "Incident Created",
221
+ "System Updated",
222
+ ]);
223
+ });
224
+ });
225
+
226
+ describe("getEvent", () => {
227
+ it("returns undefined for non-existent event", () => {
228
+ expect(registry.getEvent("non-existent.event")).toBeUndefined();
229
+ });
230
+
231
+ it("returns event by fully qualified ID", () => {
232
+ registry.register(
233
+ {
234
+ hook: testHook1,
235
+ displayName: "Incident Created",
236
+ payloadSchema: incidentPayloadSchema,
237
+ },
238
+ testPluginMetadata
239
+ );
240
+
241
+ const event = registry.getEvent("test-plugin.incident.created");
242
+ expect(event?.displayName).toBe("Incident Created");
243
+ });
244
+ });
245
+
246
+ describe("hasEvent", () => {
247
+ it("returns false for non-existent event", () => {
248
+ expect(registry.hasEvent("non-existent.event")).toBe(false);
249
+ });
250
+
251
+ it("returns true for registered event", () => {
252
+ registry.register(
253
+ {
254
+ hook: testHook1,
255
+ displayName: "Incident Created",
256
+ payloadSchema: incidentPayloadSchema,
257
+ },
258
+ testPluginMetadata
259
+ );
260
+
261
+ expect(registry.hasEvent("test-plugin.incident.created")).toBe(true);
262
+ });
263
+ });
264
+
265
+ // ─────────────────────────────────────────────────────────────────────────
266
+ // Category Grouping
267
+ // ─────────────────────────────────────────────────────────────────────────
268
+
269
+ describe("getEventsByCategory", () => {
270
+ it("returns empty map when no events registered", () => {
271
+ const byCategory = registry.getEventsByCategory();
272
+ expect(byCategory.size).toBe(0);
273
+ });
274
+
275
+ it("groups events by category", () => {
276
+ registry.register(
277
+ {
278
+ hook: testHook1,
279
+ displayName: "Incident Created",
280
+ category: "Incidents",
281
+ payloadSchema: incidentPayloadSchema,
282
+ },
283
+ testPluginMetadata
284
+ );
285
+ registry.register(
286
+ {
287
+ hook: testHook2,
288
+ displayName: "System Updated",
289
+ category: "Catalog",
290
+ payloadSchema: systemPayloadSchema,
291
+ },
292
+ testPluginMetadata
293
+ );
294
+ registry.register(
295
+ {
296
+ hook: testHook3,
297
+ displayName: "Maintenance Started",
298
+ category: "Incidents",
299
+ payloadSchema: maintenancePayloadSchema,
300
+ },
301
+ testPluginMetadata
302
+ );
303
+
304
+ const byCategory = registry.getEventsByCategory();
305
+
306
+ expect(byCategory.size).toBe(2);
307
+ expect(byCategory.get("Incidents")?.length).toBe(2);
308
+ expect(byCategory.get("Catalog")?.length).toBe(1);
309
+ });
310
+
311
+ it("groups uncategorized events together", () => {
312
+ registry.register(
313
+ {
314
+ hook: testHook1,
315
+ displayName: "Event 1",
316
+ payloadSchema: incidentPayloadSchema,
317
+ },
318
+ testPluginMetadata
319
+ );
320
+ registry.register(
321
+ {
322
+ hook: testHook2,
323
+ displayName: "Event 2",
324
+ payloadSchema: systemPayloadSchema,
325
+ },
326
+ testPluginMetadata
327
+ );
328
+
329
+ const byCategory = registry.getEventsByCategory();
330
+ expect(byCategory.get("Uncategorized")?.length).toBe(2);
331
+ });
332
+ });
333
+
334
+ // ─────────────────────────────────────────────────────────────────────────
335
+ // Multi-Plugin Registration
336
+ // ─────────────────────────────────────────────────────────────────────────
337
+
338
+ describe("multi-plugin registration", () => {
339
+ it("handles events from multiple plugins", () => {
340
+ const plugin1 = { pluginId: "plugin-1" } as const;
341
+ const plugin2 = { pluginId: "plugin-2" } as const;
342
+
343
+ registry.register(
344
+ {
345
+ hook: testHook1,
346
+ displayName: "Plugin 1 Event",
347
+ payloadSchema: incidentPayloadSchema,
348
+ },
349
+ plugin1
350
+ );
351
+ registry.register(
352
+ {
353
+ hook: testHook1,
354
+ displayName: "Plugin 2 Event",
355
+ payloadSchema: incidentPayloadSchema,
356
+ },
357
+ plugin2
358
+ );
359
+
360
+ expect(registry.hasEvent("plugin-1.incident.created")).toBe(true);
361
+ expect(registry.hasEvent("plugin-2.incident.created")).toBe(true);
362
+
363
+ const events = registry.getEvents();
364
+ expect(events.length).toBe(2);
365
+ });
366
+
367
+ it("correctly namespaces events by plugin", () => {
368
+ const plugin1 = { pluginId: "incident" } as const;
369
+ const plugin2 = { pluginId: "maintenance" } as const;
370
+
371
+ registry.register(
372
+ {
373
+ hook: createHook("created"),
374
+ displayName: "Incident Created",
375
+ payloadSchema: incidentPayloadSchema,
376
+ },
377
+ plugin1
378
+ );
379
+ registry.register(
380
+ {
381
+ hook: createHook("created"),
382
+ displayName: "Maintenance Created",
383
+ payloadSchema: maintenancePayloadSchema,
384
+ },
385
+ plugin2
386
+ );
387
+
388
+ expect(registry.getEvent("incident.created")?.displayName).toBe(
389
+ "Incident Created"
390
+ );
391
+ expect(registry.getEvent("maintenance.created")?.displayName).toBe(
392
+ "Maintenance Created"
393
+ );
394
+ });
395
+ });
396
+ });
@@ -0,0 +1,99 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+ import { toJsonSchema } from "@checkstack/backend-api";
3
+ import type {
4
+ IntegrationEventDefinition,
5
+ RegisteredIntegrationEvent,
6
+ } from "./provider-types";
7
+
8
+ /**
9
+ * Registry for integration events.
10
+ * Plugins register their hooks here to expose them for external webhook subscriptions.
11
+ */
12
+ export interface IntegrationEventRegistry {
13
+ /**
14
+ * Register a hook as an integration event.
15
+ * Called via the extension point during plugin registration.
16
+ */
17
+ register<T>(
18
+ definition: IntegrationEventDefinition<T>,
19
+ pluginMetadata: PluginMetadata
20
+ ): void;
21
+
22
+ /** Get all registered events */
23
+ getEvents(): RegisteredIntegrationEvent[];
24
+
25
+ /** Get events grouped by category */
26
+ getEventsByCategory(): Map<string, RegisteredIntegrationEvent[]>;
27
+
28
+ /** Get a specific event by its fully qualified ID */
29
+ getEvent(eventId: string): RegisteredIntegrationEvent | undefined;
30
+
31
+ /** Check if an event is registered */
32
+ hasEvent(eventId: string): boolean;
33
+ }
34
+
35
+ /**
36
+ * Create a new integration event registry instance.
37
+ */
38
+ export function createIntegrationEventRegistry(): IntegrationEventRegistry {
39
+ const events = new Map<string, RegisteredIntegrationEvent>();
40
+
41
+ return {
42
+ register<T>(
43
+ definition: IntegrationEventDefinition<T>,
44
+ pluginMetadata: PluginMetadata
45
+ ): void {
46
+ // Extract hook ID from the hook reference
47
+ const hookId = definition.hook.id;
48
+
49
+ // Create fully qualified event ID
50
+ const eventId = `${pluginMetadata.pluginId}.${hookId}`;
51
+
52
+ // Convert Zod schema to JSON Schema for UI preview
53
+ // Uses the platform's toJsonSchema which handles secrets/colors
54
+ const payloadJsonSchema = toJsonSchema(definition.payloadSchema);
55
+
56
+ const registered: RegisteredIntegrationEvent<T> = {
57
+ eventId,
58
+ hook: definition.hook,
59
+ ownerPluginId: pluginMetadata.pluginId,
60
+ displayName: definition.displayName,
61
+ description: definition.description,
62
+ category: definition.category ?? "Uncategorized",
63
+ payloadJsonSchema,
64
+ payloadSchema: definition.payloadSchema,
65
+ transformPayload: definition.transformPayload,
66
+ };
67
+
68
+ // We cast to RegisteredIntegrationEvent (with unknown) when storing because
69
+ // the Map erases the specific type T anyway. This is type-safe because
70
+ // the transformPayload function will only be called with the correct type.
71
+ events.set(eventId, registered as RegisteredIntegrationEvent);
72
+ },
73
+
74
+ getEvents(): RegisteredIntegrationEvent[] {
75
+ return [...events.values()];
76
+ },
77
+
78
+ getEventsByCategory(): Map<string, RegisteredIntegrationEvent[]> {
79
+ const byCategory = new Map<string, RegisteredIntegrationEvent[]>();
80
+
81
+ for (const event of events.values()) {
82
+ const category = event.category ?? "Uncategorized";
83
+ const existing = byCategory.get(category) ?? [];
84
+ existing.push(event);
85
+ byCategory.set(category, existing);
86
+ }
87
+
88
+ return byCategory;
89
+ },
90
+
91
+ getEvent(eventId: string): RegisteredIntegrationEvent | undefined {
92
+ return events.get(eventId);
93
+ },
94
+
95
+ hasEvent(eventId: string): boolean {
96
+ return events.has(eventId);
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,104 @@
1
+ import type {
2
+ Logger,
3
+ HookSubscribeOptions,
4
+ } from "@checkstack/backend-api";
5
+ import type { IntegrationEventRegistry } from "./event-registry";
6
+ import type { DeliveryCoordinator } from "./delivery-coordinator";
7
+ import type { RegisteredIntegrationEvent } from "./provider-types";
8
+
9
+ /**
10
+ * Hook subscription function type (matches env.onHook signature)
11
+ */
12
+ type OnHookFn = <T>(
13
+ hook: { id: string; _type?: T },
14
+ listener: (payload: T) => Promise<void>,
15
+ options?: HookSubscribeOptions
16
+ ) => () => Promise<void>;
17
+
18
+ /**
19
+ * Subscribe to all registered integration events.
20
+ *
21
+ * This function is called during afterPluginsReady to set up hook listeners
22
+ * for each registered integration event. It uses work-queue mode to ensure
23
+ * that only ONE backend instance processes each event (important for horizontal scaling).
24
+ *
25
+ * @param onHook - The onHook function from the plugin environment
26
+ * @param eventRegistry - The registry of integration events
27
+ * @param deliveryCoordinator - The coordinator that routes events to subscriptions
28
+ * @param logger - Logger for debugging
29
+ */
30
+ export function subscribeToRegisteredEvents({
31
+ onHook,
32
+ eventRegistry,
33
+ deliveryCoordinator,
34
+ logger,
35
+ }: {
36
+ onHook: OnHookFn;
37
+ eventRegistry: IntegrationEventRegistry;
38
+ deliveryCoordinator: DeliveryCoordinator;
39
+ logger: Logger;
40
+ }): void {
41
+ const events = eventRegistry.getEvents();
42
+
43
+ logger.debug(`Subscribing to ${events.length} integration events...`);
44
+
45
+ for (const event of events) {
46
+ subscribeToEvent(event, onHook, deliveryCoordinator, logger);
47
+ }
48
+
49
+ logger.info(
50
+ `Subscribed to ${events.length} integration events for webhook delivery`
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Subscribe to a single integration event
56
+ */
57
+ function subscribeToEvent(
58
+ event: RegisteredIntegrationEvent,
59
+ onHook: OnHookFn,
60
+ deliveryCoordinator: DeliveryCoordinator,
61
+ logger: Logger
62
+ ): void {
63
+ // Create a unique worker group for this event
64
+ // This ensures competing consumer behavior across instances
65
+ const workerGroup = `webhook-${event.eventId}`;
66
+
67
+ onHook(
68
+ event.hook,
69
+ async (payload: unknown) => {
70
+ logger.debug(`Received integration event: ${event.eventId}`);
71
+
72
+ try {
73
+ // Apply optional payload transformation
74
+ const transformedPayload = event.transformPayload
75
+ ? event.transformPayload(payload)
76
+ : (payload as Record<string, unknown>);
77
+
78
+ // Route to matching subscriptions
79
+ await deliveryCoordinator.routeEvent({
80
+ eventId: event.eventId,
81
+ payload: transformedPayload,
82
+ timestamp: new Date().toISOString(),
83
+ });
84
+ } catch (error) {
85
+ logger.error(
86
+ `Failed to route integration event ${event.eventId}:`,
87
+ error instanceof Error ? error.message : String(error)
88
+ );
89
+ // Don't re-throw - we don't want to fail the entire hook chain
90
+ // The event will be logged but not retried at the hook level
91
+ }
92
+ },
93
+ {
94
+ // CRITICAL: Use work-queue mode to ensure only ONE instance processes each event
95
+ // This prevents duplicate webhook deliveries when horizontally scaled
96
+ mode: "work-queue",
97
+ workerGroup,
98
+ }
99
+ );
100
+
101
+ logger.debug(
102
+ `Subscribed to event: ${event.eventId} (workerGroup: ${workerGroup})`
103
+ );
104
+ }