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