@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,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
|
+
}
|