@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,390 @@
1
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import type { Logger } from "@checkstack/backend-api";
3
+ import type { QueueManager } from "@checkstack/queue-api";
4
+ import type { SignalService } from "@checkstack/signal-common";
5
+ import { eq, sql } from "drizzle-orm";
6
+
7
+ import type { IntegrationProviderRegistry } from "./provider-registry";
8
+ import type { ConnectionStore } from "./connection-store";
9
+ import * as schema from "./schema";
10
+ import { INTEGRATION_DELIVERY_COMPLETED } from "@checkstack/integration-common";
11
+
12
+ /**
13
+ * Event payload for delivery routing
14
+ */
15
+ export interface IntegrationEventPayload {
16
+ eventId: string;
17
+ payload: Record<string, unknown>;
18
+ timestamp: string;
19
+ }
20
+
21
+ /**
22
+ * Job data for the delivery queue
23
+ */
24
+ interface DeliveryJobData {
25
+ logId: string;
26
+ subscriptionId: string;
27
+ subscriptionName: string;
28
+ providerId: string;
29
+ providerConfig: Record<string, unknown>;
30
+ eventId: string;
31
+ payload: Record<string, unknown>;
32
+ timestamp: string;
33
+ }
34
+
35
+ /**
36
+ * Delivery coordinator - routes events to subscriptions and manages delivery via queue
37
+ */
38
+ export interface DeliveryCoordinator {
39
+ /**
40
+ * Route an event to all matching subscriptions.
41
+ * This is called by the hook subscriber when a registered event is emitted.
42
+ */
43
+ routeEvent(event: IntegrationEventPayload): Promise<void>;
44
+
45
+ /**
46
+ * Start the delivery worker that processes queued deliveries.
47
+ * Must be called during afterPluginsReady.
48
+ */
49
+ startWorker(): Promise<void>;
50
+
51
+ /**
52
+ * Retry a specific failed delivery
53
+ */
54
+ retryDelivery(logId: string): Promise<{ success: boolean; message?: string }>;
55
+ }
56
+
57
+ interface DeliveryCoordinatorDeps {
58
+ db: NodePgDatabase<typeof schema>;
59
+ providerRegistry: IntegrationProviderRegistry;
60
+ connectionStore: ConnectionStore;
61
+ queueManager: QueueManager;
62
+ signalService: SignalService;
63
+ logger: Logger;
64
+ }
65
+
66
+ /**
67
+ * Create a delivery coordinator instance
68
+ */
69
+ export function createDeliveryCoordinator(
70
+ deps: DeliveryCoordinatorDeps
71
+ ): DeliveryCoordinator {
72
+ const {
73
+ db,
74
+ providerRegistry,
75
+ connectionStore,
76
+ queueManager,
77
+ signalService,
78
+ logger,
79
+ } = deps;
80
+
81
+ const QUEUE_NAME = "integration-delivery";
82
+ const MAX_RETRIES = 3;
83
+ const RETRY_DELAYS = [60_000, 300_000, 900_000]; // 1min, 5min, 15min
84
+
85
+ /**
86
+ * Find all subscriptions that match the given event
87
+ */
88
+ async function findMatchingSubscriptions(
89
+ eventId: string,
90
+ payload: Record<string, unknown>
91
+ ) {
92
+ // Get all enabled subscriptions
93
+ const subscriptions = await db
94
+ .select()
95
+ .from(schema.webhookSubscriptions)
96
+ .where(eq(schema.webhookSubscriptions.enabled, true));
97
+
98
+ // Filter to those that match this event
99
+ return subscriptions.filter((sub) => {
100
+ // Check event ID matches
101
+ if (sub.eventId !== eventId) {
102
+ return false;
103
+ }
104
+
105
+ // Check system filter if present
106
+ if (sub.systemFilter && sub.systemFilter.length > 0) {
107
+ const systemId = payload["systemId"] as string | undefined;
108
+ if (!systemId || !sub.systemFilter.includes(systemId)) {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ // Check if provider supports this event
114
+ const provider = providerRegistry.getProvider(sub.providerId);
115
+ if (!provider) {
116
+ logger.warn(
117
+ `Provider not found for subscription ${sub.id}: ${sub.providerId}`
118
+ );
119
+ return false;
120
+ }
121
+
122
+ if (
123
+ provider.supportedEvents &&
124
+ !provider.supportedEvents.includes(eventId)
125
+ ) {
126
+ return false;
127
+ }
128
+
129
+ return true;
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Execute delivery for a single job
135
+ */
136
+ async function executeDelivery(job: DeliveryJobData): Promise<void> {
137
+ const provider = providerRegistry.getProvider(job.providerId);
138
+ if (!provider) {
139
+ throw new Error(`Provider not found: ${job.providerId}`);
140
+ }
141
+
142
+ // Update log to show attempt in progress
143
+ await db
144
+ .update(schema.deliveryLogs)
145
+ .set({
146
+ status: "retrying",
147
+ attempts: sql`${schema.deliveryLogs.attempts} + 1`,
148
+ lastAttemptAt: new Date(),
149
+ })
150
+ .where(eq(schema.deliveryLogs.id, job.logId));
151
+
152
+ try {
153
+ // Call the provider's deliver method
154
+ const result = await provider.deliver({
155
+ event: {
156
+ eventId: job.eventId,
157
+ payload: job.payload,
158
+ timestamp: job.timestamp,
159
+ deliveryId: job.logId,
160
+ },
161
+ subscription: {
162
+ id: job.subscriptionId,
163
+ name: job.subscriptionName,
164
+ },
165
+ providerConfig: job.providerConfig,
166
+ logger: logger,
167
+ getConnectionWithCredentials:
168
+ connectionStore.getConnectionWithCredentials.bind(connectionStore),
169
+ });
170
+
171
+ if (result.success) {
172
+ // Mark as successful
173
+ await db
174
+ .update(schema.deliveryLogs)
175
+ .set({
176
+ status: "success",
177
+ externalId: result.externalId,
178
+ errorMessage: undefined,
179
+ nextRetryAt: undefined,
180
+ })
181
+ .where(eq(schema.deliveryLogs.id, job.logId));
182
+
183
+ // Emit success signal
184
+ await signalService.broadcast(INTEGRATION_DELIVERY_COMPLETED, {
185
+ logId: job.logId,
186
+ subscriptionId: job.subscriptionId,
187
+ eventType: job.eventId,
188
+ status: "success",
189
+ externalId: result.externalId,
190
+ });
191
+
192
+ logger.debug(
193
+ `Delivery successful: ${job.logId} -> ${
194
+ result.externalId ?? "no external ID"
195
+ }`
196
+ );
197
+ } else {
198
+ throw new Error(
199
+ result.error ?? "Delivery failed without error message"
200
+ );
201
+ }
202
+ } catch (error) {
203
+ const errorMessage =
204
+ error instanceof Error ? error.message : String(error);
205
+
206
+ // Get current attempt count
207
+ const [log] = await db
208
+ .select({ attempts: schema.deliveryLogs.attempts })
209
+ .from(schema.deliveryLogs)
210
+ .where(eq(schema.deliveryLogs.id, job.logId));
211
+
212
+ const attempts = log?.attempts ?? 1;
213
+
214
+ if (attempts < MAX_RETRIES) {
215
+ // Schedule retry
216
+ const retryDelay =
217
+ RETRY_DELAYS.at(attempts - 1) ??
218
+ RETRY_DELAYS.at(-1) ??
219
+ RETRY_DELAYS[0];
220
+ const nextRetryAt = new Date(Date.now() + retryDelay);
221
+
222
+ await db
223
+ .update(schema.deliveryLogs)
224
+ .set({
225
+ status: "retrying",
226
+ errorMessage,
227
+ nextRetryAt,
228
+ })
229
+ .where(eq(schema.deliveryLogs.id, job.logId));
230
+
231
+ // Re-queue with delay
232
+ const queue = await queueManager.getQueue<DeliveryJobData>(QUEUE_NAME);
233
+ await queue.enqueue(job, { startDelay: retryDelay / 1000 }); // Convert ms to seconds
234
+
235
+ logger.warn(
236
+ `Delivery failed (attempt ${attempts}/${MAX_RETRIES}), retrying at ${nextRetryAt.toISOString()}: ${errorMessage}`
237
+ );
238
+ } else {
239
+ // Max retries exceeded, mark as failed
240
+ await db
241
+ .update(schema.deliveryLogs)
242
+ .set({
243
+ status: "failed",
244
+ errorMessage,
245
+ nextRetryAt: undefined,
246
+ })
247
+ .where(eq(schema.deliveryLogs.id, job.logId));
248
+
249
+ // Emit failure signal
250
+ await signalService.broadcast(INTEGRATION_DELIVERY_COMPLETED, {
251
+ logId: job.logId,
252
+ subscriptionId: job.subscriptionId,
253
+ eventType: job.eventId,
254
+ status: "failed",
255
+ errorMessage,
256
+ });
257
+
258
+ logger.error(
259
+ `Delivery failed permanently after ${MAX_RETRIES} attempts: ${errorMessage}`
260
+ );
261
+ }
262
+ }
263
+ }
264
+
265
+ return {
266
+ async routeEvent(event: IntegrationEventPayload): Promise<void> {
267
+ const { eventId, payload, timestamp } = event;
268
+
269
+ logger.debug(`Routing integration event: ${eventId}`);
270
+
271
+ // Find matching subscriptions
272
+ const subscriptions = await findMatchingSubscriptions(eventId, payload);
273
+
274
+ if (subscriptions.length === 0) {
275
+ logger.debug(`No matching subscriptions for event: ${eventId}`);
276
+ return;
277
+ }
278
+
279
+ logger.debug(
280
+ `Found ${subscriptions.length} matching subscriptions for event: ${eventId}`
281
+ );
282
+
283
+ // Create delivery log entries and queue jobs
284
+ const queue = await queueManager.getQueue<DeliveryJobData>(QUEUE_NAME);
285
+
286
+ for (const subscription of subscriptions) {
287
+ const logId = crypto.randomUUID();
288
+
289
+ // Create delivery log entry
290
+ await db.insert(schema.deliveryLogs).values({
291
+ id: logId,
292
+ subscriptionId: subscription.id,
293
+ eventType: eventId,
294
+ eventPayload: payload,
295
+ status: "pending",
296
+ attempts: 0,
297
+ });
298
+
299
+ // Queue delivery job
300
+ const jobData: DeliveryJobData = {
301
+ logId,
302
+ subscriptionId: subscription.id,
303
+ subscriptionName: subscription.name,
304
+ providerId: subscription.providerId,
305
+ providerConfig: subscription.providerConfig,
306
+ eventId,
307
+ payload,
308
+ timestamp,
309
+ };
310
+
311
+ await queue.enqueue(jobData);
312
+
313
+ logger.debug(
314
+ `Queued delivery: ${logId} for subscription: ${subscription.name}`
315
+ );
316
+ }
317
+ },
318
+
319
+ async startWorker(): Promise<void> {
320
+ const queue = await queueManager.getQueue<DeliveryJobData>(QUEUE_NAME);
321
+
322
+ await queue.consume(
323
+ async (job) => {
324
+ await executeDelivery(job.data);
325
+ },
326
+ { consumerGroup: "integration-delivery" }
327
+ );
328
+
329
+ logger.info(
330
+ `Integration delivery worker started on queue: ${QUEUE_NAME}`
331
+ );
332
+ },
333
+
334
+ async retryDelivery(
335
+ logId: string
336
+ ): Promise<{ success: boolean; message?: string }> {
337
+ // Get the delivery log
338
+ const [log] = await db
339
+ .select()
340
+ .from(schema.deliveryLogs)
341
+ .where(eq(schema.deliveryLogs.id, logId));
342
+
343
+ if (!log) {
344
+ return { success: false, message: "Delivery log not found" };
345
+ }
346
+
347
+ if (log.status !== "failed") {
348
+ return { success: false, message: "Can only retry failed deliveries" };
349
+ }
350
+
351
+ // Get the subscription
352
+ const [subscription] = await db
353
+ .select()
354
+ .from(schema.webhookSubscriptions)
355
+ .where(eq(schema.webhookSubscriptions.id, log.subscriptionId));
356
+
357
+ if (!subscription) {
358
+ return { success: false, message: "Subscription not found" };
359
+ }
360
+
361
+ // Reset the log and re-queue
362
+ await db
363
+ .update(schema.deliveryLogs)
364
+ .set({
365
+ status: "pending",
366
+ attempts: 0,
367
+ errorMessage: undefined,
368
+ nextRetryAt: undefined,
369
+ })
370
+ .where(eq(schema.deliveryLogs.id, logId));
371
+
372
+ // Queue delivery job
373
+ const jobData: DeliveryJobData = {
374
+ logId,
375
+ subscriptionId: subscription.id,
376
+ subscriptionName: subscription.name,
377
+ providerId: subscription.providerId,
378
+ providerConfig: subscription.providerConfig,
379
+ eventId: log.eventType,
380
+ payload: log.eventPayload,
381
+ timestamp: new Date().toISOString(),
382
+ };
383
+
384
+ const queue = await queueManager.getQueue<DeliveryJobData>(QUEUE_NAME);
385
+ await queue.enqueue(jobData);
386
+
387
+ return { success: true, message: "Delivery re-queued" };
388
+ },
389
+ };
390
+ }