webhook-delivery-engine 0.0.0-stage → 1.0.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/dist/src/app/bootstrap.d.ts +14 -0
  4. package/dist/src/app/bootstrap.js +13 -0
  5. package/dist/src/app/embedded.d.ts +45 -0
  6. package/dist/src/app/embedded.js +228 -0
  7. package/dist/src/app/http.server.d.ts +10 -0
  8. package/dist/src/app/http.server.js +65 -0
  9. package/dist/src/app/lifecycle.d.ts +108 -0
  10. package/dist/src/app/lifecycle.js +138 -0
  11. package/dist/src/config/engine.config.d.ts +45 -0
  12. package/dist/src/config/engine.config.js +1 -0
  13. package/dist/src/core/errors.d.ts +4 -0
  14. package/dist/src/core/errors.js +7 -0
  15. package/dist/src/core/ports.d.ts +217 -0
  16. package/dist/src/core/ports.js +1 -0
  17. package/dist/src/core/types.d.ts +98 -0
  18. package/dist/src/core/types.js +10 -0
  19. package/dist/src/features/delivery/delivery.backoff.d.ts +74 -0
  20. package/dist/src/features/delivery/delivery.backoff.js +56 -0
  21. package/dist/src/features/delivery/delivery.classify.d.ts +28 -0
  22. package/dist/src/features/delivery/delivery.classify.js +38 -0
  23. package/dist/src/features/delivery/delivery.dispatcher.d.ts +57 -0
  24. package/dist/src/features/delivery/delivery.dispatcher.js +155 -0
  25. package/dist/src/features/delivery/delivery.hmac.d.ts +62 -0
  26. package/dist/src/features/delivery/delivery.hmac.js +31 -0
  27. package/dist/src/features/delivery/delivery.worker.d.ts +60 -0
  28. package/dist/src/features/delivery/delivery.worker.js +136 -0
  29. package/dist/src/features/http/http.responses.d.ts +6 -0
  30. package/dist/src/features/http/http.responses.js +45 -0
  31. package/dist/src/features/ingestion/ingestion.handler.d.ts +81 -0
  32. package/dist/src/features/ingestion/ingestion.handler.js +59 -0
  33. package/dist/src/features/ingestion/ingestion.routes.d.ts +12 -0
  34. package/dist/src/features/ingestion/ingestion.routes.js +25 -0
  35. package/dist/src/features/ingestion/ingestion.schema.d.ts +26 -0
  36. package/dist/src/features/ingestion/ingestion.schema.js +24 -0
  37. package/dist/src/features/query/query.routes.d.ts +11 -0
  38. package/dist/src/features/query/query.routes.js +99 -0
  39. package/dist/src/features/scheduler/scheduler.d.ts +110 -0
  40. package/dist/src/features/scheduler/scheduler.js +153 -0
  41. package/dist/src/index.d.ts +8 -0
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/security/auth.d.ts +40 -0
  44. package/dist/src/security/auth.js +39 -0
  45. package/dist/src/security/ssrf.d.ts +47 -0
  46. package/dist/src/security/ssrf.js +173 -0
  47. package/dist/src/shared/logger.d.ts +11 -0
  48. package/dist/src/shared/logger.js +52 -0
  49. package/dist/src/storage/migrations/runner.d.ts +51 -0
  50. package/dist/src/storage/migrations/runner.js +155 -0
  51. package/dist/src/storage/migrations/sql/sqlite/001_initial_schema.sql +52 -0
  52. package/dist/src/storage/sqlite.adapter.d.ts +51 -0
  53. package/dist/src/storage/sqlite.adapter.js +478 -0
  54. package/package.json +76 -4
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Coordinates a clean process exit on SIGINT or SIGTERM.
3
+ *
4
+ * Responsibilities (WBS 4.2):
5
+ * 1. Register one-time SIGINT and SIGTERM handlers via `register()`.
6
+ * 2. On signal (or direct `shutdown()` call): stop the scheduler so no new
7
+ * events are claimed.
8
+ * 3. Call `DatabaseAdapter.releaseEventLease` for every event that was claimed
9
+ * but whose dispatch has not yet started, returning those leases to the
10
+ * `pending` state so they are picked up on the next startup.
11
+ * 4. Remain idempotent — duplicate signals or `shutdown()` calls run the
12
+ * shutdown sequence exactly once.
13
+ *
14
+ * The lifecycle module does NOT implement the poll loop itself (WBS 4.1), does
15
+ * NOT dispatch events, and does NOT know about HMAC signing or HTTP transport.
16
+ *
17
+ * ### Lease tracking contract
18
+ *
19
+ * When the scheduler claims an event and hands it to the worker seam, callers
20
+ * should immediately call `trackClaimed(event)`. When network dispatch starts,
21
+ * call `trackDispatch(event.id, work)` to move the event from releasable claims
22
+ * to work that shutdown must drain. `untrackClaimed` remains available for
23
+ * callers that only need claim release tracking.
24
+ */
25
+ export class GracefulShutdown {
26
+ scheduler;
27
+ adapter;
28
+ /**
29
+ * Events that have been claimed by the scheduler but have not yet started
30
+ * network dispatch. Keyed by event id for O(1) lookup and removal.
31
+ */
32
+ pendingClaims = new Map();
33
+ /** Dispatches that crossed the network-start boundary and must be drained. */
34
+ activeDispatches = new Set();
35
+ /** Whether the shutdown sequence has been started (guards idempotency). */
36
+ shuttingDown = false;
37
+ /** Shared completion promise so concurrent stop calls await the same drain. */
38
+ shutdownPromise = null;
39
+ /** Whether `register()` has been called (guards duplicate listener registration). */
40
+ registered = false;
41
+ /**
42
+ * Creates a lifecycle coordinator for the given scheduler and adapter.
43
+ *
44
+ * @param scheduler - The running poll loop to stop on shutdown.
45
+ * @param adapter - Adapter used to release unclaimed-dispatch leases.
46
+ */
47
+ constructor(scheduler, adapter) {
48
+ this.scheduler = scheduler;
49
+ this.adapter = adapter;
50
+ }
51
+ /**
52
+ * Registers one-time SIGINT and SIGTERM listeners on `process`.
53
+ *
54
+ * Safe to call multiple times — only one pair of listeners is ever added.
55
+ * Each signal triggers `shutdown()` which is itself idempotent.
56
+ */
57
+ register() {
58
+ if (this.registered)
59
+ return;
60
+ this.registered = true;
61
+ const handle = () => {
62
+ void this.shutdown();
63
+ };
64
+ process.on('SIGINT', handle);
65
+ process.on('SIGTERM', handle);
66
+ }
67
+ /**
68
+ * Marks an event as claimed-but-not-dispatched.
69
+ *
70
+ * Call this immediately after the scheduler hands an event to the worker
71
+ * seam so that a concurrent or subsequent `shutdown()` can release its
72
+ * lease.
73
+ *
74
+ * @param event - The claimed event to track.
75
+ */
76
+ trackClaimed(event) {
77
+ this.pendingClaims.set(event.id, event);
78
+ }
79
+ /**
80
+ * Removes an event from the pending-claim tracking set.
81
+ *
82
+ * Call this when the worker begins actual network dispatch for an event.
83
+ * After removal, `shutdown()` will not attempt to release that lease —
84
+ * the stale-lease recovery mechanism will handle it if the process aborts
85
+ * mid-dispatch.
86
+ *
87
+ * @param eventId - Id of the event whose dispatch has started.
88
+ */
89
+ untrackClaimed(eventId) {
90
+ this.pendingClaims.delete(eventId);
91
+ }
92
+ /**
93
+ * Marks dispatch as started and tracks its completion for shutdown.
94
+ * The lease is no longer eligible for release once network dispatch begins.
95
+ */
96
+ trackDispatch(eventId, dispatch) {
97
+ this.pendingClaims.delete(eventId);
98
+ const tracked = dispatch.then(() => undefined, () => undefined);
99
+ this.activeDispatches.add(tracked);
100
+ void tracked.then(() => this.activeDispatches.delete(tracked));
101
+ }
102
+ /**
103
+ * Executes the graceful shutdown sequence exactly once.
104
+ *
105
+ * 1. Stops the scheduler (no new claims after this point).
106
+ * 2. Releases the leases of all still-tracked (not-yet-dispatched) events
107
+ * by calling `releaseEventLease` for each. Failures for individual events
108
+ * are swallowed so that the remaining releases still run; those events
109
+ * will be recovered by the stale-lease mechanism on next startup.
110
+ *
111
+ * Safe to call multiple times — subsequent calls resolve immediately.
112
+ *
113
+ * @returns A promise that resolves when the shutdown sequence completes.
114
+ */
115
+ async shutdown() {
116
+ if (this.shuttingDown)
117
+ return this.shutdownPromise;
118
+ this.shuttingDown = true;
119
+ this.shutdownPromise = this.drainAndRelease();
120
+ return this.shutdownPromise;
121
+ }
122
+ async drainAndRelease() {
123
+ await this.scheduler.stop();
124
+ const releasedAt = new Date().toISOString();
125
+ const releasePromises = Array.from(this.pendingClaims.values()).map((event) => this.adapter
126
+ .releaseEventLease({
127
+ eventId: event.id,
128
+ workerId: event.lockedBy ?? '',
129
+ releasedAt,
130
+ })
131
+ .catch(() => {
132
+ // Failed releases remain recoverable through stale-lease recovery.
133
+ }));
134
+ await Promise.allSettled(releasePromises);
135
+ this.pendingClaims.clear();
136
+ await Promise.all(Array.from(this.activeDispatches));
137
+ }
138
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Worker and polling limits used by the engine.
3
+ *
4
+ * Every value must be a positive safe integer. `batchSize` must be greater
5
+ * than zero and should not exceed the adapter's practical claim limit.
6
+ */
7
+ export interface EngineConcurrency {
8
+ /** Maximum number of events processed concurrently. */
9
+ readonly workerCount: number;
10
+ /** Maximum number of events claimed in one poll cycle. */
11
+ readonly batchSize: number;
12
+ /** Milliseconds between scheduler polls when no work is immediately due. */
13
+ readonly pollIntervalMs: number;
14
+ }
15
+ /**
16
+ * Bounded exponential retry policy used after retryable delivery failures.
17
+ *
18
+ * Delay before jitter is `min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs)`;
19
+ * full jitter selects a delay from zero through that bound.
20
+ */
21
+ export interface RetryPolicy {
22
+ /** Maximum number of dispatch attempts before terminal exhaustion. */
23
+ readonly maxAttempts: number;
24
+ /** Initial exponential backoff bound in milliseconds. */
25
+ readonly baseDelayMs: number;
26
+ /** Maximum exponential backoff bound in milliseconds. */
27
+ readonly maxDelayMs: number;
28
+ }
29
+ /**
30
+ * Immutable runtime configuration required by the delivery engine.
31
+ *
32
+ * Configuration providers validate positive safe-integer limits and require
33
+ * `retry.maxDelayMs >= retry.baseDelayMs` before the engine starts. Documented
34
+ * SDD v1 defaults are batch size 10, a 10,000 ms request timeout, 5 attempts,
35
+ * and 1,000/60,000 ms retry bounds; worker count and poll interval remain
36
+ * provider-selected values.
37
+ */
38
+ export interface EngineConfig {
39
+ /** Bounded worker and polling concurrency controls. */
40
+ readonly concurrency: EngineConcurrency;
41
+ /** Maximum duration of a single outbound HTTP request in milliseconds. */
42
+ readonly timeoutLimitMs: number;
43
+ /** Retry ceiling and bounded exponential backoff settings. */
44
+ readonly retry: RetryPolicy;
45
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ /** A caller supplied an invalid opaque event-list cursor. */
2
+ export declare class InvalidPageCursorError extends TypeError {
3
+ constructor(options?: ErrorOptions);
4
+ }
@@ -0,0 +1,7 @@
1
+ /** A caller supplied an invalid opaque event-list cursor. */
2
+ export class InvalidPageCursorError extends TypeError {
3
+ constructor(options) {
4
+ super('cursor must be a valid event page cursor.', options);
5
+ this.name = 'InvalidPageCursorError';
6
+ }
7
+ }
@@ -0,0 +1,217 @@
1
+ import type { AttemptRecord, EventStatus, Timestamp, WebhookEvent } from './types.js';
2
+ /**
3
+ * The event returned from an idempotent upsert.
4
+ *
5
+ * When `inserted` is false, `event` is the existing row identified by the
6
+ * same `(clientId, idempotencyKey)` pair.
7
+ */
8
+ export interface EventUpsertResult {
9
+ /** Newly inserted event or the existing idempotent match. */
10
+ readonly event: WebhookEvent;
11
+ /** Whether this call inserted a new event row. */
12
+ readonly inserted: boolean;
13
+ }
14
+ /**
15
+ * Parameters for atomically claiming a batch of due events.
16
+ */
17
+ export interface ClaimDueEventsInput {
18
+ /** Unique identifier persisted on each claimed lease. */
19
+ readonly workerId: string;
20
+ /** UTC reference time used to determine which events are due. */
21
+ readonly now: Timestamp;
22
+ /** Maximum number of eligible events to claim. */
23
+ readonly limit: number;
24
+ }
25
+ /**
26
+ * Parameters for atomically returning expired processing leases to the retry
27
+ * queue.
28
+ *
29
+ * The adapter calculates the cutoff as
30
+ * `now - max(timeoutLimitMs + 30000, 90000)` and schedules recovered events
31
+ * for `now + 10000` milliseconds. A lease exactly at the cutoff is not stale.
32
+ */
33
+ export interface RecoverStaleLeasesInput {
34
+ /** UTC reference time used for the cutoff and retry schedule. */
35
+ readonly now: Timestamp;
36
+ /** Configured per-request timeout in milliseconds; must be a positive safe integer. */
37
+ readonly timeoutLimitMs: number;
38
+ }
39
+ /**
40
+ * Parameters for releasing an unstarted claim during graceful shutdown.
41
+ *
42
+ * The adapter only releases a lease owned by `workerId`; it restores the event
43
+ * to `pending`, clears its lease, and reverses the attempt reservation made
44
+ * when the event was claimed.
45
+ */
46
+ export interface ReleaseEventLeaseInput {
47
+ /** Identifier of the event whose lease is being released. */
48
+ readonly eventId: string;
49
+ /** Worker identifier that must still own the event lease. */
50
+ readonly workerId: string;
51
+ /** UTC time at which the lease is released. */
52
+ readonly releasedAt: Timestamp;
53
+ }
54
+ /**
55
+ * Parameters for atomically appending an attempt and changing its event state.
56
+ *
57
+ * The `nextRetryAt` invariant is enforced by the discriminated union: retry
58
+ * transitions require a timestamp, while terminal transitions require null.
59
+ */
60
+ export type RecordAttemptAndTransitionInput = {
61
+ /** Attempt row to append for the event. */
62
+ readonly attempt: AttemptRecord & {
63
+ readonly outcome: 'success';
64
+ };
65
+ /** Worker that must own the event lease. */
66
+ readonly workerId: string;
67
+ /** Successful attempts transition the event to delivered. */
68
+ readonly nextStatus: 'delivered';
69
+ /** Successful terminal delivery does not schedule a retry. */
70
+ readonly nextRetryAt: null;
71
+ /** UTC time at which the event state change is recorded. */
72
+ readonly updatedAt: Timestamp;
73
+ } | {
74
+ /** Attempt row to append for the event. */
75
+ readonly attempt: AttemptRecord & {
76
+ readonly outcome: 'retry_scheduled';
77
+ };
78
+ /** Worker that must own the event lease. */
79
+ readonly workerId: string;
80
+ /** A retryable attempt transitions the event to retry. */
81
+ readonly nextStatus: 'retry';
82
+ /** Time at or after which the retry may be claimed. */
83
+ readonly nextRetryAt: Timestamp;
84
+ /** UTC time at which the event state change is recorded. */
85
+ readonly updatedAt: Timestamp;
86
+ } | {
87
+ /** Attempt row to append for the event. */
88
+ readonly attempt: AttemptRecord & {
89
+ readonly outcome: 'exhausted';
90
+ };
91
+ /** Worker that must own the event lease. */
92
+ readonly workerId: string;
93
+ /** Terminal unsuccessful attempts transition the event to failed. */
94
+ readonly nextStatus: 'failed';
95
+ /** Terminal failure does not schedule a retry. */
96
+ readonly nextRetryAt: null;
97
+ /** UTC time at which the event state change is recorded. */
98
+ readonly updatedAt: Timestamp;
99
+ };
100
+ /**
101
+ * Tenant-scoped event page query with optional status filtering and cursor
102
+ * pagination.
103
+ */
104
+ export interface ListEventsInput {
105
+ /** Tenant that owns all returned rows. */
106
+ readonly clientId: string;
107
+ /** Optional lifecycle state to filter by. */
108
+ readonly status?: EventStatus;
109
+ /** Maximum number of events to return. */
110
+ readonly limit: number;
111
+ /** Opaque cursor from the preceding page, if one exists. */
112
+ readonly cursor?: string;
113
+ }
114
+ /**
115
+ * A page of events and the opaque cursor for the next page, when available.
116
+ */
117
+ export interface EventPage {
118
+ /** Tenant-scoped events in deterministic creation order. */
119
+ readonly items: readonly WebhookEvent[];
120
+ /** Cursor for the next page, or null when the result set is exhausted. */
121
+ readonly nextCursor: string | null;
122
+ }
123
+ /**
124
+ * Persistence boundary used by engine modules without depending on a database
125
+ * driver.
126
+ *
127
+ * Implementations must use parameterized statements and preserve the
128
+ * idempotency, tenant-isolation, lease-ownership, and append-only attempt
129
+ * invariants described by each operation. Rejected promises represent
130
+ * persistence failures and must not be converted to successful fallback
131
+ * values.
132
+ */
133
+ export interface DatabaseAdapter {
134
+ /**
135
+ * Inserts an event or returns the existing event for the same tenant/key
136
+ * pair without creating a duplicate.
137
+ *
138
+ * @param event Complete validated event to insert.
139
+ * @returns The inserted event or its idempotent existing match.
140
+ */
141
+ upsertEvent(event: WebhookEvent): Promise<EventUpsertResult>;
142
+ /**
143
+ * Atomically claims up to `limit` due pending/retry events for one worker.
144
+ *
145
+ * A successful claim sets `processing`, stamps the lease, and increments the
146
+ * dispatch attempt counter in the same atomic database operation.
147
+ *
148
+ * @param input Claim owner, reference time, and maximum batch size.
149
+ * @returns The claimed events with their updated lease and attempt state.
150
+ */
151
+ claimDueEvents(input: ClaimDueEventsInput): Promise<readonly WebhookEvent[]>;
152
+ /**
153
+ * Atomically returns processing events with expired leases to the retry
154
+ * queue and clears their lease fields.
155
+ *
156
+ * @param input UTC reference time and per-request timeout used to derive the
157
+ * stale cutoff and retry schedule.
158
+ * @returns The number of leases recovered.
159
+ */
160
+ recoverStaleLeases(input: RecoverStaleLeasesInput): Promise<number>;
161
+ /**
162
+ * Releases a worker-owned event lease that has not started network dispatch.
163
+ *
164
+ * The event returns to `pending` and its claim-reserved attempt count is
165
+ * reversed; a lease owned by a different worker must remain unchanged.
166
+ *
167
+ * @param input Event, expected lease owner, and release timestamp.
168
+ * @returns Whether the adapter released the matching lease.
169
+ */
170
+ releaseEventLease(input: ReleaseEventLeaseInput): Promise<boolean>;
171
+ /**
172
+ * Appends the attempt and applies the resulting event transition atomically.
173
+ *
174
+ * The adapter must verify lease ownership, ensure the attempt belongs to the
175
+ * event and is the next 1-based ordinal, match the attempt outcome to the
176
+ * target event state, append no duplicate audit row, and clear the lease as
177
+ * part of a successful transition. If the worker no longer owns the lease,
178
+ * neither the attempt nor transition is written.
179
+ *
180
+ * @param input Attempt details, expected lease owner, and resulting state.
181
+ * @returns Whether the attempt and state transition were committed.
182
+ */
183
+ recordAttemptAndTransition(input: RecordAttemptAndTransitionInput): Promise<boolean>;
184
+ /**
185
+ * Finds one event while enforcing its tenant boundary.
186
+ *
187
+ * @param eventId System-generated event identifier.
188
+ * @param clientId Configured tenant that must own the event.
189
+ * @returns The tenant-owned event, or null when not found.
190
+ */
191
+ findEventById(eventId: string, clientId: string): Promise<WebhookEvent | null>;
192
+ /**
193
+ * Finds an event by its tenant-scoped idempotency key.
194
+ *
195
+ * @param clientId Configured tenant that owns the idempotency namespace.
196
+ * @param idempotencyKey Client-supplied key to find.
197
+ * @returns The matching tenant-owned event, or null when not found.
198
+ */
199
+ findEventByIdempotencyKey(clientId: string, idempotencyKey: string): Promise<WebhookEvent | null>;
200
+ /**
201
+ * Lists events with tenant filtering, optional status filtering, and a
202
+ * bounded cursor page.
203
+ *
204
+ * @param input Tenant identity and page constraints.
205
+ * @returns A deterministic page containing only the requested tenant's rows.
206
+ */
207
+ listEvents(input: ListEventsInput): Promise<EventPage>;
208
+ /**
209
+ * Reads a tenant-owned event's append-only attempt history in attempt-number
210
+ * order. Implementations must enforce ownership in the database query.
211
+ *
212
+ * @param eventId Identifier of the event whose attempts are requested.
213
+ * @param clientId Configured tenant that must own the event.
214
+ * @returns The event's attempts ordered by their 1-based ordinal.
215
+ */
216
+ listAttempts(eventId: string, clientId: string): Promise<readonly AttemptRecord[]>;
217
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A JSON-compatible scalar value.
3
+ */
4
+ export type JsonPrimitive = string | number | boolean | null;
5
+ /**
6
+ * A JSON value whose arrays and object properties are readonly. Numbers must
7
+ * be finite values to remain valid JSON.
8
+ */
9
+ export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
10
+ readonly [key: string]: JsonValue;
11
+ };
12
+ /**
13
+ * An immutable UTC timestamp encoded as an ISO-8601 string.
14
+ *
15
+ * Values crossing a persistence boundary must be normalized to UTC and remain
16
+ * parseable by the JavaScript `Date` constructor.
17
+ */
18
+ export type Timestamp = string;
19
+ /**
20
+ * Lifecycle states persisted for a webhook event.
21
+ */
22
+ export declare const EVENT_STATUSES: readonly ["pending", "processing", "delivered", "retry", "failed"];
23
+ export type EventStatus = (typeof EVENT_STATUSES)[number];
24
+ /**
25
+ * Result recorded for a delivery attempt. `exhausted` means the event reached
26
+ * a terminal unsuccessful state, either through a permanent response or by
27
+ * reaching the configured retry limit.
28
+ */
29
+ export declare const ATTEMPT_OUTCOMES: readonly ["success", "retry_scheduled", "exhausted"];
30
+ export type AttemptOutcome = (typeof ATTEMPT_OUTCOMES)[number];
31
+ /**
32
+ * Durable webhook delivery work together with its immutable input and lease
33
+ * state.
34
+ *
35
+ * `attemptCount` is the number of dispatch attempts started, `nextRetryAt` is
36
+ * null when no retry is scheduled, and the lease fields are both null unless
37
+ * the event is being processed. `metadata`, `payload`, and `headers` are
38
+ * readonly data; engine-owned signing and event-identification headers must
39
+ * not be overridden by values in `headers`.
40
+ */
41
+ export interface WebhookEvent {
42
+ /** System-generated, non-enumerable event identifier. */
43
+ readonly id: string;
44
+ /** Client-supplied key, unique within `clientId`. */
45
+ readonly idempotencyKey: string;
46
+ /** Host-configured tenant identifier used for all tenant-scoped reads. */
47
+ readonly clientId: string;
48
+ /** Fully qualified destination URL validated before persistence. */
49
+ readonly destinationUrl: string;
50
+ /** JSON body data delivered to the consumer. */
51
+ readonly payload: JsonValue;
52
+ /** Additional immutable JSON context associated with the event. */
53
+ readonly metadata: Readonly<Record<string, JsonValue>>;
54
+ /** Event-specific outbound headers, excluding reserved engine-owned names. */
55
+ readonly headers: Readonly<Record<string, string>>;
56
+ /** Current durable lifecycle state. */
57
+ readonly status: EventStatus;
58
+ /** Number of delivery attempts started for this event. */
59
+ readonly attemptCount: number;
60
+ /** Time at or after which a retry may be claimed, or null when not scheduled. */
61
+ readonly nextRetryAt: Timestamp | null;
62
+ /** Time at which the current processing lease was acquired, if any. */
63
+ readonly lockedAt: Timestamp | null;
64
+ /** Identifier of the worker holding the current lease, if any. */
65
+ readonly lockedBy: string | null;
66
+ /** Time at which the event was first persisted. */
67
+ readonly createdAt: Timestamp;
68
+ /** Time at which the event was most recently changed. */
69
+ readonly updatedAt: Timestamp;
70
+ }
71
+ /**
72
+ * Append-only audit data for one outbound delivery attempt.
73
+ *
74
+ * The attempt ordinal is 1-based. `statusCode` is null when no HTTP response
75
+ * was received, `responseSnippet` is null when no response body exists and
76
+ * must be bounded to at most 1 KB, and `error` contains a non-secret
77
+ * description when dispatch failed.
78
+ */
79
+ export interface AttemptRecord {
80
+ /** Adapter-generated identifier for this audit row. */
81
+ readonly id: string;
82
+ /** Identifier of the event that was dispatched. */
83
+ readonly eventId: string;
84
+ /** 1-based ordinal matching the event's dispatch attempt counter. */
85
+ readonly attemptNumber: number;
86
+ /** UTC time at which this dispatch attempt began. */
87
+ readonly attemptedAt: Timestamp;
88
+ /** HTTP response status, or null when transport failed before a response. */
89
+ readonly statusCode: number | null;
90
+ /** Secret-free response excerpt bounded to at most 1 KB, if available. */
91
+ readonly responseSnippet: string | null;
92
+ /** Non-secret transport or delivery error description, if any. */
93
+ readonly error: string | null;
94
+ /** Round-trip duration in milliseconds, or null if it was not measured. */
95
+ readonly latencyMs: number | null;
96
+ /** Delivery result used to determine the event's next lifecycle state. */
97
+ readonly outcome: AttemptOutcome;
98
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Lifecycle states persisted for a webhook event.
3
+ */
4
+ export const EVENT_STATUSES = ['pending', 'processing', 'delivered', 'retry', 'failed'];
5
+ /**
6
+ * Result recorded for a delivery attempt. `exhausted` means the event reached
7
+ * a terminal unsuccessful state, either through a permanent response or by
8
+ * reaching the configured retry limit.
9
+ */
10
+ export const ATTEMPT_OUTCOMES = ['success', 'retry_scheduled', 'exhausted'];
@@ -0,0 +1,74 @@
1
+ import type { Timestamp } from '../../core/types.js';
2
+ /**
3
+ * Input for one backoff calculation.
4
+ */
5
+ export interface BackoffInput {
6
+ /**
7
+ * 1-based dispatch attempt number for the event.
8
+ *
9
+ * Must be a positive safe integer (≥ 1).
10
+ */
11
+ readonly attempt: number;
12
+ /**
13
+ * Initial exponential backoff bound in milliseconds.
14
+ *
15
+ * The uncapped delay for attempt `n` is `baseDelayMs * 2^(n - 1)`.
16
+ * Must be a positive safe integer.
17
+ */
18
+ readonly baseDelayMs: number;
19
+ /**
20
+ * Maximum exponential backoff bound in milliseconds.
21
+ *
22
+ * The delay is capped at this value before full jitter is applied.
23
+ * Must be a positive safe integer ≥ `baseDelayMs`.
24
+ */
25
+ readonly maxDelayMs: number;
26
+ /**
27
+ * UTC reference time for computing `nextRetryAt`.
28
+ *
29
+ * Must be a valid ISO-8601 timestamp string.
30
+ */
31
+ readonly now: Timestamp;
32
+ }
33
+ /**
34
+ * Result of a single backoff calculation.
35
+ */
36
+ export interface BackoffResult {
37
+ /**
38
+ * UTC ISO-8601 timestamp at or after which the event may next be claimed.
39
+ *
40
+ * Computed as `now + floor(Math.random() * cap)` where
41
+ * `cap = min(baseDelayMs * 2^(attempt - 1), maxDelayMs)`.
42
+ */
43
+ readonly nextRetryAt: Timestamp;
44
+ }
45
+ /**
46
+ * Computes the next retry timestamp using bounded exponential backoff with
47
+ * full jitter.
48
+ *
49
+ * ### Algorithm
50
+ *
51
+ * ```
52
+ * cap = min(baseDelayMs * 2^(attempt - 1), maxDelayMs)
53
+ * actualDelay = floor(Math.random() * cap) // full jitter: uniform [0, cap)
54
+ * nextRetryAt = now + actualDelay
55
+ * ```
56
+ *
57
+ * Full jitter prevents thundering-herd on struggling endpoints by spreading
58
+ * retries uniformly across the entire backoff window rather than clustering
59
+ * them near the cap (R5, §10 trade-offs).
60
+ *
61
+ * `Math.random()` returns values in [0, 1). When the mock returns exactly 1
62
+ * in tests, `floor(1 * cap)` equals `cap`, which is why the tests that pass
63
+ * `mockReturnValue(1)` expect the full cap as the delay. In production the
64
+ * max actual delay is `cap - 1` ms due to `Math.random() < 1`.
65
+ *
66
+ * @param input - Attempt number, policy bounds, and UTC reference time.
67
+ * @returns The ISO-8601 timestamp at or after which the retry may be claimed.
68
+ * @throws {RangeError} When `attempt` is not a positive safe integer.
69
+ * @throws {RangeError} When `baseDelayMs` is not a positive safe integer.
70
+ * @throws {RangeError} When `maxDelayMs < baseDelayMs` or not a positive safe
71
+ * integer.
72
+ * @throws {TypeError} When `now` is not a valid ISO-8601 timestamp.
73
+ */
74
+ export declare function calculateNextRetryAt(input: BackoffInput): BackoffResult;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Computes the next retry timestamp using bounded exponential backoff with
3
+ * full jitter.
4
+ *
5
+ * ### Algorithm
6
+ *
7
+ * ```
8
+ * cap = min(baseDelayMs * 2^(attempt - 1), maxDelayMs)
9
+ * actualDelay = floor(Math.random() * cap) // full jitter: uniform [0, cap)
10
+ * nextRetryAt = now + actualDelay
11
+ * ```
12
+ *
13
+ * Full jitter prevents thundering-herd on struggling endpoints by spreading
14
+ * retries uniformly across the entire backoff window rather than clustering
15
+ * them near the cap (R5, §10 trade-offs).
16
+ *
17
+ * `Math.random()` returns values in [0, 1). When the mock returns exactly 1
18
+ * in tests, `floor(1 * cap)` equals `cap`, which is why the tests that pass
19
+ * `mockReturnValue(1)` expect the full cap as the delay. In production the
20
+ * max actual delay is `cap - 1` ms due to `Math.random() < 1`.
21
+ *
22
+ * @param input - Attempt number, policy bounds, and UTC reference time.
23
+ * @returns The ISO-8601 timestamp at or after which the retry may be claimed.
24
+ * @throws {RangeError} When `attempt` is not a positive safe integer.
25
+ * @throws {RangeError} When `baseDelayMs` is not a positive safe integer.
26
+ * @throws {RangeError} When `maxDelayMs < baseDelayMs` or not a positive safe
27
+ * integer.
28
+ * @throws {TypeError} When `now` is not a valid ISO-8601 timestamp.
29
+ */
30
+ export function calculateNextRetryAt(input) {
31
+ if (!Number.isSafeInteger(input.attempt) || input.attempt < 1) {
32
+ throw new RangeError('attempt must be a positive safe integer (≥ 1).');
33
+ }
34
+ if (!Number.isSafeInteger(input.baseDelayMs) || input.baseDelayMs <= 0) {
35
+ throw new RangeError('baseDelayMs must be a positive safe integer.');
36
+ }
37
+ if (!Number.isSafeInteger(input.maxDelayMs) || input.maxDelayMs <= 0) {
38
+ throw new RangeError('maxDelayMs must be a positive safe integer.');
39
+ }
40
+ if (input.maxDelayMs < input.baseDelayMs) {
41
+ throw new RangeError('maxDelayMs must be greater than or equal to baseDelayMs.');
42
+ }
43
+ const nowMs = Date.parse(input.now);
44
+ if (!Number.isFinite(nowMs)) {
45
+ throw new TypeError('now must be a valid ISO-8601 timestamp.');
46
+ }
47
+ // Compute the exponential cap, guarding against overflow for large attempts.
48
+ const exponent = input.attempt - 1;
49
+ const uncapped = exponent >= 53 ? Number.MAX_SAFE_INTEGER : input.baseDelayMs * Math.pow(2, exponent);
50
+ const cap = Math.min(uncapped, input.maxDelayMs);
51
+ // Full jitter: uniform random delay in [0, cap].
52
+ const actualDelay = Math.floor(Math.random() * (cap + 1));
53
+ const clampedDelay = Math.min(actualDelay, cap);
54
+ const nextRetryAt = new Date(nowMs + clampedDelay).toISOString();
55
+ return { nextRetryAt };
56
+ }