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