@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/schema.js CHANGED
@@ -1,11 +1,7 @@
1
1
  import {
2
- SYS_WEBHOOK_DELIVERY,
3
- SysWebhook,
4
- SysWebhookDelivery
5
- } from "./chunk-7HS5DLU2.js";
2
+ SysWebhook
3
+ } from "./chunk-HWFTXTTI.js";
6
4
  export {
7
- SYS_WEBHOOK_DELIVERY,
8
- SysWebhook,
9
- SysWebhookDelivery
5
+ SysWebhook
10
6
  };
11
7
  //# sourceMappingURL=schema.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-webhooks",
3
- "version": "7.4.1",
3
+ "version": "7.6.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.",
6
6
  "type": "module",
@@ -12,11 +12,6 @@
12
12
  "import": "./dist/index.js",
13
13
  "require": "./dist/index.cjs"
14
14
  },
15
- "./sql": {
16
- "types": "./dist/sql-outbox.d.ts",
17
- "import": "./dist/sql-outbox.js",
18
- "require": "./dist/sql-outbox.cjs"
19
- },
20
15
  "./schema": {
21
16
  "types": "./dist/schema.d.ts",
22
17
  "import": "./dist/schema.js",
@@ -24,16 +19,14 @@
24
19
  }
25
20
  },
26
21
  "dependencies": {
27
- "@objectstack/core": "7.4.1",
28
- "@objectstack/platform-objects": "7.4.1",
29
- "@objectstack/service-cluster": "7.4.1",
30
- "@objectstack/spec": "7.4.1",
31
- "@objectstack/types": "7.4.1"
22
+ "@objectstack/core": "7.6.0",
23
+ "@objectstack/service-messaging": "7.6.0",
24
+ "@objectstack/spec": "7.6.0"
32
25
  },
33
26
  "devDependencies": {
34
27
  "@types/node": "^25.9.1",
35
28
  "typescript": "^6.0.3",
36
- "vitest": "^4.1.7"
29
+ "vitest": "^4.1.8"
37
30
  },
38
31
  "keywords": [
39
32
  "objectstack",
@@ -3,18 +3,17 @@
3
3
  /**
4
4
  * AutoEnqueuer end-to-end test.
5
5
  *
6
- * Verifies that the bridge between `IRealtimeService` (data events) and
7
- * `IWebhookOutbox` (delivery rows) works as documented:
6
+ * Verifies the bridge between `IRealtimeService` (data events) and the shared
7
+ * `service-messaging` HTTP outbox (ADR-0018 M3 enqueue via `messaging.enqueueHttp`):
8
8
  *
9
9
  * - On startup, subscription rules are loaded from the engine.
10
10
  * - `data.record.created/updated/deleted` events fan out to matching
11
- * `sys_webhook` rows.
11
+ * `sys_webhook` rows, enqueued as `source: 'webhook'`.
12
12
  * - The `triggers` CSV column filters which actions fire.
13
13
  * - The `object_name` field scopes events to a specific object.
14
14
  * - Edits to `sys_webhook` self-heal the cache without restart.
15
15
  * - Enqueue is fire-and-forget (handler never throws or blocks).
16
- * - The deterministic eventId means two replays of the same event
17
- * produce one outbox row (dedup via the underlying outbox).
16
+ * - The deterministic dedupKey (`<webhookId>:<eventId>`) collapses replays.
18
17
  */
19
18
 
20
19
  import { describe, expect, it, vi } from 'vitest';
@@ -24,13 +23,31 @@ import type {
24
23
  RealtimeEventHandler,
25
24
  RealtimeEventPayload,
26
25
  } from '@objectstack/spec/contracts';
27
- import { AutoEnqueuer } from './auto-enqueuer.js';
28
- import { MemoryWebhookOutbox } from './memory-outbox.js';
26
+ import type { EnqueueHttpInput } from '@objectstack/service-messaging';
27
+ import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js';
29
28
 
30
29
  // ---------------------------------------------------------------------------
31
30
  // Fakes
32
31
  // ---------------------------------------------------------------------------
33
32
 
33
+ /**
34
+ * Records `enqueueHttp` calls and dedups on `(source, dedupKey)` — mirroring the
35
+ * shared outbox's UNIQUE constraint so the replay test still holds.
36
+ */
37
+ function makeRecorder() {
38
+ const calls: EnqueueHttpInput[] = [];
39
+ const seen = new Map<string, string>();
40
+ const enqueue: HttpEnqueueFn = async (input) => {
41
+ const key = `${input.source}::${input.dedupKey}`;
42
+ const existing = seen.get(key);
43
+ if (existing) return existing;
44
+ seen.set(key, key);
45
+ calls.push(input);
46
+ return key;
47
+ };
48
+ return { enqueue, calls };
49
+ }
50
+
34
51
  class FakeRealtime implements IRealtimeService {
35
52
  private subs = new Map<string, { handler: RealtimeEventHandler; opts?: any }>();
36
53
  private n = 0;
@@ -42,7 +59,7 @@ class FakeRealtime implements IRealtimeService {
42
59
  await sub.handler(event);
43
60
  }
44
61
  }
45
- async subscribe(channel: string, handler: any, opts?: any): Promise<string> {
62
+ async subscribe(_channel: string, handler: any, opts?: any): Promise<string> {
46
63
  const id = `s-${++this.n}`;
47
64
  this.subs.set(id, { handler, opts });
48
65
  return id;
@@ -62,9 +79,7 @@ class FakeEngine implements IDataEngine {
62
79
  async find(name: string, q?: any): Promise<any[]> {
63
80
  const all = this.rows[name] ?? [];
64
81
  if (!q?.where) return all;
65
- return all.filter((r) =>
66
- Object.entries(q.where).every(([k, v]) => r[k] === v),
67
- );
82
+ return all.filter((r) => Object.entries(q.where).every(([k, v]) => r[k] === v));
68
83
  }
69
84
  async findOne(name: string, q?: any): Promise<any> {
70
85
  return (await this.find(name, q))[0] ?? null;
@@ -77,10 +92,7 @@ class FakeEngine implements IDataEngine {
77
92
  async update(name: string, data: any, opts?: any): Promise<any> {
78
93
  const arr = this.rows[name] ?? [];
79
94
  for (const r of arr) {
80
- if (
81
- opts?.where &&
82
- Object.entries(opts.where).every(([k, v]) => r[k] === v)
83
- ) {
95
+ if (opts?.where && Object.entries(opts.where).every(([k, v]) => r[k] === v)) {
84
96
  Object.assign(r, data);
85
97
  }
86
98
  }
@@ -90,11 +102,7 @@ class FakeEngine implements IDataEngine {
90
102
  const arr = this.rows[name] ?? [];
91
103
  const before = arr.length;
92
104
  this.rows[name] = arr.filter(
93
- (r) =>
94
- !(
95
- opts?.where &&
96
- Object.entries(opts.where).every(([k, v]) => r[k] === v)
97
- ),
105
+ (r) => !(opts?.where && Object.entries(opts.where).every(([k, v]) => r[k] === v)),
98
106
  );
99
107
  return { affected: before - this.rows[name].length };
100
108
  }
@@ -139,7 +147,6 @@ function event(
139
147
  }
140
148
 
141
149
  async function flush() {
142
- // Let microtasks run — fire-and-forget enqueues return on next tick.
143
150
  await new Promise((r) => setTimeout(r, 0));
144
151
  }
145
152
 
@@ -151,48 +158,41 @@ describe('AutoEnqueuer', () => {
151
158
  it('enqueues a delivery when a matching data event fires', async () => {
152
159
  const engine = new FakeEngine({ sys_webhook: [webhook()] });
153
160
  const realtime = new FakeRealtime();
154
- const outbox = new MemoryWebhookOutbox();
155
- const ae = new AutoEnqueuer(engine, realtime, outbox, {
156
- refreshIntervalMs: 0,
157
- });
161
+ const { enqueue, calls } = makeRecorder();
162
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
158
163
  await ae.start();
159
164
 
160
165
  await realtime.publish(event('created', 'contact', { id: 'c-1', name: 'Alice' }));
161
166
  await flush();
162
167
 
163
- const rows = await outbox.list();
164
- expect(rows).toHaveLength(1);
165
- expect(rows[0].url).toBe('https://hooks.example/wh');
166
- expect(rows[0].eventType).toBe('data.record.created');
167
- expect((rows[0].payload as any).recordId).toBe('c-1');
168
+ expect(calls).toHaveLength(1);
169
+ expect(calls[0].source).toBe('webhook');
170
+ expect(calls[0].refId).toBe('wh-1');
171
+ expect(calls[0].url).toBe('https://hooks.example/wh');
172
+ expect(calls[0].label).toBe('data.record.created');
173
+ expect((calls[0].payload as any).recordId).toBe('c-1');
168
174
  await ae.stop();
169
175
  });
170
176
 
171
177
  it('skips events for other objects', async () => {
172
178
  const engine = new FakeEngine({ sys_webhook: [webhook({ object_name: 'contact' })] });
173
179
  const realtime = new FakeRealtime();
174
- const outbox = new MemoryWebhookOutbox();
175
- const ae = new AutoEnqueuer(engine, realtime, outbox, {
176
- refreshIntervalMs: 0,
177
- });
180
+ const { enqueue, calls } = makeRecorder();
181
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
178
182
  await ae.start();
179
183
 
180
184
  await realtime.publish(event('created', 'lead', { id: 'l-1' }));
181
185
  await flush();
182
186
 
183
- expect(await outbox.list()).toHaveLength(0);
187
+ expect(calls).toHaveLength(0);
184
188
  await ae.stop();
185
189
  });
186
190
 
187
191
  it('respects the triggers CSV (create-only webhook ignores updates)', async () => {
188
- const engine = new FakeEngine({
189
- sys_webhook: [webhook({ triggers: 'create' })],
190
- });
192
+ const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: 'create' })] });
191
193
  const realtime = new FakeRealtime();
192
- const outbox = new MemoryWebhookOutbox();
193
- const ae = new AutoEnqueuer(engine, realtime, outbox, {
194
- refreshIntervalMs: 0,
195
- });
194
+ const { enqueue, calls } = makeRecorder();
195
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
196
196
  await ae.start();
197
197
 
198
198
  await realtime.publish(event('created', 'contact', { id: 'c-1' }));
@@ -200,9 +200,8 @@ describe('AutoEnqueuer', () => {
200
200
  await realtime.publish(event('deleted', 'contact', { id: 'c-1' }, '2026-05-24T00:00:02.000Z'));
201
201
  await flush();
202
202
 
203
- const rows = await outbox.list();
204
- expect(rows).toHaveLength(1);
205
- expect(rows[0].eventType).toBe('data.record.created');
203
+ expect(calls).toHaveLength(1);
204
+ expect(calls[0].label).toBe('data.record.created');
206
205
  await ae.stop();
207
206
  });
208
207
 
@@ -214,18 +213,15 @@ describe('AutoEnqueuer', () => {
214
213
  ],
215
214
  });
216
215
  const realtime = new FakeRealtime();
217
- const outbox = new MemoryWebhookOutbox();
218
- const ae = new AutoEnqueuer(engine, realtime, outbox, {
219
- refreshIntervalMs: 0,
220
- });
216
+ const { enqueue, calls } = makeRecorder();
217
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
221
218
  await ae.start();
222
219
 
223
220
  await realtime.publish(event('created', 'contact', { id: 'c-1' }));
224
221
  await flush();
225
222
 
226
- const rows = await outbox.list();
227
- expect(rows).toHaveLength(2);
228
- expect(rows.map((r) => r.url).sort()).toEqual([
223
+ expect(calls).toHaveLength(2);
224
+ expect(calls.map((r) => r.url).sort()).toEqual([
229
225
  'https://amplitude.test',
230
226
  'https://slack.test',
231
227
  ]);
@@ -233,58 +229,44 @@ describe('AutoEnqueuer', () => {
233
229
  });
234
230
 
235
231
  it('skips inactive webhooks', async () => {
236
- const engine = new FakeEngine({
237
- sys_webhook: [webhook({ active: false })],
238
- });
232
+ const engine = new FakeEngine({ sys_webhook: [webhook({ active: false })] });
239
233
  const realtime = new FakeRealtime();
240
- const outbox = new MemoryWebhookOutbox();
241
- const ae = new AutoEnqueuer(engine, realtime, outbox, {
242
- refreshIntervalMs: 0,
243
- });
234
+ const { enqueue, calls } = makeRecorder();
235
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
244
236
  await ae.start();
245
237
 
246
238
  await realtime.publish(event('created', 'contact', { id: 'c-1' }));
247
239
  await flush();
248
240
 
249
- expect(await outbox.list()).toHaveLength(0);
241
+ expect(calls).toHaveLength(0);
250
242
  await ae.stop();
251
243
  });
252
244
 
253
245
  it('skips manual-only webhooks (no triggers)', async () => {
254
- const engine = new FakeEngine({
255
- sys_webhook: [webhook({ triggers: '' })],
256
- });
246
+ const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: '' })] });
257
247
  const realtime = new FakeRealtime();
258
- const outbox = new MemoryWebhookOutbox();
259
- const ae = new AutoEnqueuer(engine, realtime, outbox, {
260
- refreshIntervalMs: 0,
261
- });
248
+ const { enqueue, calls } = makeRecorder();
249
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
262
250
  await ae.start();
263
251
 
264
252
  await realtime.publish(event('created', 'contact', { id: 'c-1' }));
265
253
  await flush();
266
254
 
267
- expect(await outbox.list()).toHaveLength(0);
255
+ expect(calls).toHaveLength(0);
268
256
  await ae.stop();
269
257
  });
270
258
 
271
259
  it('self-heals the cache when sys_webhook changes', async () => {
272
- // Start with no webhooks; add one via the engine; the next event
273
- // should be enqueued without an explicit refresh() call.
274
260
  const engine = new FakeEngine({ sys_webhook: [] });
275
261
  const realtime = new FakeRealtime();
276
- const outbox = new MemoryWebhookOutbox();
277
- const ae = new AutoEnqueuer(engine, realtime, outbox, {
278
- refreshIntervalMs: 0,
279
- });
262
+ const { enqueue, calls } = makeRecorder();
263
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
280
264
  await ae.start();
281
265
 
282
266
  await realtime.publish(event('created', 'contact', { id: 'c-1' }));
283
267
  await flush();
284
- expect(await outbox.list()).toHaveLength(0);
268
+ expect(calls).toHaveLength(0);
285
269
 
286
- // Admin adds a webhook through the API and the engine publishes
287
- // a data.record.created event for sys_webhook itself.
288
270
  await engine.insert('sys_webhook', webhook());
289
271
  await realtime.publish({
290
272
  type: 'data.record.created',
@@ -295,56 +277,45 @@ describe('AutoEnqueuer', () => {
295
277
  await flush();
296
278
  await flush(); // Two ticks: the self-heal handler itself awaits refresh
297
279
 
298
- await realtime.publish(
299
- event('created', 'contact', { id: 'c-2' }, '2026-05-24T00:01:01.000Z'),
300
- );
280
+ await realtime.publish(event('created', 'contact', { id: 'c-2' }, '2026-05-24T00:01:01.000Z'));
301
281
  await flush();
302
282
 
303
- const rows = await outbox.list();
304
- expect(rows).toHaveLength(1);
305
- expect((rows[0].payload as any).recordId).toBe('c-2');
283
+ expect(calls).toHaveLength(1);
284
+ expect((calls[0].payload as any).recordId).toBe('c-2');
306
285
  await ae.stop();
307
286
  });
308
287
 
309
- it('uses deterministic eventId so dedup catches replays', async () => {
288
+ it('uses a deterministic dedupKey so replays collapse', async () => {
310
289
  const engine = new FakeEngine({ sys_webhook: [webhook()] });
311
290
  const realtime = new FakeRealtime();
312
- const outbox = new MemoryWebhookOutbox();
313
- const ae = new AutoEnqueuer(engine, realtime, outbox, {
314
- refreshIntervalMs: 0,
315
- });
291
+ const { enqueue, calls } = makeRecorder();
292
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
316
293
  await ae.start();
317
294
 
318
- // Publish identical event twice — outbox dedup must collapse.
319
295
  const evt = event('created', 'contact', { id: 'c-1' });
320
296
  await realtime.publish(evt);
321
297
  await realtime.publish(evt);
322
298
  await flush();
323
299
 
324
- expect(await outbox.list()).toHaveLength(1);
300
+ expect(calls).toHaveLength(1);
325
301
  await ae.stop();
326
302
  });
327
303
 
328
304
  it('handler is fire-and-forget (publish does not block on enqueue)', async () => {
329
305
  const engine = new FakeEngine({ sys_webhook: [webhook()] });
330
306
  const realtime = new FakeRealtime();
331
- const outbox = new MemoryWebhookOutbox();
332
307
  let slowResolve!: () => void;
333
308
  const blocker = new Promise<void>((res) => {
334
309
  slowResolve = res;
335
310
  });
336
-
337
- // Wrap outbox to make enqueue slow.
338
- const slow: typeof outbox = Object.assign(outbox, {
339
- enqueue: async (...args: Parameters<typeof outbox.enqueue>) => {
340
- await blocker;
341
- return MemoryWebhookOutbox.prototype.enqueue.apply(outbox, args);
342
- },
343
- });
344
-
345
- const ae = new AutoEnqueuer(engine, realtime, slow, {
346
- refreshIntervalMs: 0,
347
- });
311
+ const calls: EnqueueHttpInput[] = [];
312
+ const enqueue: HttpEnqueueFn = async (input) => {
313
+ await blocker;
314
+ calls.push(input);
315
+ return 'id';
316
+ };
317
+
318
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
348
319
  await ae.start();
349
320
 
350
321
  const before = Date.now();
@@ -354,7 +325,7 @@ describe('AutoEnqueuer', () => {
354
325
 
355
326
  slowResolve();
356
327
  await flush();
357
- expect(await outbox.list()).toHaveLength(1);
328
+ expect(calls).toHaveLength(1);
358
329
  await ae.stop();
359
330
  });
360
331
 
@@ -366,25 +337,21 @@ describe('AutoEnqueuer', () => {
366
337
  ],
367
338
  });
368
339
  const realtime = new FakeRealtime();
369
- const outbox = new MemoryWebhookOutbox();
370
- const orig = outbox.enqueue.bind(outbox);
371
- outbox.enqueue = vi.fn(async (input) => {
372
- if (input.webhookId === 'wh-bad') throw new Error('boom');
373
- return orig(input);
340
+ const calls: EnqueueHttpInput[] = [];
341
+ const enqueue: HttpEnqueueFn = vi.fn(async (input) => {
342
+ if (input.refId === 'wh-bad') throw new Error('boom');
343
+ calls.push(input);
344
+ return 'id';
374
345
  });
375
346
  const warn = vi.fn();
376
- const ae = new AutoEnqueuer(engine, realtime, outbox, {
377
- refreshIntervalMs: 0,
378
- logger: { warn },
379
- });
347
+ const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0, logger: { warn } });
380
348
  await ae.start();
381
349
 
382
350
  await realtime.publish(event('created', 'contact', { id: 'c-1' }));
383
351
  await flush();
384
352
 
385
- const rows = await outbox.list();
386
- expect(rows).toHaveLength(1);
387
- expect(rows[0].url).toBe('https://good.test');
353
+ expect(calls).toHaveLength(1);
354
+ expect(calls[0].url).toBe('https://good.test');
388
355
  expect(warn).toHaveBeenCalled();
389
356
  await ae.stop();
390
357
  });
@@ -1,7 +1,14 @@
1
1
  // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';
4
- import type { IWebhookOutbox } from './outbox.js';
4
+ import type { EnqueueHttpInput } from '@objectstack/service-messaging';
5
+
6
+ /**
7
+ * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).
8
+ * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no
9
+ * longer own a delivery outbox/dispatcher — they share the generic substrate.
10
+ */
11
+ export type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;
5
12
 
6
13
  /**
7
14
  * Optional logger interface (subset of console / kernel logger).
@@ -98,7 +105,7 @@ export class AutoEnqueuer {
98
105
  constructor(
99
106
  private readonly engine: IDataEngine,
100
107
  private readonly realtime: IRealtimeService,
101
- private readonly outbox: IWebhookOutbox,
108
+ private readonly enqueue: HttpEnqueueFn,
102
109
  opts: AutoEnqueuerOptions = {},
103
110
  ) {
104
111
  this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';
@@ -274,32 +281,36 @@ export class AutoEnqueuer {
274
281
  for (const sub of subs) {
275
282
  if (!sub.triggers.has(trigger)) continue;
276
283
 
277
- // Fire-and-forget — never await on the hot path.
278
- void this.outbox
279
- .enqueue({
280
- webhookId: sub.id,
284
+ // Fire-and-forget — never await on the hot path. Map the webhook
285
+ // delivery onto the generic HTTP-outbox shape (ADR-0018 M3):
286
+ // - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves
287
+ // the old (event_id, webhook_id) at-most-once enqueue;
288
+ // - refId = webhookId keeps per-webhook partition affinity / ordering;
289
+ // - label = event type → X-Objectstack-Event header.
290
+ void this.enqueue({
291
+ source: 'webhook',
292
+ refId: sub.id,
293
+ dedupKey: `${sub.id}:${eventId}`,
294
+ label: event.type,
295
+ url: sub.url,
296
+ method: sub.method,
297
+ headers: sub.headers,
298
+ signingSecret: sub.secret,
299
+ timeoutMs: sub.timeoutMs,
300
+ payload: {
301
+ object: event.object,
302
+ recordId,
303
+ action,
304
+ timestamp: event.timestamp,
305
+ ...payload,
306
+ },
307
+ }).catch((err) =>
308
+ this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {
309
+ webhook: sub.name,
281
310
  eventId,
282
- eventType: event.type,
283
- url: sub.url,
284
- method: sub.method,
285
- headers: sub.headers,
286
- secret: sub.secret,
287
- timeoutMs: sub.timeoutMs,
288
- payload: {
289
- object: event.object,
290
- recordId,
291
- action,
292
- timestamp: event.timestamp,
293
- ...payload,
294
- },
295
- })
296
- .catch((err) =>
297
- this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {
298
- webhook: sub.name,
299
- eventId,
300
- err: (err as Error)?.message ?? err,
301
- }),
302
- );
311
+ err: (err as Error)?.message ?? err,
312
+ }),
313
+ );
303
314
  }
304
315
  }
305
316
 
package/src/index.ts CHANGED
@@ -3,23 +3,21 @@
3
3
  /**
4
4
  * @objectstack/plugin-webhooks
5
5
  *
6
- * Persistent, cluster-aware webhook outbox + dispatcher.
6
+ * Webhook fan-out on top of the shared outbound-HTTP delivery substrate
7
+ * (ADR-0018 M3). The durable outbox, cluster-coordinated dispatcher, retry /
8
+ * backoff / dead-letter, and retention all live in
9
+ * `@objectstack/service-messaging` (`sys_http_delivery` + `HttpDispatcher`).
7
10
  *
8
- * Implements stages 3–5 of the pipeline in
9
- * `content/docs/concepts/webhook-delivery.mdx` (Persist · Dispatch ·
10
- * Retry). Stages 1–2 (Event capture · Match) integrate via the
11
- * `webhook.outbox.enqueue()` service consumers call after persistence.
11
+ * This package ships only the webhook-specific concerns:
12
+ * - the `sys_webhook` configuration object,
13
+ * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox
14
+ * rows (`source: 'webhook'`),
15
+ * - the redeliver admin endpoint.
12
16
  *
13
- * The first real cross-node consumer of `cluster.lock`.
17
+ * **Requires** `MessagingServicePlugin` (a foundational, always-on capability).
14
18
  *
15
19
  * ## Subpath exports
16
- * - `@objectstack/plugin-webhooks/sql` — `SqlWebhookOutbox` (production
17
- * storage; durable rows via ObjectQL / any driver)
18
- * - `@objectstack/plugin-webhooks/schema` — `SysWebhookDelivery` object
19
- * schema to register in `defineStack({ objects: [...] })`
20
- *
21
- * The main entry intentionally ships only the `MemoryWebhookOutbox` so
22
- * downstream bundles don't pay for the SQL impl unless they import it.
20
+ * - `@objectstack/plugin-webhooks/schema` — `SysWebhook` object schema.
23
21
  */
24
22
 
25
23
  export {
@@ -27,30 +25,5 @@ export {
27
25
  type WebhookOutboxPluginOptions,
28
26
  } from './webhook-outbox-plugin.js';
29
27
 
30
- export { WebhookDispatcher, type DispatcherOptions } from './dispatcher.js';
31
- export { MemoryWebhookOutbox } from './memory-outbox.js';
32
- export { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';
33
- export {
34
- DeliveryRetentionSweeper,
35
- type DeliveryRetentionOptions,
36
- } from './retention.js';
37
- export { hashPartition } from './partition.js';
38
- export {
39
- sendOnce,
40
- classifyAttempt,
41
- nextRetryDelayMs,
42
- DEFAULT_TIMEOUT_MS,
43
- type AttemptOutcome,
44
- type FetchImpl,
45
- } from './http-sender.js';
46
- export type {
47
- AckFailure,
48
- AckResult,
49
- AckSuccess,
50
- ClaimOptions,
51
- DeliveryStatus,
52
- EnqueueInput,
53
- IWebhookOutbox,
54
- WebhookDelivery,
55
- } from './outbox.js';
56
- export { RedeliverError } from './outbox.js';
28
+ export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn } from './auto-enqueuer.js';
29
+ export { SysWebhook } from './sys-webhook.object.js';
package/src/schema.ts CHANGED
@@ -3,24 +3,19 @@
3
3
  /**
4
4
  * Public schema subpath: `@objectstack/plugin-webhooks/schema`.
5
5
  *
6
- * Thin re-export barrel kept stable across refactors. The actual object
7
- * definitions live in `sys-webhook.object.ts` and
8
- * `sys-webhook-delivery.object.ts` (matching the `*.object.ts` convention
9
- * used everywhere else in the monorepo for `sys_*` schemas).
6
+ * Thin re-export barrel kept stable across refactors. The object definition
7
+ * lives in `sys-webhook.object.ts` (matching the `*.object.ts` convention used
8
+ * everywhere else in the monorepo for `sys_*` schemas).
10
9
  *
11
- * `sys_webhook` moved here from `@objectstack/platform-objects` per
12
- * ADR-0029 (K2.a) so this plugin owns both of its objects.
10
+ * `sys_webhook` moved here from `@objectstack/platform-objects` per ADR-0029
11
+ * (K2.a) so this plugin owns its configuration object. Delivery telemetry is no
12
+ * longer a webhook-owned object: post-ADR-0018 M3 deliveries are rows in the
13
+ * shared `sys_http_delivery` outbox owned by `@objectstack/service-messaging`.
13
14
  *
14
- * Note: callers that just need the runtime should import from the
15
- * package root (`@objectstack/plugin-webhooks`), which auto-registers
16
- * `sys_webhook` + `sys_webhook_delivery` via the plugin manifest. This
17
- * subpath exists for the rare case where you want the schema without
18
- * installing the dispatcher plugin (e.g. read-only inspection from a
19
- * different runtime).
15
+ * Note: callers that just need the runtime should import from the package root
16
+ * (`@objectstack/plugin-webhooks`), which auto-registers `sys_webhook` via the
17
+ * plugin manifest. This subpath exists for read-only inspection from a
18
+ * different runtime.
20
19
  */
21
20
 
22
21
  export { SysWebhook } from './sys-webhook.object.js';
23
- export {
24
- SysWebhookDelivery,
25
- SYS_WEBHOOK_DELIVERY,
26
- } from './sys-webhook-delivery.object.js';