@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.
- package/CHANGELOG.md +85 -0
- package/drizzle/0000_glossy_red_hulk.sql +28 -0
- package/drizzle/0001_rich_fixer.sql +2 -0
- package/drizzle/meta/0000_snapshot.json +191 -0
- package/drizzle/meta/0001_snapshot.json +190 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +8 -0
- package/package.json +36 -0
- package/src/connection-store.test.ts +468 -0
- package/src/connection-store.ts +463 -0
- package/src/delivery-coordinator.ts +390 -0
- package/src/event-registry.test.ts +396 -0
- package/src/event-registry.ts +99 -0
- package/src/hook-subscriber.ts +104 -0
- package/src/index.ts +306 -0
- package/src/provider-registry.test.ts +314 -0
- package/src/provider-registry.ts +107 -0
- package/src/provider-types.ts +257 -0
- package/src/router.ts +858 -0
- package/src/schema.ts +78 -0
- package/tsconfig.json +6 -0
|
@@ -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
|
+
}
|