@objectstack/plugin-webhooks 5.1.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +35 -13
  2. package/CHANGELOG.md +17 -33
  3. package/dist/chunk-33LYZT7O.js +184 -0
  4. package/dist/chunk-33LYZT7O.js.map +1 -0
  5. package/dist/chunk-BS2QTZH3.js +256 -0
  6. package/dist/chunk-BS2QTZH3.js.map +1 -0
  7. package/dist/chunk-FA66GQEO.cjs +256 -0
  8. package/dist/chunk-FA66GQEO.cjs.map +1 -0
  9. package/dist/chunk-MJZGD37S.cjs +184 -0
  10. package/dist/chunk-MJZGD37S.cjs.map +1 -0
  11. package/dist/index.cjs +908 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +469 -0
  14. package/dist/index.d.ts +435 -70
  15. package/dist/index.js +872 -217
  16. package/dist/index.js.map +1 -1
  17. package/dist/outbox-CIn7LSyB.d.cts +155 -0
  18. package/dist/outbox-CIn7LSyB.d.ts +155 -0
  19. package/dist/schema.cjs +9 -0
  20. package/dist/schema.cjs.map +1 -0
  21. package/dist/schema.d.cts +4787 -0
  22. package/dist/schema.d.ts +4787 -0
  23. package/dist/schema.js +9 -0
  24. package/dist/schema.js.map +1 -0
  25. package/dist/sql-outbox.cjs +8 -0
  26. package/dist/sql-outbox.cjs.map +1 -0
  27. package/dist/sql-outbox.d.cts +55 -0
  28. package/dist/sql-outbox.d.ts +55 -0
  29. package/dist/sql-outbox.js +8 -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 +49 -12
  38. package/src/memory-outbox.test.ts +86 -0
  39. package/src/memory-outbox.ts +155 -0
  40. package/src/outbox.ts +175 -0
  41. package/src/partition.ts +19 -0
  42. package/src/retention.test.ts +116 -0
  43. package/src/retention.ts +144 -0
  44. package/src/schema.ts +22 -0
  45. package/src/sql-outbox.test.ts +490 -0
  46. package/src/sql-outbox.ts +343 -0
  47. package/src/sys-webhook-delivery.object.ts +224 -0
  48. package/src/webhook-outbox-plugin.ts +442 -0
  49. package/tsconfig.json +5 -13
  50. package/tsup.config.ts +14 -0
  51. package/dist/index.d.mts +0 -104
  52. package/dist/index.mjs +0 -216
  53. package/dist/index.mjs.map +0 -1
  54. package/src/webhooks-plugin.test.ts +0 -218
  55. package/src/webhooks-plugin.ts +0 -294
package/dist/schema.js ADDED
@@ -0,0 +1,9 @@
1
+ import {
2
+ SYS_WEBHOOK_DELIVERY,
3
+ SysWebhookDelivery
4
+ } from "./chunk-33LYZT7O.js";
5
+ export {
6
+ SYS_WEBHOOK_DELIVERY,
7
+ SysWebhookDelivery
8
+ };
9
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,8 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
+
3
+ var _chunkFA66GQEOcjs = require('./chunk-FA66GQEO.cjs');
4
+ require('./chunk-MJZGD37S.cjs');
5
+
6
+
7
+ exports.SqlWebhookOutbox = _chunkFA66GQEOcjs.SqlWebhookOutbox;
8
+ //# sourceMappingURL=sql-outbox.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/sql-outbox.cjs"],"names":[],"mappings":"AAAA;AACE;AACF,wDAA6B;AAC7B,gCAA6B;AAC7B;AACE;AACF,8DAAC","file":"/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/sql-outbox.cjs"}
@@ -0,0 +1,55 @@
1
+ import { IDataEngine } from '@objectstack/spec/contracts';
2
+ import { I as IWebhookOutbox, E as EnqueueInput, C as ClaimOptions, W as WebhookDelivery, a as AckResult, D as DeliveryStatus } from './outbox-CIn7LSyB.cjs';
3
+
4
+ interface SqlWebhookOutboxOptions {
5
+ /**
6
+ * Total partition count — MUST match the dispatcher's `partitionCount`.
7
+ * Used at enqueue time to precompute `partition_key`.
8
+ */
9
+ partitionCount: number;
10
+ /**
11
+ * Object name to read/write. Defaults to `sys_webhook_delivery`. Override
12
+ * only if you've registered the schema under a different name.
13
+ */
14
+ objectName?: string;
15
+ }
16
+ /**
17
+ * Durable `IWebhookOutbox` backed by ObjectQL — the production storage
18
+ * impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)
19
+ * because everything goes through the driver-agnostic `IDataEngine` API.
20
+ *
21
+ * **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that
22
+ * SQL feature is Postgres-only. We get equivalent safety from two layers:
23
+ *
24
+ * 1. `cluster.lock` held per partition by the dispatcher (the primary
25
+ * mutex). One node owns one partition at a time → no two claimers.
26
+ * 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two
27
+ * claimers slip through (e.g. admin reschedule + dispatcher), only
28
+ * the first UPDATE matches each row.
29
+ *
30
+ * **Why precompute `partition_key` on enqueue?** ObjectQL has no
31
+ * cross-driver `hash()` function in WHERE clauses. Storing the partition
32
+ * as a column makes the claim query a plain indexed lookup.
33
+ *
34
+ * **Dedup race**: SELECT-then-INSERT has a tiny window where two
35
+ * concurrent producers both miss the SELECT and both INSERT. The unique
36
+ * index `(event_id, webhook_id)` on the table catches it — the second
37
+ * INSERT errors, the producer ignores it. Receivers MUST be idempotent
38
+ * on the `X-Objectstack-Delivery` header anyway.
39
+ */
40
+ declare class SqlWebhookOutbox implements IWebhookOutbox {
41
+ private readonly engine;
42
+ private readonly objectName;
43
+ private readonly partitionCount;
44
+ constructor(engine: IDataEngine, opts: SqlWebhookOutboxOptions);
45
+ enqueue(input: EnqueueInput): Promise<string>;
46
+ claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
47
+ ack(id: string, result: AckResult): Promise<void>;
48
+ list(filter?: {
49
+ status?: DeliveryStatus;
50
+ }): Promise<WebhookDelivery[]>;
51
+ redeliver(id: string): Promise<WebhookDelivery>;
52
+ private toDelivery;
53
+ }
54
+
55
+ export { SqlWebhookOutbox, type SqlWebhookOutboxOptions };
@@ -0,0 +1,55 @@
1
+ import { IDataEngine } from '@objectstack/spec/contracts';
2
+ import { I as IWebhookOutbox, E as EnqueueInput, C as ClaimOptions, W as WebhookDelivery, a as AckResult, D as DeliveryStatus } from './outbox-CIn7LSyB.js';
3
+
4
+ interface SqlWebhookOutboxOptions {
5
+ /**
6
+ * Total partition count — MUST match the dispatcher's `partitionCount`.
7
+ * Used at enqueue time to precompute `partition_key`.
8
+ */
9
+ partitionCount: number;
10
+ /**
11
+ * Object name to read/write. Defaults to `sys_webhook_delivery`. Override
12
+ * only if you've registered the schema under a different name.
13
+ */
14
+ objectName?: string;
15
+ }
16
+ /**
17
+ * Durable `IWebhookOutbox` backed by ObjectQL — the production storage
18
+ * impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)
19
+ * because everything goes through the driver-agnostic `IDataEngine` API.
20
+ *
21
+ * **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that
22
+ * SQL feature is Postgres-only. We get equivalent safety from two layers:
23
+ *
24
+ * 1. `cluster.lock` held per partition by the dispatcher (the primary
25
+ * mutex). One node owns one partition at a time → no two claimers.
26
+ * 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two
27
+ * claimers slip through (e.g. admin reschedule + dispatcher), only
28
+ * the first UPDATE matches each row.
29
+ *
30
+ * **Why precompute `partition_key` on enqueue?** ObjectQL has no
31
+ * cross-driver `hash()` function in WHERE clauses. Storing the partition
32
+ * as a column makes the claim query a plain indexed lookup.
33
+ *
34
+ * **Dedup race**: SELECT-then-INSERT has a tiny window where two
35
+ * concurrent producers both miss the SELECT and both INSERT. The unique
36
+ * index `(event_id, webhook_id)` on the table catches it — the second
37
+ * INSERT errors, the producer ignores it. Receivers MUST be idempotent
38
+ * on the `X-Objectstack-Delivery` header anyway.
39
+ */
40
+ declare class SqlWebhookOutbox implements IWebhookOutbox {
41
+ private readonly engine;
42
+ private readonly objectName;
43
+ private readonly partitionCount;
44
+ constructor(engine: IDataEngine, opts: SqlWebhookOutboxOptions);
45
+ enqueue(input: EnqueueInput): Promise<string>;
46
+ claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
47
+ ack(id: string, result: AckResult): Promise<void>;
48
+ list(filter?: {
49
+ status?: DeliveryStatus;
50
+ }): Promise<WebhookDelivery[]>;
51
+ redeliver(id: string): Promise<WebhookDelivery>;
52
+ private toDelivery;
53
+ }
54
+
55
+ export { SqlWebhookOutbox, type SqlWebhookOutboxOptions };
@@ -0,0 +1,8 @@
1
+ import {
2
+ SqlWebhookOutbox
3
+ } from "./chunk-BS2QTZH3.js";
4
+ import "./chunk-33LYZT7O.js";
5
+ export {
6
+ SqlWebhookOutbox
7
+ };
8
+ //# sourceMappingURL=sql-outbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json CHANGED
@@ -1,20 +1,33 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-webhooks",
3
- "version": "5.1.0",
3
+ "version": "6.0.0",
4
4
  "license": "Apache-2.0",
5
- "description": "Outbound webhook delivery plugin for ObjectStack — fan-out data.record.* events to external HTTP(S) sinks with HMAC signing and retry.",
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
+ "type": "module",
6
7
  "main": "dist/index.js",
7
8
  "types": "dist/index.d.ts",
8
9
  "exports": {
9
10
  ".": {
10
11
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.mjs",
12
- "require": "./dist/index.js"
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./sql": {
16
+ "types": "./dist/sql-outbox.d.ts",
17
+ "import": "./dist/sql-outbox.js",
18
+ "require": "./dist/sql-outbox.cjs"
19
+ },
20
+ "./schema": {
21
+ "types": "./dist/schema.d.ts",
22
+ "import": "./dist/schema.js",
23
+ "require": "./dist/schema.cjs"
13
24
  }
14
25
  },
15
26
  "dependencies": {
16
- "@objectstack/core": "5.1.0",
17
- "@objectstack/spec": "5.1.0"
27
+ "@objectstack/core": "6.0.0",
28
+ "@objectstack/platform-objects": "6.0.0",
29
+ "@objectstack/spec": "6.0.0",
30
+ "@objectstack/service-cluster": "5.1.2"
18
31
  },
19
32
  "devDependencies": {
20
33
  "@types/node": "^25.9.1",
@@ -24,11 +37,18 @@
24
37
  "keywords": [
25
38
  "objectstack",
26
39
  "plugin",
27
- "webhooks",
28
- "events"
40
+ "webhook",
41
+ "outbox",
42
+ "cluster"
29
43
  ],
44
+ "author": "ObjectStack",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/objectstack-ai/framework.git",
48
+ "directory": "packages/plugins/plugin-webhooks"
49
+ },
30
50
  "scripts": {
31
- "build": "tsup --config ../../../tsup.config.ts",
32
- "test": "vitest run --passWithNoTests"
51
+ "build": "tsup",
52
+ "test": "vitest run"
33
53
  }
34
54
  }
@@ -0,0 +1,391 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * AutoEnqueuer end-to-end test.
5
+ *
6
+ * Verifies that the bridge between `IRealtimeService` (data events) and
7
+ * `IWebhookOutbox` (delivery rows) works as documented:
8
+ *
9
+ * - On startup, subscription rules are loaded from the engine.
10
+ * - `data.record.created/updated/deleted` events fan out to matching
11
+ * `sys_webhook` rows.
12
+ * - The `triggers` CSV column filters which actions fire.
13
+ * - The `object_name` field scopes events to a specific object.
14
+ * - Edits to `sys_webhook` self-heal the cache without restart.
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).
18
+ */
19
+
20
+ import { describe, expect, it, vi } from 'vitest';
21
+ import type {
22
+ IDataEngine,
23
+ IRealtimeService,
24
+ RealtimeEventHandler,
25
+ RealtimeEventPayload,
26
+ } from '@objectstack/spec/contracts';
27
+ import { AutoEnqueuer } from './auto-enqueuer.js';
28
+ import { MemoryWebhookOutbox } from './memory-outbox.js';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Fakes
32
+ // ---------------------------------------------------------------------------
33
+
34
+ class FakeRealtime implements IRealtimeService {
35
+ private subs = new Map<string, { handler: RealtimeEventHandler; opts?: any }>();
36
+ private n = 0;
37
+
38
+ async publish(event: RealtimeEventPayload): Promise<void> {
39
+ for (const sub of this.subs.values()) {
40
+ const o = sub.opts ?? {};
41
+ if (o.object && event.object !== o.object) continue;
42
+ await sub.handler(event);
43
+ }
44
+ }
45
+ async subscribe(channel: string, handler: any, opts?: any): Promise<string> {
46
+ const id = `s-${++this.n}`;
47
+ this.subs.set(id, { handler, opts });
48
+ return id;
49
+ }
50
+ async unsubscribe(id: string): Promise<void> {
51
+ this.subs.delete(id);
52
+ }
53
+ }
54
+
55
+ class FakeEngine implements IDataEngine {
56
+ rows: Record<string, any[]> = {};
57
+
58
+ constructor(seed?: Record<string, any[]>) {
59
+ if (seed) this.rows = JSON.parse(JSON.stringify(seed));
60
+ }
61
+
62
+ async find(name: string, q?: any): Promise<any[]> {
63
+ const all = this.rows[name] ?? [];
64
+ if (!q?.where) return all;
65
+ return all.filter((r) =>
66
+ Object.entries(q.where).every(([k, v]) => r[k] === v),
67
+ );
68
+ }
69
+ async findOne(name: string, q?: any): Promise<any> {
70
+ return (await this.find(name, q))[0] ?? null;
71
+ }
72
+ async insert(name: string, data: any): Promise<any> {
73
+ const arr = (this.rows[name] = this.rows[name] ?? []);
74
+ arr.push(data);
75
+ return data;
76
+ }
77
+ async update(name: string, data: any, opts?: any): Promise<any> {
78
+ const arr = this.rows[name] ?? [];
79
+ for (const r of arr) {
80
+ if (
81
+ opts?.where &&
82
+ Object.entries(opts.where).every(([k, v]) => r[k] === v)
83
+ ) {
84
+ Object.assign(r, data);
85
+ }
86
+ }
87
+ return { affected: 0 };
88
+ }
89
+ async delete(name: string, opts?: any): Promise<any> {
90
+ const arr = this.rows[name] ?? [];
91
+ const before = arr.length;
92
+ this.rows[name] = arr.filter(
93
+ (r) =>
94
+ !(
95
+ opts?.where &&
96
+ Object.entries(opts.where).every(([k, v]) => r[k] === v)
97
+ ),
98
+ );
99
+ return { affected: before - this.rows[name].length };
100
+ }
101
+ async count(name: string): Promise<number> {
102
+ return (this.rows[name] ?? []).length;
103
+ }
104
+ async aggregate(): Promise<any[]> {
105
+ return [];
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Helpers
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function webhook(over: Partial<any> = {}): any {
114
+ return {
115
+ id: over.id ?? 'wh-1',
116
+ name: over.name ?? 'default',
117
+ active: over.active ?? true,
118
+ object_name: over.object_name ?? 'contact',
119
+ triggers: over.triggers ?? 'create,update,delete',
120
+ url: over.url ?? 'https://hooks.example/wh',
121
+ method: 'POST',
122
+ definition_json: over.definition_json,
123
+ ...over,
124
+ };
125
+ }
126
+
127
+ function event(
128
+ type: 'created' | 'updated' | 'deleted',
129
+ object: string,
130
+ record: any,
131
+ timestamp = '2026-05-24T00:00:00.000Z',
132
+ ): RealtimeEventPayload {
133
+ return {
134
+ type: `data.record.${type}`,
135
+ object,
136
+ payload: { recordId: record.id, after: record },
137
+ timestamp,
138
+ };
139
+ }
140
+
141
+ async function flush() {
142
+ // Let microtasks run — fire-and-forget enqueues return on next tick.
143
+ await new Promise((r) => setTimeout(r, 0));
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Tests
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe('AutoEnqueuer', () => {
151
+ it('enqueues a delivery when a matching data event fires', async () => {
152
+ const engine = new FakeEngine({ sys_webhook: [webhook()] });
153
+ const realtime = new FakeRealtime();
154
+ const outbox = new MemoryWebhookOutbox();
155
+ const ae = new AutoEnqueuer(engine, realtime, outbox, {
156
+ refreshIntervalMs: 0,
157
+ });
158
+ await ae.start();
159
+
160
+ await realtime.publish(event('created', 'contact', { id: 'c-1', name: 'Alice' }));
161
+ await flush();
162
+
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
+ await ae.stop();
169
+ });
170
+
171
+ it('skips events for other objects', async () => {
172
+ const engine = new FakeEngine({ sys_webhook: [webhook({ object_name: 'contact' })] });
173
+ const realtime = new FakeRealtime();
174
+ const outbox = new MemoryWebhookOutbox();
175
+ const ae = new AutoEnqueuer(engine, realtime, outbox, {
176
+ refreshIntervalMs: 0,
177
+ });
178
+ await ae.start();
179
+
180
+ await realtime.publish(event('created', 'lead', { id: 'l-1' }));
181
+ await flush();
182
+
183
+ expect(await outbox.list()).toHaveLength(0);
184
+ await ae.stop();
185
+ });
186
+
187
+ it('respects the triggers CSV (create-only webhook ignores updates)', async () => {
188
+ const engine = new FakeEngine({
189
+ sys_webhook: [webhook({ triggers: 'create' })],
190
+ });
191
+ const realtime = new FakeRealtime();
192
+ const outbox = new MemoryWebhookOutbox();
193
+ const ae = new AutoEnqueuer(engine, realtime, outbox, {
194
+ refreshIntervalMs: 0,
195
+ });
196
+ await ae.start();
197
+
198
+ await realtime.publish(event('created', 'contact', { id: 'c-1' }));
199
+ await realtime.publish(event('updated', 'contact', { id: 'c-1' }, '2026-05-24T00:00:01.000Z'));
200
+ await realtime.publish(event('deleted', 'contact', { id: 'c-1' }, '2026-05-24T00:00:02.000Z'));
201
+ await flush();
202
+
203
+ const rows = await outbox.list();
204
+ expect(rows).toHaveLength(1);
205
+ expect(rows[0].eventType).toBe('data.record.created');
206
+ await ae.stop();
207
+ });
208
+
209
+ it('fans out to multiple matching webhooks', async () => {
210
+ const engine = new FakeEngine({
211
+ sys_webhook: [
212
+ webhook({ id: 'wh-1', name: 'slack', url: 'https://slack.test' }),
213
+ webhook({ id: 'wh-2', name: 'analytics', url: 'https://amplitude.test' }),
214
+ ],
215
+ });
216
+ const realtime = new FakeRealtime();
217
+ const outbox = new MemoryWebhookOutbox();
218
+ const ae = new AutoEnqueuer(engine, realtime, outbox, {
219
+ refreshIntervalMs: 0,
220
+ });
221
+ await ae.start();
222
+
223
+ await realtime.publish(event('created', 'contact', { id: 'c-1' }));
224
+ await flush();
225
+
226
+ const rows = await outbox.list();
227
+ expect(rows).toHaveLength(2);
228
+ expect(rows.map((r) => r.url).sort()).toEqual([
229
+ 'https://amplitude.test',
230
+ 'https://slack.test',
231
+ ]);
232
+ await ae.stop();
233
+ });
234
+
235
+ it('skips inactive webhooks', async () => {
236
+ const engine = new FakeEngine({
237
+ sys_webhook: [webhook({ active: false })],
238
+ });
239
+ const realtime = new FakeRealtime();
240
+ const outbox = new MemoryWebhookOutbox();
241
+ const ae = new AutoEnqueuer(engine, realtime, outbox, {
242
+ refreshIntervalMs: 0,
243
+ });
244
+ await ae.start();
245
+
246
+ await realtime.publish(event('created', 'contact', { id: 'c-1' }));
247
+ await flush();
248
+
249
+ expect(await outbox.list()).toHaveLength(0);
250
+ await ae.stop();
251
+ });
252
+
253
+ it('skips manual-only webhooks (no triggers)', async () => {
254
+ const engine = new FakeEngine({
255
+ sys_webhook: [webhook({ triggers: '' })],
256
+ });
257
+ const realtime = new FakeRealtime();
258
+ const outbox = new MemoryWebhookOutbox();
259
+ const ae = new AutoEnqueuer(engine, realtime, outbox, {
260
+ refreshIntervalMs: 0,
261
+ });
262
+ await ae.start();
263
+
264
+ await realtime.publish(event('created', 'contact', { id: 'c-1' }));
265
+ await flush();
266
+
267
+ expect(await outbox.list()).toHaveLength(0);
268
+ await ae.stop();
269
+ });
270
+
271
+ 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
+ const engine = new FakeEngine({ sys_webhook: [] });
275
+ const realtime = new FakeRealtime();
276
+ const outbox = new MemoryWebhookOutbox();
277
+ const ae = new AutoEnqueuer(engine, realtime, outbox, {
278
+ refreshIntervalMs: 0,
279
+ });
280
+ await ae.start();
281
+
282
+ await realtime.publish(event('created', 'contact', { id: 'c-1' }));
283
+ await flush();
284
+ expect(await outbox.list()).toHaveLength(0);
285
+
286
+ // Admin adds a webhook through the API and the engine publishes
287
+ // a data.record.created event for sys_webhook itself.
288
+ await engine.insert('sys_webhook', webhook());
289
+ await realtime.publish({
290
+ type: 'data.record.created',
291
+ object: 'sys_webhook',
292
+ payload: { recordId: 'wh-1' },
293
+ timestamp: '2026-05-24T00:01:00.000Z',
294
+ });
295
+ await flush();
296
+ await flush(); // Two ticks: the self-heal handler itself awaits refresh
297
+
298
+ await realtime.publish(
299
+ event('created', 'contact', { id: 'c-2' }, '2026-05-24T00:01:01.000Z'),
300
+ );
301
+ await flush();
302
+
303
+ const rows = await outbox.list();
304
+ expect(rows).toHaveLength(1);
305
+ expect((rows[0].payload as any).recordId).toBe('c-2');
306
+ await ae.stop();
307
+ });
308
+
309
+ it('uses deterministic eventId so dedup catches replays', async () => {
310
+ const engine = new FakeEngine({ sys_webhook: [webhook()] });
311
+ const realtime = new FakeRealtime();
312
+ const outbox = new MemoryWebhookOutbox();
313
+ const ae = new AutoEnqueuer(engine, realtime, outbox, {
314
+ refreshIntervalMs: 0,
315
+ });
316
+ await ae.start();
317
+
318
+ // Publish identical event twice — outbox dedup must collapse.
319
+ const evt = event('created', 'contact', { id: 'c-1' });
320
+ await realtime.publish(evt);
321
+ await realtime.publish(evt);
322
+ await flush();
323
+
324
+ expect(await outbox.list()).toHaveLength(1);
325
+ await ae.stop();
326
+ });
327
+
328
+ it('handler is fire-and-forget (publish does not block on enqueue)', async () => {
329
+ const engine = new FakeEngine({ sys_webhook: [webhook()] });
330
+ const realtime = new FakeRealtime();
331
+ const outbox = new MemoryWebhookOutbox();
332
+ let slowResolve!: () => void;
333
+ const blocker = new Promise<void>((res) => {
334
+ slowResolve = res;
335
+ });
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
+ });
348
+ await ae.start();
349
+
350
+ const before = Date.now();
351
+ await realtime.publish(event('created', 'contact', { id: 'c-1' }));
352
+ const elapsed = Date.now() - before;
353
+ expect(elapsed).toBeLessThan(20); // publish must not have awaited blocker
354
+
355
+ slowResolve();
356
+ await flush();
357
+ expect(await outbox.list()).toHaveLength(1);
358
+ await ae.stop();
359
+ });
360
+
361
+ it('logs but swallows enqueue errors so other webhooks still fire', async () => {
362
+ const engine = new FakeEngine({
363
+ sys_webhook: [
364
+ webhook({ id: 'wh-bad', url: 'https://bad.test' }),
365
+ webhook({ id: 'wh-good', url: 'https://good.test' }),
366
+ ],
367
+ });
368
+ 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);
374
+ });
375
+ const warn = vi.fn();
376
+ const ae = new AutoEnqueuer(engine, realtime, outbox, {
377
+ refreshIntervalMs: 0,
378
+ logger: { warn },
379
+ });
380
+ await ae.start();
381
+
382
+ await realtime.publish(event('created', 'contact', { id: 'c-1' }));
383
+ await flush();
384
+
385
+ const rows = await outbox.list();
386
+ expect(rows).toHaveLength(1);
387
+ expect(rows[0].url).toBe('https://good.test');
388
+ expect(warn).toHaveBeenCalled();
389
+ await ae.stop();
390
+ });
391
+ });