@objectstack/plugin-webhooks 5.1.0 → 5.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.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +35 -13
  2. package/CHANGELOG.md +9 -37
  3. package/dist/chunk-JN76ZRWN.js +164 -0
  4. package/dist/chunk-JN76ZRWN.js.map +1 -0
  5. package/dist/chunk-M4M5FWIH.cjs +15 -0
  6. package/dist/chunk-M4M5FWIH.cjs.map +1 -0
  7. package/dist/chunk-NYSUNT6X.js +15 -0
  8. package/dist/chunk-NYSUNT6X.js.map +1 -0
  9. package/dist/chunk-OW7ESXOK.cjs +164 -0
  10. package/dist/chunk-OW7ESXOK.cjs.map +1 -0
  11. package/dist/index.cjs +747 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +455 -0
  14. package/dist/index.d.ts +425 -74
  15. package/dist/index.js +712 -218
  16. package/dist/index.js.map +1 -1
  17. package/dist/outbox-bPQmKYPN.d.cts +128 -0
  18. package/dist/outbox-bPQmKYPN.d.ts +128 -0
  19. package/dist/schema.cjs +9 -0
  20. package/dist/schema.cjs.map +1 -0
  21. package/dist/schema.d.cts +4772 -0
  22. package/dist/schema.d.ts +4772 -0
  23. package/dist/schema.js +9 -0
  24. package/dist/schema.js.map +1 -0
  25. package/dist/sql-outbox.cjs +184 -0
  26. package/dist/sql-outbox.cjs.map +1 -0
  27. package/dist/sql-outbox.d.cts +54 -0
  28. package/dist/sql-outbox.d.ts +54 -0
  29. package/dist/sql-outbox.js +184 -0
  30. package/dist/sql-outbox.js.map +1 -0
  31. package/package.json +30 -10
  32. package/src/auto-enqueuer.test.ts +391 -0
  33. package/src/auto-enqueuer.ts +335 -0
  34. package/src/dispatcher.test.ts +324 -0
  35. package/src/dispatcher.ts +218 -0
  36. package/src/http-sender.ts +187 -0
  37. package/src/index.ts +48 -12
  38. package/src/memory-outbox.ts +127 -0
  39. package/src/outbox.ts +141 -0
  40. package/src/partition.ts +19 -0
  41. package/src/retention.test.ts +116 -0
  42. package/src/retention.ts +144 -0
  43. package/src/schema.ts +22 -0
  44. package/src/sql-outbox.test.ts +410 -0
  45. package/src/sql-outbox.ts +282 -0
  46. package/src/sys-webhook-delivery.object.ts +202 -0
  47. package/src/webhook-outbox-plugin.ts +280 -0
  48. package/tsconfig.json +5 -13
  49. package/tsup.config.ts +14 -0
  50. package/dist/index.d.mts +0 -104
  51. package/dist/index.mjs +0 -216
  52. package/dist/index.mjs.map +0 -1
  53. package/src/webhooks-plugin.test.ts +0 -218
  54. package/src/webhooks-plugin.ts +0 -294
package/dist/index.d.ts CHANGED
@@ -1,104 +1,455 @@
1
1
  import { Plugin, PluginContext } from '@objectstack/core';
2
+ import { IDataEngine, IRealtimeService, IClusterService } from '@objectstack/spec/contracts';
3
+ import { I as IWebhookOutbox, a as AckResult, W as WebhookDelivery, E as EnqueueInput, C as ClaimOptions, D as DeliveryStatus } from './outbox-bPQmKYPN.js';
4
+ export { A as AckFailure, b as AckSuccess } from './outbox-bPQmKYPN.js';
2
5
 
3
6
  /**
4
- * A single webhook delivery target.
7
+ * Optional logger interface (subset of console / kernel logger).
5
8
  */
6
- interface WebhookSink {
7
- /** Unique sink id used for log correlation. */
9
+ interface OptionalLogger$1 {
10
+ info?(msg: string, meta?: unknown): void;
11
+ warn?(msg: string, meta?: unknown): void;
12
+ debug?(msg: string, meta?: unknown): void;
13
+ error?(msg: string, err?: unknown, meta?: unknown): void;
14
+ }
15
+ /**
16
+ * Per-row subscription cached in memory. Mirrors a subset of the
17
+ * `sys_webhook` object — only what the auto-enqueuer needs to match an
18
+ * event and build an `EnqueueInput`.
19
+ */
20
+ interface CachedSubscription {
8
21
  id: string;
9
- /** Target HTTPS URL. */
22
+ name: string;
23
+ objectName: string | undefined;
24
+ triggers: Set<'create' | 'update' | 'delete' | 'undelete'>;
10
25
  url: string;
11
- /** Optional HMAC-SHA256 secret. When set, `X-Objectstack-Signature: sha256=…` is added. */
26
+ method?: string;
27
+ headers?: Record<string, string>;
12
28
  secret?: string;
29
+ timeoutMs?: number;
30
+ }
31
+ interface AutoEnqueuerOptions {
13
32
  /**
14
- * Restrict to specific object names (logical names, e.g. `lead`, `account`).
15
- * Omit / empty all objects.
33
+ * Object name holding webhook subscriptions. Defaults to `sys_webhook`,
34
+ * the platform-objects schema authored in apps.
16
35
  */
17
- objects?: string[];
36
+ subscriptionsObject?: string;
18
37
  /**
19
- * Restrict to specific event types. Omit / empty → all `data.record.*` events.
38
+ * Periodic full-cache refresh interval (ms). Belt-and-braces in case
39
+ * the subscription-change event is missed. Default 60s.
20
40
  */
21
- eventTypes?: string[];
22
- /** Extra headers to send (Authorization, Tenant, etc.). */
23
- headers?: Record<string, string>;
24
- /** Per-request timeout in milliseconds. Default 5000. */
25
- timeoutMs?: number;
26
- /** Retry attempts on transient failure. Default 3. Set 0 to disable retries. */
27
- retries?: number;
41
+ refreshIntervalMs?: number;
42
+ logger?: OptionalLogger$1;
28
43
  }
29
44
  /**
30
- * Delivery attempt outcome surfaced to in-process listeners / tests.
45
+ * Bridge between `IRealtimeService` (`data.record.*` events emitted by
46
+ * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher
47
+ * picks up).
48
+ *
49
+ * ## Why a separate class
50
+ * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this
51
+ * class owns the runtime fan-out logic + subscription cache.
52
+ *
53
+ * ## Hot path
54
+ * Every `engine.insert/update/delete` fires a `data.record.*` event.
55
+ * The handler:
56
+ * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`
57
+ * — O(1) per event, no DB hit on the write path.
58
+ * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The
59
+ * enqueue itself is a single INSERT, which runs *after* the user's
60
+ * request has already returned.
61
+ *
62
+ * Net cost on the write path: one synchronous Map lookup (~microseconds).
63
+ *
64
+ * ## Cache freshness
65
+ * The cache is rebuilt:
66
+ * 1. Once on `start()`.
67
+ * 2. On every `data.record.{created,updated,deleted}` event whose
68
+ * object is `sys_webhook` (self-healing — when a user toggles a
69
+ * webhook, the handler refreshes the cache before returning).
70
+ * 3. Periodically (default 60s) as belt-and-braces.
71
+ *
72
+ * For multi-node clusters this is *eventually consistent* — node B may
73
+ * not see node A's edit for up to one cycle. That's acceptable for
74
+ * webhook configuration changes (humans don't expect millisecond
75
+ * propagation) and matches Hasura's behaviour.
76
+ *
77
+ * ## Determinism
78
+ * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`
79
+ * so the outbox dedup index catches duplicates that could arise from
80
+ * upstream replay or buggy producers — and is stable across nodes.
31
81
  */
32
- type WebhookDeliveryStatus = 'ok' | 'retrying' | 'failed';
33
- interface WebhookDeliveryRecord {
34
- sinkId: string;
35
- url: string;
36
- eventType: string;
37
- object?: string;
38
- status: WebhookDeliveryStatus;
82
+ declare class AutoEnqueuer {
83
+ private readonly engine;
84
+ private readonly realtime;
85
+ private readonly outbox;
86
+ private readonly subscriptions;
87
+ private readonly subscriptionsObject;
88
+ private readonly refreshIntervalMs;
89
+ private readonly logger;
90
+ private subId;
91
+ private subIdSelfHeal;
92
+ private refreshTimer;
93
+ private running;
94
+ private refreshing;
95
+ constructor(engine: IDataEngine, realtime: IRealtimeService, outbox: IWebhookOutbox, opts?: AutoEnqueuerOptions);
96
+ /**
97
+ * Load the subscription cache and start listening for events.
98
+ */
99
+ start(): Promise<void>;
100
+ stop(): Promise<void>;
101
+ /**
102
+ * Force-refresh the subscription cache from storage. Concurrent
103
+ * callers share a single in-flight refresh.
104
+ */
105
+ refresh(): Promise<void>;
106
+ private doRefresh;
107
+ private parseRow;
108
+ /**
109
+ * Handler for the firehose subscription.
110
+ *
111
+ * NOTE: we intentionally `void` the inner enqueue() so the realtime
112
+ * publisher (and therefore the user's request) is never blocked on
113
+ * webhook persistence.
114
+ */
115
+ private handleEvent;
116
+ private handleSelfHealEvent;
117
+ /** Test / admin accessor. */
118
+ snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>>;
119
+ }
120
+
121
+ /**
122
+ * Default per-request timeout. Receivers SHOULD respond within ~30s; we
123
+ * cap aggressively to free dispatcher slots.
124
+ */
125
+ declare const DEFAULT_TIMEOUT_MS = 15000;
126
+ type FetchImpl = (input: string, init: {
127
+ method: string;
128
+ headers: Record<string, string>;
129
+ body: string;
130
+ signal: AbortSignal;
131
+ }) => Promise<{
132
+ ok: boolean;
133
+ status: number;
134
+ text(): Promise<string>;
135
+ }>;
136
+ /** Single HTTP attempt classified to an `AckResult` shape (without nextRetryAt). */
137
+ type AttemptOutcome = {
138
+ success: true;
139
+ httpStatus: number;
140
+ responseBody?: string;
141
+ durationMs: number;
142
+ } | {
143
+ success: false;
144
+ retriable: boolean;
39
145
  httpStatus?: number;
40
- attempt: number;
146
+ responseBody?: string;
41
147
  error?: string;
148
+ durationMs: number;
149
+ };
150
+ /**
151
+ * Send one HTTP attempt for the delivery. Pure (no DB writes) so the
152
+ * dispatcher owns retry-schedule + ack logic.
153
+ *
154
+ * - 2xx → success
155
+ * - 4xx (except 408/429) → permanent failure (retriable = false → goes to `dead`)
156
+ * - 408, 429, 5xx, transport → retriable
157
+ */
158
+ declare function sendOnce(delivery: WebhookDelivery, fetchImpl: FetchImpl): Promise<AttemptOutcome>;
159
+ /**
160
+ * Stripe-style retry schedule. Returns the next `nextRetryAt` ms (relative
161
+ * to `now`) given how many attempts have already happened, or `null` if
162
+ * the row should be moved to `dead`.
163
+ *
164
+ * attempt 1 fails -> retry in ~1s
165
+ * attempt 2 fails -> ~10s
166
+ * attempt 3 fails -> ~1m
167
+ * attempt 4 fails -> ~10m
168
+ * attempt 5 fails -> ~1h
169
+ * attempt 6 fails -> ~6h
170
+ * attempt 7 fails -> ~24h
171
+ * attempt 8+ fails -> dead
172
+ *
173
+ * Each delay is multiplied by jitter ∈ [0.8, 1.2].
174
+ */
175
+ declare function nextRetryDelayMs(attemptsSoFar: number, rng?: () => number): number | null;
176
+ /**
177
+ * Compose an `AckResult` from an `AttemptOutcome`, applying the retry
178
+ * schedule on retriable failures.
179
+ */
180
+ declare function classifyAttempt(outcome: AttemptOutcome, attemptsSoFar: number, now?: number, rng?: () => number): AckResult;
181
+
182
+ /**
183
+ * Minimal logger surface — kernel's `Logger` is compatible (extra params
184
+ * accepted). Keeping it permissive avoids a hard dependency on the spec
185
+ * Logger interface here.
186
+ */
187
+ interface DispatcherLogger {
188
+ warn: (msg: string, meta?: any) => void;
189
+ info?: (msg: string, meta?: any) => void;
190
+ }
191
+ interface DispatcherOptions {
192
+ /** Stable id identifying this dispatcher node. */
193
+ nodeId: string;
194
+ /** Cluster service providing `lock` (and optional metrics). */
195
+ cluster: IClusterService;
196
+ /** Outbox backend. */
197
+ outbox: IWebhookOutbox;
198
+ /**
199
+ * How many partitions to split work across. Each tick the dispatcher
200
+ * attempts to acquire each partition's lock independently — the node
201
+ * that wins owns that partition for the duration of the batch.
202
+ *
203
+ * Default: 8 (matches webhook-delivery.mdx §4 example).
204
+ */
205
+ partitionCount?: number;
206
+ /** Max rows to claim from each partition per tick. Default 32. */
207
+ batchSize?: number;
208
+ /** Tick interval in ms. Default 250. */
209
+ intervalMs?: number;
210
+ /** Per-partition lock TTL. Default = 5 × intervalMs. */
211
+ lockTtlMs?: number;
212
+ /** Visibility timeout for claimed rows. Default = 2 × lockTtlMs. */
213
+ claimTtlMs?: number;
214
+ /** Override `globalThis.fetch` (tests). */
215
+ fetchImpl?: FetchImpl;
216
+ /** Hook fired after every attempt — observability hook. */
217
+ onAttempt?: (delivery: WebhookDelivery, success: boolean) => void;
218
+ /** RNG override for the retry-jitter schedule (tests). */
219
+ rng?: () => number;
220
+ /** Logger callback (optional). */
221
+ logger?: DispatcherLogger;
222
+ }
223
+ /**
224
+ * Cross-node webhook dispatcher.
225
+ *
226
+ * **Design** — each tick the dispatcher iterates over `partitionCount`
227
+ * logical partitions. For each, it tries to acquire a cluster-scoped lock
228
+ * (`webhook.dispatcher.partition.{i}`) with a short TTL. If it wins the
229
+ * lock, it claims up to `batchSize` ready rows whose `hash(webhookId) mod
230
+ * partitionCount === i`, POSTs them, and acks. The lock is released
231
+ * immediately after the batch so other nodes can fairly rotate through.
232
+ *
233
+ * **Why per-partition locks rather than one global lock?**
234
+ *
235
+ * 1. Throughput — N nodes can process N partitions concurrently.
236
+ * 2. Partition affinity — rows for the same webhook always sort into the
237
+ * same partition, preserving in-order delivery per webhook.
238
+ * 3. Failure isolation — a stuck node only blocks its partition until the
239
+ * TTL elapses; other partitions keep moving.
240
+ *
241
+ * **At-least-once, not exactly-once.** Receivers MUST be idempotent on the
242
+ * `X-Objectstack-Delivery` (== row id) header. If the HTTP call succeeds
243
+ * but the ack write fails, the row reverts to pending after the claim TTL
244
+ * and will be re-posted.
245
+ */
246
+ declare class WebhookDispatcher {
247
+ private readonly opts;
248
+ private timer;
249
+ private running;
250
+ private inflightTick;
251
+ constructor(options: DispatcherOptions);
252
+ /** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */
253
+ start(): void;
254
+ /** Stop the loop and wait for the in-flight tick to drain. */
255
+ stop(): Promise<void>;
256
+ /**
257
+ * Run one full tick (all partitions, single attempt each). Exposed for
258
+ * deterministic tests that want to step the dispatcher manually.
259
+ */
260
+ tick(): Promise<void>;
261
+ private scheduleTick;
262
+ private runTick;
263
+ private runPartition;
264
+ private processRow;
265
+ }
266
+
267
+ interface OptionalLogger {
268
+ info?(msg: string, meta?: unknown): void;
269
+ warn?(msg: string, meta?: unknown): void;
270
+ debug?(msg: string, meta?: unknown): void;
271
+ }
272
+ interface DeliveryRetentionOptions {
273
+ /**
274
+ * Object name backing the outbox. Defaults to `sys_webhook_delivery`.
275
+ */
276
+ objectName?: string;
277
+ /**
278
+ * How long to keep `success` rows. Default 7 days. Set to `0` to
279
+ * disable the success sweep (keep forever — not recommended in
280
+ * production).
281
+ */
282
+ successTtlMs?: number;
283
+ /**
284
+ * How long to keep `dead` rows. Default 30 days. Set to `0` to
285
+ * keep forever.
286
+ */
287
+ deadTtlMs?: number;
288
+ /**
289
+ * How often to run the sweep. Default 1h.
290
+ */
291
+ sweepIntervalMs?: number;
292
+ logger?: OptionalLogger;
42
293
  }
43
294
  /**
44
- * Plugin configuration.
295
+ * Periodically prunes `sys_webhook_delivery` rows so the table doesn't
296
+ * grow unbounded.
297
+ *
298
+ * Without this every successful POST would leave a permanent row. At
299
+ * even moderate scale (10 events/s × 3 webhooks = 30 rows/s = ~2.6M
300
+ * rows/day) the table becomes a problem within a week.
45
301
  *
46
- * Sinks may be supplied programmatically OR via env vars when none are
47
- * passed (suitable for 12-factor / Docker deployments):
302
+ * Retention defaults mirror Stripe/GitHub:
303
+ * - `success`: 7 days
304
+ * - `dead`: 30 days (kept longer for audit & manual re-delivery)
305
+ * - `pending`/`in_flight`/`failed`: never auto-pruned (they're
306
+ * either live work or signal something needs human attention)
48
307
  *
49
- * OBJECTSTACK_WEBHOOK_URL — single URL, or comma-separated URLs.
50
- * OBJECTSTACK_WEBHOOK_SECRET — HMAC secret applied to all env-sourced URLs.
51
- * OBJECTSTACK_WEBHOOK_OBJECTS — comma-separated object whitelist.
52
- * OBJECTSTACK_WEBHOOK_EVENTS — comma-separated event-type whitelist
53
- * (e.g. `data.record.created`).
308
+ * Runs on whichever node holds the sweeper interval — it doesn't need
309
+ * a cluster lock because DELETE WHERE created_at < threshold is
310
+ * idempotent; multiple nodes running concurrently is wasteful but
311
+ * safe.
54
312
  */
55
- interface WebhooksPluginOptions {
56
- /** Explicit sink list (takes precedence over env vars). */
57
- sinks?: WebhookSink[];
58
- /** Override fetch (mainly for tests). Defaults to globalThis.fetch. */
59
- fetchImpl?: typeof fetch;
60
- /** Hook invoked with each delivery outcome (mainly for tests / metrics). */
61
- onDelivery?: (record: WebhookDeliveryRecord) => void;
313
+ declare class DeliveryRetentionSweeper {
314
+ private readonly engine;
315
+ private readonly objectName;
316
+ private readonly successTtlMs;
317
+ private readonly deadTtlMs;
318
+ private readonly sweepIntervalMs;
319
+ private readonly logger;
320
+ private timer;
321
+ private running;
322
+ constructor(engine: IDataEngine, opts?: DeliveryRetentionOptions);
323
+ start(): void;
324
+ stop(): void;
325
+ /** Run one sweep immediately. Returns the number of rows deleted. */
326
+ sweep(now?: number): Promise<{
327
+ success: number;
328
+ dead: number;
329
+ }>;
330
+ }
331
+
332
+ interface WebhookOutboxPluginOptions extends Partial<Omit<DispatcherOptions, 'cluster' | 'outbox' | 'nodeId'>> {
333
+ /**
334
+ * Override the outbox backend. If omitted a fresh `MemoryWebhookOutbox`
335
+ * is used — fine for local development, **not for production**: each
336
+ * node will see only its own rows.
337
+ *
338
+ * Pass a factory if you need the kernel-resolved `IDataEngine`:
339
+ *
340
+ * ```ts
341
+ * outbox: (ctx) => new SqlWebhookOutbox(
342
+ * ctx.getService('objectql'), { partitionCount: 8 },
343
+ * ),
344
+ * ```
345
+ */
346
+ outbox?: IWebhookOutbox | ((ctx: PluginContext) => IWebhookOutbox);
347
+ /**
348
+ * Stable node id. If omitted, uses `process.env.OBJECTSTACK_NODE_ID`
349
+ * or a random UUID generated at plugin init.
350
+ */
351
+ nodeId?: string;
352
+ /**
353
+ * If `false`, the plugin registers the outbox/dispatcher services but
354
+ * does NOT auto-start the loop — useful for tests that want to step
355
+ * the dispatcher manually via `dispatcher.tick()`.
356
+ *
357
+ * Default: true.
358
+ */
359
+ autoStart?: boolean;
360
+ /**
361
+ * Auto-enqueue config. When enabled (default `true` if the realtime
362
+ * + data engine services are available), the plugin subscribes to
363
+ * `data.record.*` events emitted by the engine and automatically
364
+ * enqueues a delivery row for every matching `sys_webhook` row.
365
+ *
366
+ * Set `false` to disable and only use the imperative
367
+ * `outbox.enqueue()` API.
368
+ */
369
+ autoEnqueue?: boolean | AutoEnqueuerOptions;
370
+ /**
371
+ * Retention sweep config. When enabled (default `true` if a SQL
372
+ * outbox is in use), a periodic timer prunes old `success` and
373
+ * `dead` rows from `sys_webhook_delivery`.
374
+ *
375
+ * Set `false` to disable (e.g. when using `MemoryWebhookOutbox`).
376
+ */
377
+ retention?: boolean | DeliveryRetentionOptions;
62
378
  }
63
379
  /**
64
- * WebhooksPlugin fan out data.record.* events to external HTTP endpoints.
65
- *
66
- * @example
67
- * ```ts
68
- * kernel.use(new WebhooksPlugin({
69
- * sinks: [
70
- * { id: 'crm-sync', url: 'https://hooks.example.com/in',
71
- * secret: process.env.HOOK_SECRET, objects: ['lead', 'account'] },
72
- * ],
73
- * }));
74
- * ```
380
+ * Wires a persistent, cluster-aware webhook outbox into the kernel.
381
+ *
382
+ * Registered services:
383
+ * - `webhook.outbox` → `IWebhookOutbox` (enqueue / claim / ack / list)
384
+ * - `webhook.dispatcher` → `WebhookDispatcher` (manual `tick()` if needed)
385
+ * - `webhook.autoEnqueuer` → `AutoEnqueuer` when auto-enqueue is on
386
+ * - `webhook.retention` → `DeliveryRetentionSweeper` when retention is on
387
+ *
388
+ * End-to-end flow once auto-enqueue is enabled:
389
+ *
390
+ * engine.insert('contact', {...})
391
+ * → engine publishes data.record.created via IRealtimeService
392
+ * → AutoEnqueuer matches active sys_webhook rows in O(1)
393
+ * → outbox.enqueue() runs fire-and-forget (not on the write path)
394
+ * → dispatcher claims and POSTs (cluster-coordinated)
395
+ *
396
+ * **Cluster requirement** — this plugin depends on the cluster service
397
+ * (`ClusterServicePlugin`). With the default `memory` driver the
398
+ * dispatcher works correctly inside a single process; with a real driver
399
+ * (`@objectstack/service-cluster-redis`) it correctly coordinates work
400
+ * across nodes.
75
401
  */
76
- declare class WebhooksPlugin implements Plugin {
402
+ declare class WebhookOutboxPlugin implements Plugin {
403
+ private readonly options;
77
404
  name: string;
78
405
  version: string;
79
- type: string;
406
+ type: "standard";
80
407
  dependencies: string[];
81
- private readonly options;
82
- private subscriptionIds;
83
- private realtime?;
84
- private sinks;
85
- private logger?;
86
- constructor(options?: WebhooksPluginOptions);
408
+ private dispatcher;
409
+ private autoEnqueuer;
410
+ private retention;
411
+ private outboxInstance;
412
+ constructor(options?: WebhookOutboxPluginOptions);
87
413
  init(ctx: PluginContext): Promise<void>;
88
- start(ctx: PluginContext): Promise<void>;
89
- stop(ctx: PluginContext): Promise<void>;
90
- /**
91
- * Resolve sinks from constructor options, falling back to env vars when
92
- * none provided. Exposed for testing.
93
- */
94
- private resolveSinks;
95
- /**
96
- * Dispatch a single event to a sink, with HMAC signing, timeout, and
97
- * exponential-backoff retry. Failures past the retry budget are logged
98
- * but never thrown — webhook delivery must never break the originating
99
- * mutation.
100
- */
101
- private dispatch;
414
+ dispose(): Promise<void>;
415
+ private resolveOutbox;
416
+ private bootAutoEnqueue;
417
+ private bootRetention;
418
+ private tryGetService;
102
419
  }
103
420
 
104
- export { type WebhookDeliveryRecord, type WebhookDeliveryStatus, type WebhookSink, WebhooksPlugin, type WebhooksPluginOptions };
421
+ /**
422
+ * In-memory `IWebhookOutbox` for tests and single-process development.
423
+ *
424
+ * Implements the atomic-claim semantics by running its claim/ack logic
425
+ * synchronously (single-threaded JS event loop) inside one `Map`. Two
426
+ * `MemoryWebhookOutbox` instances do NOT share state — for the cross-node
427
+ * test the *same* instance is passed to both dispatchers (simulating one
428
+ * shared database).
429
+ *
430
+ * A production SQL-backed implementation will live in a sibling file and
431
+ * use `SELECT ... FOR UPDATE SKIP LOCKED`.
432
+ */
433
+ declare class MemoryWebhookOutbox implements IWebhookOutbox {
434
+ private readonly rows;
435
+ /** Dedup index keyed by `${eventId}::${webhookId}` -> row id. */
436
+ private readonly dedup;
437
+ enqueue(input: EnqueueInput): Promise<string>;
438
+ claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
439
+ ack(id: string, result: AckResult): Promise<void>;
440
+ list(filter?: {
441
+ status?: DeliveryStatus;
442
+ }): Promise<WebhookDelivery[]>;
443
+ }
444
+
445
+ /**
446
+ * Stable, framework-free partition hash. The dispatcher uses this to
447
+ * assign webhooks to partitions; the in-memory outbox uses the same hash
448
+ * to filter rows in `claim()`. Both call sites MUST agree, which is why
449
+ * this is a single shared helper.
450
+ *
451
+ * Uses a 32-bit FNV-1a variant — fast, no allocations, deterministic.
452
+ */
453
+ declare function hashPartition(key: string, count: number): number;
454
+
455
+ export { AckResult, type AttemptOutcome, AutoEnqueuer, type AutoEnqueuerOptions, ClaimOptions, DEFAULT_TIMEOUT_MS, type DeliveryRetentionOptions, DeliveryRetentionSweeper, DeliveryStatus, type DispatcherOptions, EnqueueInput, type FetchImpl, IWebhookOutbox, MemoryWebhookOutbox, WebhookDelivery, WebhookDispatcher, WebhookOutboxPlugin, type WebhookOutboxPluginOptions, classifyAttempt, hashPartition, nextRetryDelayMs, sendOnce };