@objectstack/plugin-webhooks 7.4.1 → 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 +59 -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 +5 -12
  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/dist/index.d.ts CHANGED
@@ -1,12 +1,19 @@
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-CIn7LSyB.js';
4
- export { A as AckFailure, b as AckSuccess, R as RedeliverError } from './outbox-CIn7LSyB.js';
2
+ import { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';
3
+ import { EnqueueHttpInput } from '@objectstack/service-messaging';
4
+ export { SysWebhook } from './schema.js';
5
+ import '@objectstack/spec/data';
5
6
 
7
+ /**
8
+ * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).
9
+ * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no
10
+ * longer own a delivery outbox/dispatcher — they share the generic substrate.
11
+ */
12
+ type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;
6
13
  /**
7
14
  * Optional logger interface (subset of console / kernel logger).
8
15
  */
9
- interface OptionalLogger$1 {
16
+ interface OptionalLogger {
10
17
  info?(msg: string, meta?: unknown): void;
11
18
  warn?(msg: string, meta?: unknown): void;
12
19
  debug?(msg: string, meta?: unknown): void;
@@ -39,7 +46,7 @@ interface AutoEnqueuerOptions {
39
46
  * the subscription-change event is missed. Default 60s.
40
47
  */
41
48
  refreshIntervalMs?: number;
42
- logger?: OptionalLogger$1;
49
+ logger?: OptionalLogger;
43
50
  }
44
51
  /**
45
52
  * Bridge between `IRealtimeService` (`data.record.*` events emitted by
@@ -82,7 +89,7 @@ interface AutoEnqueuerOptions {
82
89
  declare class AutoEnqueuer {
83
90
  private readonly engine;
84
91
  private readonly realtime;
85
- private readonly outbox;
92
+ private readonly enqueue;
86
93
  private readonly subscriptions;
87
94
  private readonly subscriptionsObject;
88
95
  private readonly refreshIntervalMs;
@@ -92,7 +99,7 @@ declare class AutoEnqueuer {
92
99
  private refreshTimer;
93
100
  private running;
94
101
  private refreshing;
95
- constructor(engine: IDataEngine, realtime: IRealtimeService, outbox: IWebhookOutbox, opts?: AutoEnqueuerOptions);
102
+ constructor(engine: IDataEngine, realtime: IRealtimeService, enqueue: HttpEnqueueFn, opts?: AutoEnqueuerOptions);
96
103
  /**
97
104
  * Load the subscription cache and start listening for events.
98
105
  */
@@ -118,287 +125,41 @@ declare class AutoEnqueuer {
118
125
  snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>>;
119
126
  }
120
127
 
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;
145
- httpStatus?: number;
146
- responseBody?: string;
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;
293
- }
294
- /**
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.
301
- *
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)
307
- *
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.
312
- */
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.OS_NODE_ID`
349
- * (legacy `OBJECTSTACK_NODE_ID` still honoured with deprecation warning)
350
- * or a random UUID generated at plugin init.
351
- */
352
- nodeId?: string;
353
- /**
354
- * If `false`, the plugin registers the outbox/dispatcher services but
355
- * does NOT auto-start the loop — useful for tests that want to step
356
- * the dispatcher manually via `dispatcher.tick()`.
357
- *
358
- * Default: true.
359
- */
360
- autoStart?: boolean;
128
+ interface WebhookOutboxPluginOptions {
361
129
  /**
362
- * Auto-enqueue config. When enabled (default `true` if the realtime
363
- * + data engine services are available), the plugin subscribes to
364
- * `data.record.*` events emitted by the engine and automatically
365
- * enqueues a delivery row for every matching `sys_webhook` row.
130
+ * Auto-enqueue config. When enabled (default `true` if the realtime + data
131
+ * engine services are available), the plugin subscribes to `data.record.*`
132
+ * events and enqueues a delivery onto the shared messaging HTTP outbox for
133
+ * every matching `sys_webhook` row.
366
134
  *
367
- * Set `false` to disable and only use the imperative
368
- * `outbox.enqueue()` API.
135
+ * Set `false` to disable and enqueue webhooks imperatively elsewhere.
369
136
  */
370
137
  autoEnqueue?: boolean | AutoEnqueuerOptions;
371
- /**
372
- * Retention sweep config. When enabled (default `true` if a SQL
373
- * outbox is in use), a periodic timer prunes old `success` and
374
- * `dead` rows from `sys_webhook_delivery`.
375
- *
376
- * Set `false` to disable (e.g. when using `MemoryWebhookOutbox`).
377
- */
378
- retention?: boolean | DeliveryRetentionOptions;
379
138
  }
380
139
  /**
381
- * Wires a persistent, cluster-aware webhook outbox into the kernel.
140
+ * Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate
141
+ * (ADR-0018 M3).
382
142
  *
383
- * Registered services:
384
- * - `webhook.outbox` → `IWebhookOutbox` (enqueue / claim / ack / list)
385
- * - `webhook.dispatcher` `WebhookDispatcher` (manual `tick()` if needed)
386
- * - `webhook.autoEnqueuer` `AutoEnqueuer` when auto-enqueue is on
387
- * - `webhook.retention` → `DeliveryRetentionSweeper` when retention is on
143
+ * Webhooks are no longer their own delivery engine: the durable outbox, the
144
+ * cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and
145
+ * the retention sweep all live in `@objectstack/service-messaging`
146
+ * (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the
147
+ * webhook-specific concerns:
148
+ * - the `sys_webhook` configuration object,
149
+ * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox
150
+ * rows (`source: 'webhook'`), and
151
+ * - the redeliver admin endpoint.
388
152
  *
389
- * End-to-end flow once auto-enqueue is enabled:
153
+ * End-to-end flow:
390
154
  *
391
155
  * engine.insert('contact', {...})
392
156
  * → engine publishes data.record.created via IRealtimeService
393
157
  * → AutoEnqueuer matches active sys_webhook rows in O(1)
394
- * → outbox.enqueue() runs fire-and-forget (not on the write path)
395
- * → dispatcher claims and POSTs (cluster-coordinated)
158
+ * → messaging.enqueueHttp() runs fire-and-forget (off the write path)
159
+ * → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)
396
160
  *
397
- * **Cluster requirement** this plugin depends on the cluster service
398
- * (`ClusterServicePlugin`). With the default `memory` driver the
399
- * dispatcher works correctly inside a single process; with a real driver
400
- * (`@objectstack/service-cluster-redis`) it correctly coordinates work
401
- * across nodes.
161
+ * **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),
162
+ * which is a foundational, always-on capability.
402
163
  */
403
164
  declare class WebhookOutboxPlugin implements Plugin {
404
165
  private readonly options;
@@ -406,65 +167,20 @@ declare class WebhookOutboxPlugin implements Plugin {
406
167
  version: string;
407
168
  type: "standard";
408
169
  dependencies: string[];
409
- private dispatcher;
410
170
  private autoEnqueuer;
411
- private retention;
412
- private outboxInstance;
413
171
  constructor(options?: WebhookOutboxPluginOptions);
414
172
  init(ctx: PluginContext): Promise<void>;
415
173
  dispose(): Promise<void>;
416
- private resolveOutbox;
174
+ private getMessaging;
417
175
  private bootAutoEnqueue;
418
- private bootRetention;
419
176
  private tryGetService;
420
177
  /**
421
- * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one
422
- * is available. Silently no-ops in environments without an HTTP
423
- * server (MSW, edge tests, pure library use). Auth is delegated to
424
- * the better-auth session cookie — every authenticated user counts.
178
+ * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is
179
+ * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the
180
+ * better-auth session cookie every authenticated user counts.
425
181
  */
426
182
  private registerAdminRoutes;
427
- /**
428
- * Resolve the requesting user's id from a better-auth session cookie.
429
- * Returns `undefined` for anonymous callers — the caller decides
430
- * whether that's a 401.
431
- */
432
183
  private resolveSessionUserId;
433
184
  }
434
185
 
435
- /**
436
- * In-memory `IWebhookOutbox` for tests and single-process development.
437
- *
438
- * Implements the atomic-claim semantics by running its claim/ack logic
439
- * synchronously (single-threaded JS event loop) inside one `Map`. Two
440
- * `MemoryWebhookOutbox` instances do NOT share state — for the cross-node
441
- * test the *same* instance is passed to both dispatchers (simulating one
442
- * shared database).
443
- *
444
- * A production SQL-backed implementation will live in a sibling file and
445
- * use `SELECT ... FOR UPDATE SKIP LOCKED`.
446
- */
447
- declare class MemoryWebhookOutbox implements IWebhookOutbox {
448
- private readonly rows;
449
- /** Dedup index keyed by `${eventId}::${webhookId}` -> row id. */
450
- private readonly dedup;
451
- enqueue(input: EnqueueInput): Promise<string>;
452
- claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
453
- ack(id: string, result: AckResult): Promise<void>;
454
- list(filter?: {
455
- status?: DeliveryStatus;
456
- }): Promise<WebhookDelivery[]>;
457
- redeliver(id: string): Promise<WebhookDelivery>;
458
- }
459
-
460
- /**
461
- * Stable, framework-free partition hash. The dispatcher uses this to
462
- * assign webhooks to partitions; the in-memory outbox uses the same hash
463
- * to filter rows in `claim()`. Both call sites MUST agree, which is why
464
- * this is a single shared helper.
465
- *
466
- * Uses a 32-bit FNV-1a variant — fast, no allocations, deterministic.
467
- */
468
- declare function hashPartition(key: string, count: number): number;
469
-
470
- 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 };
186
+ export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn, WebhookOutboxPlugin, type WebhookOutboxPluginOptions };