@objectstack/plugin-webhooks 7.5.0 → 7.7.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.
- package/.turbo/turbo-build.log +20 -32
- package/CHANGELOG.md +60 -0
- package/dist/chunk-HWFTXTTI.js +138 -0
- package/dist/chunk-HWFTXTTI.js.map +1 -0
- package/dist/chunk-KPKLAXNA.cjs +138 -0
- package/dist/chunk-KPKLAXNA.cjs.map +1 -0
- package/dist/index.cjs +62 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -325
- package/dist/index.d.ts +41 -325
- package/dist/index.js +52 -606
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +2 -6
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +5 -4764
- package/dist/schema.d.ts +5 -4764
- package/dist/schema.js +3 -7
- package/package.json +4 -11
- package/src/auto-enqueuer.test.ts +83 -116
- package/src/auto-enqueuer.ts +38 -27
- package/src/index.ts +13 -40
- package/src/schema.ts +11 -16
- package/src/webhook-outbox-plugin.ts +80 -296
- package/tsup.config.ts +1 -1
- package/dist/chunk-7HS5DLU2.js +0 -319
- package/dist/chunk-7HS5DLU2.js.map +0 -1
- package/dist/chunk-HF7CCDPB.cjs +0 -256
- package/dist/chunk-HF7CCDPB.cjs.map +0 -1
- package/dist/chunk-KNGLLSSP.js +0 -256
- package/dist/chunk-KNGLLSSP.js.map +0 -1
- package/dist/chunk-TDSI7UHY.cjs +0 -319
- package/dist/chunk-TDSI7UHY.cjs.map +0 -1
- package/dist/outbox-CIn7LSyB.d.cts +0 -155
- package/dist/outbox-CIn7LSyB.d.ts +0 -155
- package/dist/sql-outbox.cjs +0 -8
- package/dist/sql-outbox.cjs.map +0 -1
- package/dist/sql-outbox.d.cts +0 -55
- package/dist/sql-outbox.d.ts +0 -55
- package/dist/sql-outbox.js +0 -8
- package/dist/sql-outbox.js.map +0 -1
- package/src/dispatcher.test.ts +0 -324
- package/src/dispatcher.ts +0 -218
- package/src/http-sender.ts +0 -187
- package/src/memory-outbox.test.ts +0 -86
- package/src/memory-outbox.ts +0 -155
- package/src/outbox.ts +0 -175
- package/src/partition.ts +0 -19
- package/src/retention.test.ts +0 -116
- package/src/retention.ts +0 -144
- package/src/sql-outbox.test.ts +0 -490
- package/src/sql-outbox.ts +0 -343
- package/src/sys-webhook-delivery.object.ts +0 -224
package/dist/schema.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
SysWebhookDelivery
|
|
5
|
-
} from "./chunk-7HS5DLU2.js";
|
|
2
|
+
SysWebhook
|
|
3
|
+
} from "./chunk-HWFTXTTI.js";
|
|
6
4
|
export {
|
|
7
|
-
|
|
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.
|
|
3
|
+
"version": "7.7.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,11 +19,9 @@
|
|
|
24
19
|
}
|
|
25
20
|
},
|
|
26
21
|
"dependencies": {
|
|
27
|
-
"@objectstack/core": "7.
|
|
28
|
-
"@objectstack/
|
|
29
|
-
"@objectstack/
|
|
30
|
-
"@objectstack/spec": "7.5.0",
|
|
31
|
-
"@objectstack/types": "7.5.0"
|
|
22
|
+
"@objectstack/core": "7.7.0",
|
|
23
|
+
"@objectstack/service-messaging": "7.7.0",
|
|
24
|
+
"@objectstack/spec": "7.7.0"
|
|
32
25
|
},
|
|
33
26
|
"devDependencies": {
|
|
34
27
|
"@types/node": "^25.9.1",
|
|
@@ -3,18 +3,17 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* AutoEnqueuer end-to-end test.
|
|
5
5
|
*
|
|
6
|
-
* Verifies
|
|
7
|
-
* `
|
|
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
|
|
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 {
|
|
28
|
-
import {
|
|
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(
|
|
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
|
|
155
|
-
const ae = new AutoEnqueuer(engine, realtime,
|
|
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
|
-
|
|
164
|
-
expect(
|
|
165
|
-
expect(
|
|
166
|
-
expect(
|
|
167
|
-
expect(
|
|
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
|
|
175
|
-
const ae = new AutoEnqueuer(engine, realtime,
|
|
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(
|
|
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
|
|
193
|
-
const ae = new AutoEnqueuer(engine, realtime,
|
|
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
|
-
|
|
204
|
-
expect(
|
|
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
|
|
218
|
-
const ae = new AutoEnqueuer(engine, realtime,
|
|
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
|
-
|
|
227
|
-
expect(
|
|
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
|
|
241
|
-
const ae = new AutoEnqueuer(engine, realtime,
|
|
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(
|
|
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
|
|
259
|
-
const ae = new AutoEnqueuer(engine, realtime,
|
|
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(
|
|
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
|
|
277
|
-
const ae = new AutoEnqueuer(engine, realtime,
|
|
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(
|
|
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
|
-
|
|
304
|
-
expect(
|
|
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
|
|
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
|
|
313
|
-
const ae = new AutoEnqueuer(engine, realtime,
|
|
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(
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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(
|
|
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
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return
|
|
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,
|
|
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
|
-
|
|
386
|
-
expect(
|
|
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
|
});
|
package/src/auto-enqueuer.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
* `webhook
|
|
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
|
-
*
|
|
17
|
+
* **Requires** `MessagingServicePlugin` (a foundational, always-on capability).
|
|
14
18
|
*
|
|
15
19
|
* ## Subpath exports
|
|
16
|
-
* - `@objectstack/plugin-webhooks/
|
|
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 {
|
|
31
|
-
export {
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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';
|