@hogsend/engine 0.7.0 → 0.8.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/package.json +7 -6
- package/src/app.ts +36 -1
- package/src/env.ts +25 -0
- package/src/index.ts +34 -1
- package/src/journeys/define-journey.ts +26 -2
- package/src/lib/bucket-emit.ts +45 -0
- package/src/lib/contacts.ts +28 -6
- package/src/lib/mailer.ts +87 -0
- package/src/lib/outbound.ts +216 -0
- package/src/lib/preferences.ts +31 -0
- package/src/lib/tracked.ts +45 -3
- package/src/lib/tracking-events.ts +66 -1
- package/src/lib/webhook-signing.ts +151 -0
- package/src/routes/admin/contacts.ts +43 -3
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/webhooks.ts +466 -0
- package/src/routes/contacts/index.ts +48 -5
- package/src/routes/lists/index.ts +41 -5
- package/src/routes/tracking/click.ts +59 -18
- package/src/routes/tracking/open.ts +62 -24
- package/src/routes/webhooks/sources.ts +69 -10
- package/src/webhook-sources/define-webhook-source.ts +57 -5
- package/src/webhook-sources/presets/clerk.ts +185 -0
- package/src/webhook-sources/presets/index.ts +80 -0
- package/src/webhook-sources/presets/segment.ts +120 -0
- package/src/webhook-sources/presets/stripe.ts +147 -0
- package/src/webhook-sources/presets/supabase.ts +131 -0
- package/src/webhook-sources/verify.ts +172 -0
- package/src/worker.ts +6 -0
- package/src/workflows/deliver-webhook.ts +399 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deadLetterQueue,
|
|
3
|
+
webhookDeliveries,
|
|
4
|
+
webhookEndpoints,
|
|
5
|
+
} from "@hogsend/db";
|
|
6
|
+
import { and, eq, lt, or, sql } from "drizzle-orm";
|
|
7
|
+
import { getDb } from "../lib/db.js";
|
|
8
|
+
import { hatchet } from "../lib/hatchet.js";
|
|
9
|
+
import { createLogger } from "../lib/logger.js";
|
|
10
|
+
import { signWebhook } from "../lib/webhook-signing.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Outbound webhook delivery — the durable per-(event × endpoint) POST attempt
|
|
14
|
+
* plus the reaper cron that schedules retries and recovers orphaned `sending`
|
|
15
|
+
* rows.
|
|
16
|
+
*
|
|
17
|
+
* Delivery model (Section 1.5, LOCKED decision 5/6): one `webhook_deliveries`
|
|
18
|
+
* row + one `runNoWait` per endpoint (independent retry/backoff/dead-letter),
|
|
19
|
+
* with a 1-minute reaper cron as the retry scheduler AND the orphan-`sending`
|
|
20
|
+
* recovery — mirroring `reapStuckCampaignsTask`. Hatchet's own retry is OFF
|
|
21
|
+
* (`retries: 0`); `nextRetryAt` is the single retry clock.
|
|
22
|
+
*
|
|
23
|
+
* The task signs from the FROZEN `payload` envelope on the row + the LIVE
|
|
24
|
+
* endpoint secret read at delivery time, so a rotate-secret invalidates
|
|
25
|
+
* in-flight deliveries to a compromised secret (acceptable under at-least-once).
|
|
26
|
+
* The `body` that `signWebhook` produces is the EXACT bytes that are POSTed —
|
|
27
|
+
* the payload is never re-serialized between sign and send (Open Risk 8).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Statuses that are TERMINAL — a duplicate/late enqueue must not re-deliver. */
|
|
31
|
+
const TERMINAL_STATUSES = ["delivered", "failed", "discarded"] as const;
|
|
32
|
+
|
|
33
|
+
/** Max delivery attempts before the row is dead-lettered (env-tunable). */
|
|
34
|
+
const MAX_ATTEMPTS = Number(process.env.OUTBOUND_WEBHOOK_MAX_ATTEMPTS ?? 8);
|
|
35
|
+
/** Per-attempt POST timeout (AbortController), ms. */
|
|
36
|
+
const TIMEOUT_MS = Number(process.env.OUTBOUND_WEBHOOK_TIMEOUT_MS ?? 15000);
|
|
37
|
+
/** Exponential backoff base, ms. delay = BASE * 2^attempt + jitter(0..BASE). */
|
|
38
|
+
const BASE_DELAY_MS = Number(
|
|
39
|
+
process.env.OUTBOUND_WEBHOOK_BASE_DELAY_MS ?? 5000,
|
|
40
|
+
);
|
|
41
|
+
/** Backoff ceiling, ms (default 6h). */
|
|
42
|
+
const MAX_DELAY_MS = Number(
|
|
43
|
+
process.env.OUTBOUND_WEBHOOK_MAX_DELAY_MS ?? 6 * 60 * 60 * 1000,
|
|
44
|
+
);
|
|
45
|
+
/** A `sending` row older than this (no live run) is re-driven by the reaper. */
|
|
46
|
+
const STUCK_AFTER_MS = Number(
|
|
47
|
+
process.env.OUTBOUND_WEBHOOK_STUCK_AFTER_MS ?? 5 * 60 * 1000,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** Response-body snippet cap persisted for forensics (≤1KB). */
|
|
51
|
+
const SNIPPET_MAX = 1024;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Exponential backoff with full jitter, capped at `MAX_DELAY_MS`:
|
|
55
|
+
* min(BASE * 2^attempt + jitter(0..BASE), MAX_DELAY).
|
|
56
|
+
* `attempt` is the (already-incremented) attempt count, so the FIRST retry after
|
|
57
|
+
* one failed attempt waits ~`BASE * 2` (a real backoff, not a near-zero retry).
|
|
58
|
+
*/
|
|
59
|
+
function backoffMs(attempt: number): number {
|
|
60
|
+
const exp = BASE_DELAY_MS * 2 ** attempt;
|
|
61
|
+
const jitter = Math.floor(Math.random() * BASE_DELAY_MS);
|
|
62
|
+
return Math.min(exp + jitter, MAX_DELAY_MS);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Retry classification (mirrors `plugin-resend` `isRetryableStatusCode`, with
|
|
67
|
+
* the `408`/`429` carve-outs from Section 1.5 step 7). Network/timeout failures
|
|
68
|
+
* (no HTTP status) are retryable and handled by the caller (status === null).
|
|
69
|
+
*
|
|
70
|
+
* A persistent 4xx (e.g. `410 Gone`, `400 Bad Request`) is NOT retryable — a
|
|
71
|
+
* misconfigured/decommissioned endpoint should fast-fail, not burn 8 attempts.
|
|
72
|
+
* `408 Request Timeout` and `429 Too Many Requests` are the retryable 4xx
|
|
73
|
+
* exceptions; everything `>= 500` is retryable.
|
|
74
|
+
*/
|
|
75
|
+
function isRetryableStatus(status: number): boolean {
|
|
76
|
+
if (status === 408 || status === 429) return true;
|
|
77
|
+
if (status >= 500) return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One durable delivery attempt for a single `webhook_deliveries` row.
|
|
83
|
+
*
|
|
84
|
+
* `retries: 0` — the reaper (driven off `nextRetryAt`) is the retry scheduler,
|
|
85
|
+
* NOT Hatchet's own backoff (which would double up on the reaper's). The CAS to
|
|
86
|
+
* `sending` (step 3) prevents an overlapping reaper re-drive from double-POSTing
|
|
87
|
+
* the same row.
|
|
88
|
+
*/
|
|
89
|
+
export const deliverWebhookTask = hatchet.task({
|
|
90
|
+
name: "deliver-webhook",
|
|
91
|
+
retries: 0,
|
|
92
|
+
executionTimeout: "30s",
|
|
93
|
+
fn: async (input: { deliveryId: string }) => {
|
|
94
|
+
const db = getDb();
|
|
95
|
+
const logger = createLogger(process.env.LOG_LEVEL ?? "info");
|
|
96
|
+
|
|
97
|
+
// (1) Load the delivery row. Absent → nothing to do (a hard delete cascaded
|
|
98
|
+
// it away between enqueue and run).
|
|
99
|
+
const [row] = await db
|
|
100
|
+
.select()
|
|
101
|
+
.from(webhookDeliveries)
|
|
102
|
+
.where(eq(webhookDeliveries.id, input.deliveryId))
|
|
103
|
+
.limit(1);
|
|
104
|
+
if (!row) {
|
|
105
|
+
return { status: "skipped", reason: "not_found" as const };
|
|
106
|
+
}
|
|
107
|
+
// Already terminal — a duplicate/late enqueue (or a reaper re-drive that
|
|
108
|
+
// raced a just-finished run) must not re-deliver.
|
|
109
|
+
if ((TERMINAL_STATUSES as readonly string[]).includes(row.status)) {
|
|
110
|
+
return { status: row.status, skipped: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// (2) Load the endpoint. Absent (cascade-deleted) OR disabled → `discarded`:
|
|
114
|
+
// an operator action, NOT a delivery error, so it is NOT dead-lettered.
|
|
115
|
+
const [endpoint] = await db
|
|
116
|
+
.select()
|
|
117
|
+
.from(webhookEndpoints)
|
|
118
|
+
.where(eq(webhookEndpoints.id, row.endpointId))
|
|
119
|
+
.limit(1);
|
|
120
|
+
if (!endpoint || endpoint.disabled) {
|
|
121
|
+
await db
|
|
122
|
+
.update(webhookDeliveries)
|
|
123
|
+
.set({
|
|
124
|
+
status: "discarded",
|
|
125
|
+
nextRetryAt: null,
|
|
126
|
+
updatedAt: new Date(),
|
|
127
|
+
})
|
|
128
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
129
|
+
return {
|
|
130
|
+
status: "discarded" as const,
|
|
131
|
+
reason: endpoint
|
|
132
|
+
? ("endpoint_disabled" as const)
|
|
133
|
+
: ("endpoint_deleted" as const),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// (3) CAS the row to `sending` so an overlapping reaper re-drive cannot
|
|
138
|
+
// double-POST. The status guard (still non-terminal) makes a concurrent
|
|
139
|
+
// claim affect zero rows; the loser of the race bails out here.
|
|
140
|
+
const claimed = await db
|
|
141
|
+
.update(webhookDeliveries)
|
|
142
|
+
.set({
|
|
143
|
+
status: "sending",
|
|
144
|
+
lastAttemptAt: new Date(),
|
|
145
|
+
updatedAt: new Date(),
|
|
146
|
+
})
|
|
147
|
+
.where(
|
|
148
|
+
and(
|
|
149
|
+
eq(webhookDeliveries.id, row.id),
|
|
150
|
+
eq(webhookDeliveries.status, row.status),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
.returning({ id: webhookDeliveries.id });
|
|
154
|
+
if (claimed.length === 0) {
|
|
155
|
+
return { status: "skipped", reason: "lost_cas" as const };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// (4) Sign from the FROZEN row payload + the LIVE endpoint secret. `body` is
|
|
159
|
+
// the EXACT bytes signed AND sent — never re-serialize between sign and send
|
|
160
|
+
// (Open Risk 8).
|
|
161
|
+
const { headers, body } = signWebhook({
|
|
162
|
+
id: row.webhookId,
|
|
163
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
164
|
+
payload: row.payload,
|
|
165
|
+
secret: endpoint.secret,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// (5) POST with an AbortController timeout. A network error / timeout leaves
|
|
169
|
+
// `responseStatus` null (a retryable failure, handled below).
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
172
|
+
let responseStatus: number | null = null;
|
|
173
|
+
let responseBodySnippet: string | null = null;
|
|
174
|
+
let lastError: string | null = null;
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(endpoint.url, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers,
|
|
179
|
+
body,
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
});
|
|
182
|
+
responseStatus = res.status;
|
|
183
|
+
const text = await res.text().catch(() => "");
|
|
184
|
+
responseBodySnippet = text ? text.slice(0, SNIPPET_MAX) : null;
|
|
185
|
+
if (responseStatus < 200 || responseStatus >= 300) {
|
|
186
|
+
lastError = `HTTP ${responseStatus}`;
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
lastError =
|
|
190
|
+
err instanceof Error
|
|
191
|
+
? controller.signal.aborted
|
|
192
|
+
? `Timeout after ${TIMEOUT_MS}ms`
|
|
193
|
+
: err.message
|
|
194
|
+
: String(err);
|
|
195
|
+
} finally {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const now = new Date();
|
|
200
|
+
|
|
201
|
+
// (6) 2xx → delivered (TERMINAL). Also bump the endpoint's lastDeliveryAt.
|
|
202
|
+
if (
|
|
203
|
+
responseStatus !== null &&
|
|
204
|
+
responseStatus >= 200 &&
|
|
205
|
+
responseStatus < 300
|
|
206
|
+
) {
|
|
207
|
+
await db
|
|
208
|
+
.update(webhookDeliveries)
|
|
209
|
+
.set({
|
|
210
|
+
status: "delivered",
|
|
211
|
+
attemptCount: row.attemptCount + 1,
|
|
212
|
+
responseStatus,
|
|
213
|
+
responseBodySnippet,
|
|
214
|
+
deliveredAt: now,
|
|
215
|
+
nextRetryAt: null,
|
|
216
|
+
lastError: null,
|
|
217
|
+
lastAttemptAt: now,
|
|
218
|
+
updatedAt: now,
|
|
219
|
+
})
|
|
220
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
221
|
+
await db
|
|
222
|
+
.update(webhookEndpoints)
|
|
223
|
+
.set({ lastDeliveryAt: now, updatedAt: now })
|
|
224
|
+
.where(eq(webhookEndpoints.id, endpoint.id));
|
|
225
|
+
logger.info("deliver-webhook: delivered", {
|
|
226
|
+
deliveryId: row.id,
|
|
227
|
+
endpointId: endpoint.id,
|
|
228
|
+
eventType: row.eventType,
|
|
229
|
+
responseStatus,
|
|
230
|
+
});
|
|
231
|
+
return { status: "delivered" as const, responseStatus };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const attemptCount = row.attemptCount + 1;
|
|
235
|
+
|
|
236
|
+
// (7) Persistent-4xx fast-fail: a non-retryable client error (anything 4xx
|
|
237
|
+
// except 408/429) is permanent after attempt >= 2 — a `410 Gone` must not
|
|
238
|
+
// burn 8 attempts. The `>= 2` guard tolerates a single transient 4xx blip
|
|
239
|
+
// before declaring the endpoint mis-configured.
|
|
240
|
+
const httpFastFail =
|
|
241
|
+
responseStatus !== null &&
|
|
242
|
+
!isRetryableStatus(responseStatus) &&
|
|
243
|
+
attemptCount >= 2;
|
|
244
|
+
|
|
245
|
+
// (8) Retryable failure with attempts remaining → back to `pending` with the
|
|
246
|
+
// next backoff deadline; the reaper re-drives it once `nextRetryAt` passes.
|
|
247
|
+
if (!httpFastFail && attemptCount < MAX_ATTEMPTS) {
|
|
248
|
+
const nextRetryAt = new Date(now.getTime() + backoffMs(attemptCount));
|
|
249
|
+
await db
|
|
250
|
+
.update(webhookDeliveries)
|
|
251
|
+
.set({
|
|
252
|
+
status: "pending",
|
|
253
|
+
attemptCount,
|
|
254
|
+
responseStatus,
|
|
255
|
+
responseBodySnippet,
|
|
256
|
+
nextRetryAt,
|
|
257
|
+
lastError,
|
|
258
|
+
lastAttemptAt: now,
|
|
259
|
+
updatedAt: now,
|
|
260
|
+
})
|
|
261
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
262
|
+
logger.warn("deliver-webhook: retry scheduled", {
|
|
263
|
+
deliveryId: row.id,
|
|
264
|
+
endpointId: endpoint.id,
|
|
265
|
+
attemptCount,
|
|
266
|
+
responseStatus,
|
|
267
|
+
nextRetryAt: nextRetryAt.toISOString(),
|
|
268
|
+
error: lastError,
|
|
269
|
+
});
|
|
270
|
+
return {
|
|
271
|
+
status: "pending" as const,
|
|
272
|
+
attemptCount,
|
|
273
|
+
nextRetryAt: nextRetryAt.toISOString(),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// (9) Exhausted (attempts >= MAX) OR a persistent-4xx fast-fail → `failed`
|
|
278
|
+
// (TERMINAL) + a forensic `dead_letter_queue` mirror, in one transaction so
|
|
279
|
+
// the terminal status and the DLQ row commit together. This is the DLQ's
|
|
280
|
+
// first real producer (LOCKED decision 8).
|
|
281
|
+
const exhaustError = `Exhausted ${attemptCount}: ${lastError ?? "unknown"}`;
|
|
282
|
+
await db.transaction(async (tx) => {
|
|
283
|
+
await tx
|
|
284
|
+
.update(webhookDeliveries)
|
|
285
|
+
.set({
|
|
286
|
+
status: "failed",
|
|
287
|
+
attemptCount,
|
|
288
|
+
responseStatus,
|
|
289
|
+
responseBodySnippet,
|
|
290
|
+
nextRetryAt: null,
|
|
291
|
+
lastError,
|
|
292
|
+
lastAttemptAt: now,
|
|
293
|
+
updatedAt: now,
|
|
294
|
+
})
|
|
295
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
296
|
+
await tx.insert(deadLetterQueue).values({
|
|
297
|
+
source: "webhook-delivery",
|
|
298
|
+
sourceId: row.id,
|
|
299
|
+
payload: {
|
|
300
|
+
endpointId: endpoint.id,
|
|
301
|
+
url: endpoint.url,
|
|
302
|
+
eventType: row.eventType,
|
|
303
|
+
webhookId: row.webhookId,
|
|
304
|
+
body: row.payload,
|
|
305
|
+
},
|
|
306
|
+
error: exhaustError,
|
|
307
|
+
retryCount: attemptCount,
|
|
308
|
+
status: "pending",
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
logger.error("deliver-webhook: failed (dead-lettered)", {
|
|
312
|
+
deliveryId: row.id,
|
|
313
|
+
endpointId: endpoint.id,
|
|
314
|
+
eventType: row.eventType,
|
|
315
|
+
attemptCount,
|
|
316
|
+
responseStatus,
|
|
317
|
+
fastFail: httpFastFail,
|
|
318
|
+
error: lastError,
|
|
319
|
+
});
|
|
320
|
+
return { status: "failed" as const, attemptCount, fastFail: httpFastFail };
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
/** Max rows a single reaper sweep re-drives (bounds the per-tick fan-out). */
|
|
325
|
+
const REAPER_BATCH = 500;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Engine-owned reaper cron for outbound webhook deliveries (Section 1.5, cloned
|
|
329
|
+
* from `reapStuckCampaignsTask`). It is BOTH the retry scheduler AND the
|
|
330
|
+
* orphan-`sending` recovery:
|
|
331
|
+
*
|
|
332
|
+
* - A `pending` row whose `nextRetryAt` has passed (or is null — a freshly
|
|
333
|
+
* enqueued row whose `runNoWait` failed at emit time) is re-driven.
|
|
334
|
+
* - A `sending` row whose worker died mid-POST (OOM/SIGKILL/timeout, so the JS
|
|
335
|
+
* never reached a terminal write) is re-driven once it is older than
|
|
336
|
+
* `STUCK_AFTER_MS` (measured from `updatedAt`, which step 3's CAS bumped).
|
|
337
|
+
*
|
|
338
|
+
* Recovery is `deliverWebhookTask.run({ deliveryId })`; the delivery task's own
|
|
339
|
+
* `sending` CAS guard makes an overlap with a still-live run safe (the loser
|
|
340
|
+
* no-ops). Self-bootstraps `db`/`logger` from `process.env` (cron runs have no
|
|
341
|
+
* request container).
|
|
342
|
+
*/
|
|
343
|
+
export const reapDueWebhookDeliveriesTask = hatchet.task({
|
|
344
|
+
name: "reap-due-webhook-deliveries",
|
|
345
|
+
onCrons: [process.env.OUTBOUND_WEBHOOK_REAPER_CRON ?? "*/1 * * * *"],
|
|
346
|
+
retries: 1,
|
|
347
|
+
executionTimeout: "120s",
|
|
348
|
+
fn: async () => {
|
|
349
|
+
const db = getDb();
|
|
350
|
+
const logger = createLogger(process.env.LOG_LEVEL ?? "info");
|
|
351
|
+
|
|
352
|
+
const now = new Date();
|
|
353
|
+
const stuckBefore = new Date(now.getTime() - STUCK_AFTER_MS);
|
|
354
|
+
|
|
355
|
+
// Due-pending (retry clock elapsed or never set) OR stale-sending (orphan).
|
|
356
|
+
const due = await db
|
|
357
|
+
.select({ id: webhookDeliveries.id })
|
|
358
|
+
.from(webhookDeliveries)
|
|
359
|
+
.where(
|
|
360
|
+
or(
|
|
361
|
+
and(
|
|
362
|
+
eq(webhookDeliveries.status, "pending"),
|
|
363
|
+
or(
|
|
364
|
+
sql`${webhookDeliveries.nextRetryAt} is null`,
|
|
365
|
+
lt(webhookDeliveries.nextRetryAt, now),
|
|
366
|
+
),
|
|
367
|
+
),
|
|
368
|
+
and(
|
|
369
|
+
eq(webhookDeliveries.status, "sending"),
|
|
370
|
+
lt(webhookDeliveries.updatedAt, stuckBefore),
|
|
371
|
+
),
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
.orderBy(webhookDeliveries.nextRetryAt)
|
|
375
|
+
.limit(REAPER_BATCH);
|
|
376
|
+
|
|
377
|
+
let reDriven = 0;
|
|
378
|
+
for (const row of due) {
|
|
379
|
+
try {
|
|
380
|
+
await deliverWebhookTask.run({ deliveryId: row.id });
|
|
381
|
+
reDriven += 1;
|
|
382
|
+
} catch (err) {
|
|
383
|
+
logger.warn("reap-due-webhook-deliveries: re-drive failed", {
|
|
384
|
+
deliveryId: row.id,
|
|
385
|
+
error: err instanceof Error ? err.message : String(err),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (due.length > 0) {
|
|
391
|
+
logger.info("reap-due-webhook-deliveries: swept", {
|
|
392
|
+
candidates: due.length,
|
|
393
|
+
reDriven,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { candidates: due.length, reDriven };
|
|
398
|
+
},
|
|
399
|
+
});
|