@objectstack/plugin-webhooks 7.5.0 → 7.6.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.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +20 -32
  2. package/CHANGELOG.md +49 -0
  3. package/dist/chunk-HWFTXTTI.js +138 -0
  4. package/dist/chunk-HWFTXTTI.js.map +1 -0
  5. package/dist/chunk-KPKLAXNA.cjs +138 -0
  6. package/dist/chunk-KPKLAXNA.cjs.map +1 -0
  7. package/dist/index.cjs +62 -616
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +41 -325
  10. package/dist/index.d.ts +41 -325
  11. package/dist/index.js +52 -606
  12. package/dist/index.js.map +1 -1
  13. package/dist/schema.cjs +2 -6
  14. package/dist/schema.cjs.map +1 -1
  15. package/dist/schema.d.cts +5 -4764
  16. package/dist/schema.d.ts +5 -4764
  17. package/dist/schema.js +3 -7
  18. package/package.json +4 -11
  19. package/src/auto-enqueuer.test.ts +83 -116
  20. package/src/auto-enqueuer.ts +38 -27
  21. package/src/index.ts +13 -40
  22. package/src/schema.ts +11 -16
  23. package/src/webhook-outbox-plugin.ts +80 -296
  24. package/tsup.config.ts +1 -1
  25. package/dist/chunk-7HS5DLU2.js +0 -319
  26. package/dist/chunk-7HS5DLU2.js.map +0 -1
  27. package/dist/chunk-HF7CCDPB.cjs +0 -256
  28. package/dist/chunk-HF7CCDPB.cjs.map +0 -1
  29. package/dist/chunk-KNGLLSSP.js +0 -256
  30. package/dist/chunk-KNGLLSSP.js.map +0 -1
  31. package/dist/chunk-TDSI7UHY.cjs +0 -319
  32. package/dist/chunk-TDSI7UHY.cjs.map +0 -1
  33. package/dist/outbox-CIn7LSyB.d.cts +0 -155
  34. package/dist/outbox-CIn7LSyB.d.ts +0 -155
  35. package/dist/sql-outbox.cjs +0 -8
  36. package/dist/sql-outbox.cjs.map +0 -1
  37. package/dist/sql-outbox.d.cts +0 -55
  38. package/dist/sql-outbox.d.ts +0 -55
  39. package/dist/sql-outbox.js +0 -8
  40. package/dist/sql-outbox.js.map +0 -1
  41. package/src/dispatcher.test.ts +0 -324
  42. package/src/dispatcher.ts +0 -218
  43. package/src/http-sender.ts +0 -187
  44. package/src/memory-outbox.test.ts +0 -86
  45. package/src/memory-outbox.ts +0 -155
  46. package/src/outbox.ts +0 -175
  47. package/src/partition.ts +0 -19
  48. package/src/retention.test.ts +0 -116
  49. package/src/retention.ts +0 -144
  50. package/src/sql-outbox.test.ts +0 -490
  51. package/src/sql-outbox.ts +0 -343
  52. package/src/sys-webhook-delivery.object.ts +0 -224
package/src/dispatcher.ts DELETED
@@ -1,218 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { IClusterService, LockHandle } from '@objectstack/spec/contracts';
4
- import type { FetchImpl } from './http-sender.js';
5
- import { classifyAttempt, sendOnce } from './http-sender.js';
6
- import type { IWebhookOutbox, WebhookDelivery } from './outbox.js';
7
-
8
- /**
9
- * Minimal logger surface — kernel's `Logger` is compatible (extra params
10
- * accepted). Keeping it permissive avoids a hard dependency on the spec
11
- * Logger interface here.
12
- */
13
- export interface DispatcherLogger {
14
- warn: (msg: string, meta?: any) => void;
15
- info?: (msg: string, meta?: any) => void;
16
- }
17
-
18
- export interface DispatcherOptions {
19
- /** Stable id identifying this dispatcher node. */
20
- nodeId: string;
21
- /** Cluster service providing `lock` (and optional metrics). */
22
- cluster: IClusterService;
23
- /** Outbox backend. */
24
- outbox: IWebhookOutbox;
25
- /**
26
- * How many partitions to split work across. Each tick the dispatcher
27
- * attempts to acquire each partition's lock independently — the node
28
- * that wins owns that partition for the duration of the batch.
29
- *
30
- * Default: 8 (matches webhook-delivery.mdx §4 example).
31
- */
32
- partitionCount?: number;
33
- /** Max rows to claim from each partition per tick. Default 32. */
34
- batchSize?: number;
35
- /** Tick interval in ms. Default 250. */
36
- intervalMs?: number;
37
- /** Per-partition lock TTL. Default = 5 × intervalMs. */
38
- lockTtlMs?: number;
39
- /** Visibility timeout for claimed rows. Default = 2 × lockTtlMs. */
40
- claimTtlMs?: number;
41
- /** Override `globalThis.fetch` (tests). */
42
- fetchImpl?: FetchImpl;
43
- /** Hook fired after every attempt — observability hook. */
44
- onAttempt?: (delivery: WebhookDelivery, success: boolean) => void;
45
- /** RNG override for the retry-jitter schedule (tests). */
46
- rng?: () => number;
47
- /** Logger callback (optional). */
48
- logger?: DispatcherLogger;
49
- }
50
-
51
- /**
52
- * Cross-node webhook dispatcher.
53
- *
54
- * **Design** — each tick the dispatcher iterates over `partitionCount`
55
- * logical partitions. For each, it tries to acquire a cluster-scoped lock
56
- * (`webhook.dispatcher.partition.{i}`) with a short TTL. If it wins the
57
- * lock, it claims up to `batchSize` ready rows whose `hash(webhookId) mod
58
- * partitionCount === i`, POSTs them, and acks. The lock is released
59
- * immediately after the batch so other nodes can fairly rotate through.
60
- *
61
- * **Why per-partition locks rather than one global lock?**
62
- *
63
- * 1. Throughput — N nodes can process N partitions concurrently.
64
- * 2. Partition affinity — rows for the same webhook always sort into the
65
- * same partition, preserving in-order delivery per webhook.
66
- * 3. Failure isolation — a stuck node only blocks its partition until the
67
- * TTL elapses; other partitions keep moving.
68
- *
69
- * **At-least-once, not exactly-once.** Receivers MUST be idempotent on the
70
- * `X-Objectstack-Delivery` (== row id) header. If the HTTP call succeeds
71
- * but the ack write fails, the row reverts to pending after the claim TTL
72
- * and will be re-posted.
73
- */
74
- export class WebhookDispatcher {
75
- private readonly opts: Required<
76
- Omit<DispatcherOptions, 'onAttempt' | 'fetchImpl' | 'rng' | 'logger'>
77
- > & Pick<DispatcherOptions, 'onAttempt' | 'fetchImpl' | 'rng' | 'logger'>;
78
- private timer: ReturnType<typeof setInterval> | undefined;
79
- private running = false;
80
- private inflightTick: Promise<void> | undefined;
81
-
82
- constructor(options: DispatcherOptions) {
83
- const intervalMs = options.intervalMs ?? 250;
84
- const lockTtlMs = options.lockTtlMs ?? intervalMs * 5;
85
- this.opts = {
86
- nodeId: options.nodeId,
87
- cluster: options.cluster,
88
- outbox: options.outbox,
89
- partitionCount: options.partitionCount ?? 8,
90
- batchSize: options.batchSize ?? 32,
91
- intervalMs,
92
- lockTtlMs,
93
- claimTtlMs: options.claimTtlMs ?? lockTtlMs * 2,
94
- onAttempt: options.onAttempt,
95
- fetchImpl: options.fetchImpl,
96
- rng: options.rng,
97
- logger: options.logger,
98
- };
99
- }
100
-
101
- /** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */
102
- start(): void {
103
- if (this.running) return;
104
- this.running = true;
105
- // Fire one tick immediately so single-row tests don't wait the interval.
106
- this.scheduleTick();
107
- this.timer = setInterval(() => this.scheduleTick(), this.opts.intervalMs);
108
- }
109
-
110
- /** Stop the loop and wait for the in-flight tick to drain. */
111
- async stop(): Promise<void> {
112
- if (!this.running) return;
113
- this.running = false;
114
- if (this.timer) {
115
- clearInterval(this.timer);
116
- this.timer = undefined;
117
- }
118
- if (this.inflightTick) {
119
- try {
120
- await this.inflightTick;
121
- } catch {
122
- /* swallow — already logged */
123
- }
124
- }
125
- }
126
-
127
- /**
128
- * Run one full tick (all partitions, single attempt each). Exposed for
129
- * deterministic tests that want to step the dispatcher manually.
130
- */
131
- async tick(): Promise<void> {
132
- await this.runTick();
133
- }
134
-
135
- private scheduleTick(): void {
136
- if (this.inflightTick) return; // skip if previous tick still running
137
- this.inflightTick = this.runTick()
138
- .catch((err) => {
139
- this.opts.logger?.warn?.('webhook-dispatcher: tick failed', {
140
- nodeId: this.opts.nodeId,
141
- error: (err as Error)?.message ?? String(err),
142
- });
143
- })
144
- .finally(() => {
145
- this.inflightTick = undefined;
146
- });
147
- }
148
-
149
- private async runTick(): Promise<void> {
150
- const partitionCount = this.opts.partitionCount;
151
- // Walk partitions in a rotated order per node so contention spreads.
152
- const offset = stableNodeOffset(this.opts.nodeId, partitionCount);
153
- for (let step = 0; step < partitionCount; step++) {
154
- const i = (offset + step) % partitionCount;
155
- await this.runPartition(i);
156
- }
157
- }
158
-
159
- private async runPartition(index: number): Promise<void> {
160
- const key = `webhook.dispatcher.partition.${index}`;
161
- const handle: LockHandle | null = await this.opts.cluster.lock.acquire(key, {
162
- ttlMs: this.opts.lockTtlMs,
163
- // waitMs=0 → fail-fast; we'll try this partition again next tick.
164
- waitMs: 0,
165
- });
166
- if (!handle) return;
167
-
168
- try {
169
- const claimed = await this.opts.outbox.claim({
170
- nodeId: this.opts.nodeId,
171
- limit: this.opts.batchSize,
172
- partition: { index, count: this.opts.partitionCount },
173
- claimTtlMs: this.opts.claimTtlMs,
174
- });
175
- if (claimed.length === 0) return;
176
- // Renew before potentially long HTTP work — and bound batch time.
177
- await handle.renew(this.opts.lockTtlMs);
178
- for (const row of claimed) {
179
- if (!handle.isHeld()) break; // lost the lock — abandon remaining rows
180
- await this.processRow(row);
181
- }
182
- } finally {
183
- await handle.release();
184
- }
185
- }
186
-
187
- private async processRow(row: WebhookDelivery): Promise<void> {
188
- const fetchImpl = (this.opts.fetchImpl ?? (globalThis.fetch as unknown as FetchImpl)) as FetchImpl | undefined;
189
- if (!fetchImpl) {
190
- this.opts.logger?.warn?.('webhook-dispatcher: no fetch impl available', {
191
- rowId: row.id,
192
- });
193
- await this.opts.outbox.ack(row.id, {
194
- success: false,
195
- error: 'no fetch implementation',
196
- durationMs: 0,
197
- dead: true,
198
- });
199
- return;
200
- }
201
- const outcome = await sendOnce(row, fetchImpl);
202
- const result = classifyAttempt(outcome, row.attempts, Date.now(), this.opts.rng);
203
- await this.opts.outbox.ack(row.id, result);
204
- this.opts.onAttempt?.(row, result.success);
205
- }
206
- }
207
-
208
- /**
209
- * Spread starting partition per node so a 2-node cluster with 8 partitions
210
- * doesn't have both nodes serialise on partition 0 every tick.
211
- */
212
- function stableNodeOffset(nodeId: string, partitionCount: number): number {
213
- let h = 0;
214
- for (let i = 0; i < nodeId.length; i++) {
215
- h = (h * 31 + nodeId.charCodeAt(i)) | 0;
216
- }
217
- return Math.abs(h) % partitionCount;
218
- }
@@ -1,187 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { createHmac, randomUUID } from 'node:crypto';
4
- import type { WebhookDelivery, AckResult } from './outbox.js';
5
-
6
- /**
7
- * Default per-request timeout. Receivers SHOULD respond within ~30s; we
8
- * cap aggressively to free dispatcher slots.
9
- */
10
- export const DEFAULT_TIMEOUT_MS = 15_000;
11
-
12
- /** Truncate response bodies to keep storage cost predictable. */
13
- const RESPONSE_BODY_CAP = 16 * 1024;
14
-
15
- export type FetchImpl = (
16
- input: string,
17
- init: {
18
- method: string;
19
- headers: Record<string, string>;
20
- body: string;
21
- signal: AbortSignal;
22
- },
23
- ) => Promise<{
24
- ok: boolean;
25
- status: number;
26
- text(): Promise<string>;
27
- }>;
28
-
29
- /** Single HTTP attempt classified to an `AckResult` shape (without nextRetryAt). */
30
- export type AttemptOutcome =
31
- | { success: true; httpStatus: number; responseBody?: string; durationMs: number }
32
- | {
33
- success: false;
34
- retriable: boolean;
35
- httpStatus?: number;
36
- responseBody?: string;
37
- error?: string;
38
- durationMs: number;
39
- };
40
-
41
- /**
42
- * Send one HTTP attempt for the delivery. Pure (no DB writes) so the
43
- * dispatcher owns retry-schedule + ack logic.
44
- *
45
- * - 2xx → success
46
- * - 4xx (except 408/429) → permanent failure (retriable = false → goes to `dead`)
47
- * - 408, 429, 5xx, transport → retriable
48
- */
49
- export async function sendOnce(
50
- delivery: WebhookDelivery,
51
- fetchImpl: FetchImpl,
52
- ): Promise<AttemptOutcome> {
53
- const body =
54
- typeof delivery.payload === 'string'
55
- ? delivery.payload
56
- : JSON.stringify(delivery.payload);
57
-
58
- const headers: Record<string, string> = {
59
- 'Content-Type': 'application/json',
60
- 'User-Agent': 'ObjectStack-Webhooks/1.0',
61
- 'X-Objectstack-Event': delivery.eventType,
62
- 'X-Objectstack-Delivery': delivery.id,
63
- 'X-Objectstack-Attempt': String(delivery.attempts + 1),
64
- ...(delivery.headers ?? {}),
65
- };
66
- if (delivery.secret) {
67
- const sig = createHmac('sha256', delivery.secret).update(body).digest('hex');
68
- headers['X-Objectstack-Signature'] = `sha256=${sig}`;
69
- }
70
-
71
- const timeoutMs = delivery.timeoutMs ?? DEFAULT_TIMEOUT_MS;
72
- const controller = new AbortController();
73
- const timer = setTimeout(() => controller.abort(), timeoutMs);
74
- const start = Date.now();
75
- try {
76
- const res = await fetchImpl(delivery.url, {
77
- method: delivery.method ?? 'POST',
78
- headers,
79
- body,
80
- signal: controller.signal,
81
- });
82
- clearTimeout(timer);
83
- const responseText = await safeReadBody(res);
84
- const durationMs = Date.now() - start;
85
- if (res.ok) {
86
- return { success: true, httpStatus: res.status, responseBody: responseText, durationMs };
87
- }
88
- const retriable = res.status === 408 || res.status === 429 || res.status >= 500;
89
- return {
90
- success: false,
91
- retriable,
92
- httpStatus: res.status,
93
- responseBody: responseText,
94
- error: `HTTP ${res.status}`,
95
- durationMs,
96
- };
97
- } catch (err: unknown) {
98
- clearTimeout(timer);
99
- const durationMs = Date.now() - start;
100
- const e = err as { name?: string; message?: string };
101
- const error = e?.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);
102
- return { success: false, retriable: true, error, durationMs };
103
- }
104
- }
105
-
106
- async function safeReadBody(res: { text(): Promise<string> }): Promise<string | undefined> {
107
- try {
108
- const text = await res.text();
109
- return text.length > RESPONSE_BODY_CAP ? text.slice(0, RESPONSE_BODY_CAP) : text;
110
- } catch {
111
- return undefined;
112
- }
113
- }
114
-
115
- /**
116
- * Stripe-style retry schedule. Returns the next `nextRetryAt` ms (relative
117
- * to `now`) given how many attempts have already happened, or `null` if
118
- * the row should be moved to `dead`.
119
- *
120
- * attempt 1 fails -> retry in ~1s
121
- * attempt 2 fails -> ~10s
122
- * attempt 3 fails -> ~1m
123
- * attempt 4 fails -> ~10m
124
- * attempt 5 fails -> ~1h
125
- * attempt 6 fails -> ~6h
126
- * attempt 7 fails -> ~24h
127
- * attempt 8+ fails -> dead
128
- *
129
- * Each delay is multiplied by jitter ∈ [0.8, 1.2].
130
- */
131
- export function nextRetryDelayMs(
132
- attemptsSoFar: number,
133
- rng: () => number = Math.random,
134
- ): number | null {
135
- const SCHEDULE = [1_000, 10_000, 60_000, 600_000, 3_600_000, 21_600_000, 86_400_000];
136
- if (attemptsSoFar < 1 || attemptsSoFar > SCHEDULE.length) return null;
137
- const base = SCHEDULE[attemptsSoFar - 1];
138
- const jitter = 0.8 + rng() * 0.4;
139
- return Math.floor(base * jitter);
140
- }
141
-
142
- /**
143
- * Compose an `AckResult` from an `AttemptOutcome`, applying the retry
144
- * schedule on retriable failures.
145
- */
146
- export function classifyAttempt(
147
- outcome: AttemptOutcome,
148
- attemptsSoFar: number,
149
- now: number = Date.now(),
150
- rng?: () => number,
151
- ): AckResult {
152
- if (outcome.success) return outcome;
153
- if (!outcome.retriable) {
154
- return {
155
- success: false,
156
- httpStatus: outcome.httpStatus,
157
- responseBody: outcome.responseBody,
158
- error: outcome.error,
159
- durationMs: outcome.durationMs,
160
- dead: true,
161
- };
162
- }
163
- const delay = nextRetryDelayMs(attemptsSoFar + 1, rng);
164
- if (delay === null) {
165
- return {
166
- success: false,
167
- httpStatus: outcome.httpStatus,
168
- responseBody: outcome.responseBody,
169
- error: outcome.error,
170
- durationMs: outcome.durationMs,
171
- dead: true,
172
- };
173
- }
174
- return {
175
- success: false,
176
- httpStatus: outcome.httpStatus,
177
- responseBody: outcome.responseBody,
178
- error: outcome.error,
179
- durationMs: outcome.durationMs,
180
- nextRetryAt: now + delay,
181
- };
182
- }
183
-
184
- /** Generate a fresh delivery id (UUID v4). Exposed for tests. */
185
- export function newDeliveryId(): string {
186
- return randomUUID();
187
- }
@@ -1,86 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * MemoryWebhookOutbox — focused tests for behaviours not already covered
5
- * via `dispatcher.test.ts`. Today that's just `redeliver()` — the rest of
6
- * the contract is exercised end-to-end through the dispatcher path.
7
- */
8
-
9
- import { describe, expect, it } from 'vitest';
10
- import { MemoryWebhookOutbox } from './memory-outbox.js';
11
- import type { EnqueueInput } from './outbox.js';
12
-
13
- function input(webhookId: string, eventId: string): EnqueueInput {
14
- return {
15
- webhookId,
16
- eventId,
17
- eventType: 'data.record.created',
18
- url: 'https://example.test/hook',
19
- payload: { hello: 'world' },
20
- };
21
- }
22
-
23
- describe('MemoryWebhookOutbox.redeliver', () => {
24
- it('resets a success row back to pending with attempts=0', async () => {
25
- const outbox = new MemoryWebhookOutbox();
26
- const id = await outbox.enqueue(input('wh-1', 'ev-1'));
27
- await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
28
- await outbox.ack(id, { success: true, httpStatus: 200, durationMs: 1 });
29
-
30
- const row = await outbox.redeliver(id);
31
- expect(row.status).toBe('pending');
32
- expect(row.attempts).toBe(0);
33
- expect(row.claimedBy).toBeUndefined();
34
- expect(row.claimedAt).toBeUndefined();
35
- expect(row.nextRetryAt).toBeUndefined();
36
- expect(row.error).toBeUndefined();
37
- expect(row.responseCode).toBeUndefined();
38
- expect(row.responseBody).toBeUndefined();
39
- expect(row.url).toBe('https://example.test/hook');
40
- expect(row.payload).toEqual({ hello: 'world' });
41
- });
42
-
43
- it('resets a dead row and makes it claimable again', async () => {
44
- const outbox = new MemoryWebhookOutbox();
45
- const id = await outbox.enqueue(input('wh-1', 'ev-1'));
46
- await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
47
- await outbox.ack(id, {
48
- success: false,
49
- error: 'final',
50
- dead: true,
51
- durationMs: 5,
52
- });
53
-
54
- await outbox.redeliver(id);
55
- const claimed = await outbox.claim({
56
- nodeId: 'B',
57
- limit: 10,
58
- claimTtlMs: 60_000,
59
- });
60
- expect(claimed.map((r) => r.id)).toContain(id);
61
- });
62
-
63
- it('throws not_found when row does not exist', async () => {
64
- const outbox = new MemoryWebhookOutbox();
65
- await expect(outbox.redeliver('missing')).rejects.toMatchObject({
66
- code: 'not_found',
67
- });
68
- });
69
-
70
- it('throws not_eligible for pending rows', async () => {
71
- const outbox = new MemoryWebhookOutbox();
72
- const id = await outbox.enqueue(input('wh-1', 'ev-1'));
73
- await expect(outbox.redeliver(id)).rejects.toMatchObject({
74
- code: 'not_eligible',
75
- });
76
- });
77
-
78
- it('throws not_eligible for in_flight rows', async () => {
79
- const outbox = new MemoryWebhookOutbox();
80
- const id = await outbox.enqueue(input('wh-1', 'ev-1'));
81
- await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
82
- await expect(outbox.redeliver(id)).rejects.toMatchObject({
83
- code: 'not_eligible',
84
- });
85
- });
86
- });
@@ -1,155 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { randomUUID } from 'node:crypto';
4
- import type {
5
- AckResult,
6
- ClaimOptions,
7
- EnqueueInput,
8
- DeliveryStatus,
9
- IWebhookOutbox,
10
- WebhookDelivery,
11
- } from './outbox.js';
12
- import { RedeliverError } from './outbox.js';
13
- import { hashPartition } from './partition.js';
14
-
15
- /**
16
- * In-memory `IWebhookOutbox` for tests and single-process development.
17
- *
18
- * Implements the atomic-claim semantics by running its claim/ack logic
19
- * synchronously (single-threaded JS event loop) inside one `Map`. Two
20
- * `MemoryWebhookOutbox` instances do NOT share state — for the cross-node
21
- * test the *same* instance is passed to both dispatchers (simulating one
22
- * shared database).
23
- *
24
- * A production SQL-backed implementation will live in a sibling file and
25
- * use `SELECT ... FOR UPDATE SKIP LOCKED`.
26
- */
27
- export class MemoryWebhookOutbox implements IWebhookOutbox {
28
- private readonly rows = new Map<string, WebhookDelivery>();
29
- /** Dedup index keyed by `${eventId}::${webhookId}` -> row id. */
30
- private readonly dedup = new Map<string, string>();
31
-
32
- async enqueue(input: EnqueueInput): Promise<string> {
33
- const dedupKey = `${input.eventId}::${input.webhookId}`;
34
- const existing = this.dedup.get(dedupKey);
35
- if (existing) return existing;
36
-
37
- const id = randomUUID();
38
- const now = Date.now();
39
- const row: WebhookDelivery = {
40
- id,
41
- webhookId: input.webhookId,
42
- eventId: input.eventId,
43
- eventType: input.eventType,
44
- url: input.url,
45
- method: input.method ?? 'POST',
46
- headers: input.headers,
47
- secret: input.secret,
48
- timeoutMs: input.timeoutMs,
49
- payload: input.payload,
50
- status: 'pending',
51
- attempts: 0,
52
- createdAt: now,
53
- updatedAt: now,
54
- };
55
- this.rows.set(id, row);
56
- this.dedup.set(dedupKey, id);
57
- return id;
58
- }
59
-
60
- async claim(opts: ClaimOptions): Promise<WebhookDelivery[]> {
61
- const now = opts.now ?? Date.now();
62
- const claimed: WebhookDelivery[] = [];
63
-
64
- // First pass: reap expired in_flight rows (visibility timeout).
65
- for (const row of this.rows.values()) {
66
- if (
67
- row.status === 'in_flight' &&
68
- row.claimedAt !== undefined &&
69
- now - row.claimedAt > opts.claimTtlMs
70
- ) {
71
- row.status = 'pending';
72
- row.claimedBy = undefined;
73
- row.claimedAt = undefined;
74
- row.updatedAt = now;
75
- }
76
- }
77
-
78
- for (const row of this.rows.values()) {
79
- if (claimed.length >= opts.limit) break;
80
- if (row.status !== 'pending') continue;
81
- if (row.nextRetryAt !== undefined && row.nextRetryAt > now) continue;
82
- if (opts.partition) {
83
- const p = hashPartition(row.webhookId, opts.partition.count);
84
- if (p !== opts.partition.index) continue;
85
- }
86
- row.status = 'in_flight';
87
- row.claimedBy = opts.nodeId;
88
- row.claimedAt = now;
89
- row.updatedAt = now;
90
- claimed.push({ ...row });
91
- }
92
- return claimed;
93
- }
94
-
95
- async ack(id: string, result: AckResult): Promise<void> {
96
- const row = this.rows.get(id);
97
- if (!row) return;
98
- const now = Date.now();
99
- row.attempts += 1;
100
- row.lastAttemptedAt = now;
101
- row.updatedAt = now;
102
- row.claimedBy = undefined;
103
- row.claimedAt = undefined;
104
- row.responseCode = result.httpStatus;
105
- row.responseBody = result.responseBody;
106
-
107
- let status: DeliveryStatus;
108
- if (result.success) {
109
- status = 'success';
110
- row.nextRetryAt = undefined;
111
- row.error = undefined;
112
- } else if (result.dead) {
113
- status = 'dead';
114
- row.error = result.error;
115
- row.nextRetryAt = undefined;
116
- } else {
117
- status = 'pending';
118
- row.error = result.error;
119
- row.nextRetryAt = result.nextRetryAt;
120
- }
121
- row.status = status;
122
- }
123
-
124
- async list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]> {
125
- const all = Array.from(this.rows.values()).map((r) => ({ ...r }));
126
- return filter?.status ? all.filter((r) => r.status === filter.status) : all;
127
- }
128
-
129
- async redeliver(id: string): Promise<WebhookDelivery> {
130
- const row = this.rows.get(id);
131
- if (!row) {
132
- throw new RedeliverError(
133
- `Delivery row '${id}' not found`,
134
- 'not_found',
135
- );
136
- }
137
- if (row.status !== 'success' && row.status !== 'failed' && row.status !== 'dead') {
138
- throw new RedeliverError(
139
- `Delivery row '${id}' is '${row.status}', expected one of: success, failed, dead`,
140
- 'not_eligible',
141
- );
142
- }
143
- const now = Date.now();
144
- row.status = 'pending';
145
- row.attempts = 0;
146
- row.claimedBy = undefined;
147
- row.claimedAt = undefined;
148
- row.nextRetryAt = undefined;
149
- row.error = undefined;
150
- row.responseCode = undefined;
151
- row.responseBody = undefined;
152
- row.updatedAt = now;
153
- return { ...row };
154
- }
155
- }