@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.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +35 -13
  2. package/CHANGELOG.md +9 -37
  3. package/dist/chunk-JN76ZRWN.js +164 -0
  4. package/dist/chunk-JN76ZRWN.js.map +1 -0
  5. package/dist/chunk-M4M5FWIH.cjs +15 -0
  6. package/dist/chunk-M4M5FWIH.cjs.map +1 -0
  7. package/dist/chunk-NYSUNT6X.js +15 -0
  8. package/dist/chunk-NYSUNT6X.js.map +1 -0
  9. package/dist/chunk-OW7ESXOK.cjs +164 -0
  10. package/dist/chunk-OW7ESXOK.cjs.map +1 -0
  11. package/dist/index.cjs +747 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +455 -0
  14. package/dist/index.d.ts +425 -74
  15. package/dist/index.js +712 -218
  16. package/dist/index.js.map +1 -1
  17. package/dist/outbox-bPQmKYPN.d.cts +128 -0
  18. package/dist/outbox-bPQmKYPN.d.ts +128 -0
  19. package/dist/schema.cjs +9 -0
  20. package/dist/schema.cjs.map +1 -0
  21. package/dist/schema.d.cts +4772 -0
  22. package/dist/schema.d.ts +4772 -0
  23. package/dist/schema.js +9 -0
  24. package/dist/schema.js.map +1 -0
  25. package/dist/sql-outbox.cjs +184 -0
  26. package/dist/sql-outbox.cjs.map +1 -0
  27. package/dist/sql-outbox.d.cts +54 -0
  28. package/dist/sql-outbox.d.ts +54 -0
  29. package/dist/sql-outbox.js +184 -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 +48 -12
  38. package/src/memory-outbox.ts +127 -0
  39. package/src/outbox.ts +141 -0
  40. package/src/partition.ts +19 -0
  41. package/src/retention.test.ts +116 -0
  42. package/src/retention.ts +144 -0
  43. package/src/schema.ts +22 -0
  44. package/src/sql-outbox.test.ts +410 -0
  45. package/src/sql-outbox.ts +282 -0
  46. package/src/sys-webhook-delivery.object.ts +202 -0
  47. package/src/webhook-outbox-plugin.ts +280 -0
  48. package/tsconfig.json +5 -13
  49. package/tsup.config.ts +14 -0
  50. package/dist/index.d.mts +0 -104
  51. package/dist/index.mjs +0 -216
  52. package/dist/index.mjs.map +0 -1
  53. package/src/webhooks-plugin.test.ts +0 -218
  54. package/src/webhooks-plugin.ts +0 -294
package/dist/index.js CHANGED
@@ -1,253 +1,747 @@
1
- "use strict";
2
- var __create = Object.create;
3
- var __defProp = Object.defineProperty;
4
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
- var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
- var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __export = (target, all) => {
9
- for (var name in all)
10
- __defProp(target, name, { get: all[name], enumerable: true });
11
- };
12
- var __copyProps = (to, from, except, desc) => {
13
- if (from && typeof from === "object" || typeof from === "function") {
14
- for (let key of __getOwnPropNames(from))
15
- if (!__hasOwnProp.call(to, key) && key !== except)
16
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
- }
18
- return to;
19
- };
20
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
- // If the importer is in node compatibility mode or this is not an ESM
22
- // file that has been converted to a CommonJS file using a Babel-
23
- // compatible transform (i.e. "__esModule" has not been set), then set
24
- // "default" to the CommonJS "module.exports" for node compatibility.
25
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
- mod
27
- ));
28
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
1
+ import {
2
+ hashPartition
3
+ } from "./chunk-NYSUNT6X.js";
4
+ import {
5
+ SYS_WEBHOOK_DELIVERY,
6
+ SysWebhookDelivery
7
+ } from "./chunk-JN76ZRWN.js";
29
8
 
30
- // src/index.ts
31
- var index_exports = {};
32
- __export(index_exports, {
33
- WebhooksPlugin: () => WebhooksPlugin
34
- });
35
- module.exports = __toCommonJS(index_exports);
9
+ // src/webhook-outbox-plugin.ts
10
+ import { SysWebhook } from "@objectstack/platform-objects/integration";
36
11
 
37
- // src/webhooks-plugin.ts
38
- var import_node_crypto = __toESM(require("crypto"));
39
- var DEFAULT_TIMEOUT_MS = 5e3;
40
- var DEFAULT_RETRIES = 3;
41
- var BACKOFF_BASE_MS = 250;
42
- var BACKOFF_MAX_MS = 5e3;
43
- var WebhooksPlugin = class {
44
- constructor(options = {}) {
45
- this.name = "com.objectstack.webhooks";
46
- this.version = "1.0.0";
47
- this.type = "standard";
48
- this.dependencies = ["com.objectstack.service.realtime"];
49
- this.subscriptionIds = [];
50
- this.sinks = [];
51
- this.options = options;
12
+ // src/auto-enqueuer.ts
13
+ var AutoEnqueuer = class {
14
+ constructor(engine, realtime, outbox, opts = {}) {
15
+ this.engine = engine;
16
+ this.realtime = realtime;
17
+ this.outbox = outbox;
18
+ this.subscriptions = /* @__PURE__ */ new Map();
19
+ this.running = false;
20
+ this.subscriptionsObject = opts.subscriptionsObject ?? "sys_webhook";
21
+ this.refreshIntervalMs = opts.refreshIntervalMs ?? 6e4;
22
+ this.logger = opts.logger ?? {};
52
23
  }
53
- async init(ctx) {
54
- this.logger = ctx.logger;
55
- this.sinks = this.resolveSinks();
56
- if (this.sinks.length === 0) {
57
- ctx.logger.info(
58
- "WebhooksPlugin: no sinks configured (options.sinks empty and OBJECTSTACK_WEBHOOK_URL unset) \u2014 plugin is dormant"
24
+ /**
25
+ * Load the subscription cache and start listening for events.
26
+ */
27
+ async start() {
28
+ if (this.running) return;
29
+ this.running = true;
30
+ await this.refresh();
31
+ this.subId = await this.realtime.subscribe(
32
+ "webhook-auto-enqueuer",
33
+ (event) => this.handleEvent(event)
34
+ );
35
+ this.subIdSelfHeal = await this.realtime.subscribe(
36
+ "webhook-auto-enqueuer-self-heal",
37
+ (event) => this.handleSelfHealEvent(event),
38
+ { object: this.subscriptionsObject }
39
+ );
40
+ if (this.refreshIntervalMs > 0) {
41
+ this.refreshTimer = setInterval(() => {
42
+ this.refresh().catch(
43
+ (err) => this.logger.warn?.("[webhook-auto-enqueuer] periodic refresh failed", err)
44
+ );
45
+ }, this.refreshIntervalMs);
46
+ this.refreshTimer.unref?.();
47
+ }
48
+ }
49
+ async stop() {
50
+ if (!this.running) return;
51
+ this.running = false;
52
+ if (this.subId) await this.realtime.unsubscribe(this.subId);
53
+ if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);
54
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
55
+ this.subId = void 0;
56
+ this.subIdSelfHeal = void 0;
57
+ this.refreshTimer = void 0;
58
+ }
59
+ /**
60
+ * Force-refresh the subscription cache from storage. Concurrent
61
+ * callers share a single in-flight refresh.
62
+ */
63
+ async refresh() {
64
+ if (this.refreshing) return this.refreshing;
65
+ this.refreshing = this.doRefresh().finally(() => {
66
+ this.refreshing = void 0;
67
+ });
68
+ return this.refreshing;
69
+ }
70
+ async doRefresh() {
71
+ let rows;
72
+ try {
73
+ rows = await this.engine.find(this.subscriptionsObject, {
74
+ where: { active: true }
75
+ });
76
+ } catch (err) {
77
+ this.logger.warn?.(
78
+ `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,
79
+ err
59
80
  );
60
81
  return;
61
82
  }
62
- ctx.logger.info(`WebhooksPlugin: ${this.sinks.length} sink(s) configured`);
83
+ const next = /* @__PURE__ */ new Map();
84
+ for (const row of rows) {
85
+ const sub = this.parseRow(row);
86
+ if (!sub) continue;
87
+ const key = sub.objectName ?? "*";
88
+ const arr = next.get(key) ?? [];
89
+ arr.push(sub);
90
+ next.set(key, arr);
91
+ }
92
+ this.subscriptions.clear();
93
+ for (const [k, v] of next) this.subscriptions.set(k, v);
94
+ this.logger.debug?.("[webhook-auto-enqueuer] cache refreshed", {
95
+ objects: this.subscriptions.size,
96
+ rows: rows.length
97
+ });
63
98
  }
64
- async start(ctx) {
65
- if (this.sinks.length === 0) return;
66
- ctx.hook("kernel:ready", async () => {
99
+ parseRow(row) {
100
+ if (!row?.id || !row?.url) return null;
101
+ const triggersField = row.triggers ?? "";
102
+ const triggers = new Set(
103
+ triggersField.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)
104
+ );
105
+ if (triggers.size === 0) {
106
+ return null;
107
+ }
108
+ let defn = {};
109
+ if (typeof row.definition_json === "string" && row.definition_json.length > 0) {
67
110
  try {
68
- this.realtime = ctx.getService("realtime");
111
+ defn = JSON.parse(row.definition_json) ?? {};
69
112
  } catch {
70
- ctx.logger.warn("WebhooksPlugin: realtime service unavailable \u2014 events will not be forwarded");
71
- return;
72
- }
73
- for (const sink of this.sinks) {
74
- const opts = sink.objects && sink.objects.length === 1 || sink.eventTypes && sink.eventTypes.length > 0 ? {
75
- ...sink.objects && sink.objects.length === 1 ? { object: sink.objects[0] } : {},
76
- ...sink.eventTypes && sink.eventTypes.length > 0 ? { eventTypes: sink.eventTypes } : {}
77
- } : void 0;
78
- const id = await this.realtime.subscribe(
79
- "data.record",
80
- async (event) => {
81
- await this.dispatch(sink, event);
82
- },
83
- opts
84
- );
85
- this.subscriptionIds.push(id);
113
+ defn = {};
86
114
  }
87
- ctx.logger.info(`WebhooksPlugin: subscribed ${this.subscriptionIds.length} realtime listener(s)`);
115
+ }
116
+ return {
117
+ id: row.id,
118
+ name: row.name ?? row.id,
119
+ objectName: row.object_name ? String(row.object_name) : void 0,
120
+ triggers,
121
+ url: String(row.url),
122
+ method: row.method ?? defn.method ?? "POST",
123
+ headers: defn.headers,
124
+ secret: defn.secret,
125
+ timeoutMs: defn.timeoutMs
126
+ };
127
+ }
128
+ /**
129
+ * Handler for the firehose subscription.
130
+ *
131
+ * NOTE: we intentionally `void` the inner enqueue() so the realtime
132
+ * publisher (and therefore the user's request) is never blocked on
133
+ * webhook persistence.
134
+ */
135
+ handleEvent(event) {
136
+ if (!event.type?.startsWith("data.record.")) return;
137
+ if (!event.object) return;
138
+ if (event.object === this.subscriptionsObject) return;
139
+ const action = event.type.slice("data.record.".length);
140
+ const trigger = mapActionToTrigger(action);
141
+ if (!trigger) return;
142
+ const subs = [
143
+ ...this.subscriptions.get(event.object) ?? [],
144
+ ...this.subscriptions.get("*") ?? []
145
+ ];
146
+ if (subs.length === 0) return;
147
+ const payload = event.payload ?? {};
148
+ const recordId = payload.recordId ?? payload.id ?? payload.after?.id ?? payload.before?.id ?? "unknown";
149
+ const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;
150
+ for (const sub of subs) {
151
+ if (!sub.triggers.has(trigger)) continue;
152
+ void this.outbox.enqueue({
153
+ webhookId: sub.id,
154
+ eventId,
155
+ eventType: event.type,
156
+ url: sub.url,
157
+ method: sub.method,
158
+ headers: sub.headers,
159
+ secret: sub.secret,
160
+ timeoutMs: sub.timeoutMs,
161
+ payload: {
162
+ object: event.object,
163
+ recordId,
164
+ action,
165
+ timestamp: event.timestamp,
166
+ ...payload
167
+ }
168
+ }).catch(
169
+ (err) => this.logger.warn?.("[webhook-auto-enqueuer] enqueue failed", {
170
+ webhook: sub.name,
171
+ eventId,
172
+ err: err?.message ?? err
173
+ })
174
+ );
175
+ }
176
+ }
177
+ handleSelfHealEvent(event) {
178
+ if (event.object !== this.subscriptionsObject) return;
179
+ if (!event.type?.startsWith("data.record.")) return;
180
+ this.refresh().catch(
181
+ (err) => this.logger.warn?.("[webhook-auto-enqueuer] self-heal refresh failed", err)
182
+ );
183
+ }
184
+ /** Test / admin accessor. */
185
+ snapshot() {
186
+ return this.subscriptions;
187
+ }
188
+ };
189
+ function mapActionToTrigger(action) {
190
+ switch (action) {
191
+ case "created":
192
+ return "create";
193
+ case "updated":
194
+ return "update";
195
+ case "deleted":
196
+ return "delete";
197
+ case "undeleted":
198
+ return "undelete";
199
+ default:
200
+ return null;
201
+ }
202
+ }
203
+
204
+ // src/http-sender.ts
205
+ import { createHmac, randomUUID } from "crypto";
206
+ var DEFAULT_TIMEOUT_MS = 15e3;
207
+ var RESPONSE_BODY_CAP = 16 * 1024;
208
+ async function sendOnce(delivery, fetchImpl) {
209
+ const body = typeof delivery.payload === "string" ? delivery.payload : JSON.stringify(delivery.payload);
210
+ const headers = {
211
+ "Content-Type": "application/json",
212
+ "User-Agent": "ObjectStack-Webhooks/1.0",
213
+ "X-Objectstack-Event": delivery.eventType,
214
+ "X-Objectstack-Delivery": delivery.id,
215
+ "X-Objectstack-Attempt": String(delivery.attempts + 1),
216
+ ...delivery.headers ?? {}
217
+ };
218
+ if (delivery.secret) {
219
+ const sig = createHmac("sha256", delivery.secret).update(body).digest("hex");
220
+ headers["X-Objectstack-Signature"] = `sha256=${sig}`;
221
+ }
222
+ const timeoutMs = delivery.timeoutMs ?? DEFAULT_TIMEOUT_MS;
223
+ const controller = new AbortController();
224
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
225
+ const start = Date.now();
226
+ try {
227
+ const res = await fetchImpl(delivery.url, {
228
+ method: delivery.method ?? "POST",
229
+ headers,
230
+ body,
231
+ signal: controller.signal
88
232
  });
233
+ clearTimeout(timer);
234
+ const responseText = await safeReadBody(res);
235
+ const durationMs = Date.now() - start;
236
+ if (res.ok) {
237
+ return { success: true, httpStatus: res.status, responseBody: responseText, durationMs };
238
+ }
239
+ const retriable = res.status === 408 || res.status === 429 || res.status >= 500;
240
+ return {
241
+ success: false,
242
+ retriable,
243
+ httpStatus: res.status,
244
+ responseBody: responseText,
245
+ error: `HTTP ${res.status}`,
246
+ durationMs
247
+ };
248
+ } catch (err) {
249
+ clearTimeout(timer);
250
+ const durationMs = Date.now() - start;
251
+ const e = err;
252
+ const error = e?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);
253
+ return { success: false, retriable: true, error, durationMs };
254
+ }
255
+ }
256
+ async function safeReadBody(res) {
257
+ try {
258
+ const text = await res.text();
259
+ return text.length > RESPONSE_BODY_CAP ? text.slice(0, RESPONSE_BODY_CAP) : text;
260
+ } catch {
261
+ return void 0;
262
+ }
263
+ }
264
+ function nextRetryDelayMs(attemptsSoFar, rng = Math.random) {
265
+ const SCHEDULE = [1e3, 1e4, 6e4, 6e5, 36e5, 216e5, 864e5];
266
+ if (attemptsSoFar < 1 || attemptsSoFar > SCHEDULE.length) return null;
267
+ const base = SCHEDULE[attemptsSoFar - 1];
268
+ const jitter = 0.8 + rng() * 0.4;
269
+ return Math.floor(base * jitter);
270
+ }
271
+ function classifyAttempt(outcome, attemptsSoFar, now = Date.now(), rng) {
272
+ if (outcome.success) return outcome;
273
+ if (!outcome.retriable) {
274
+ return {
275
+ success: false,
276
+ httpStatus: outcome.httpStatus,
277
+ responseBody: outcome.responseBody,
278
+ error: outcome.error,
279
+ durationMs: outcome.durationMs,
280
+ dead: true
281
+ };
282
+ }
283
+ const delay = nextRetryDelayMs(attemptsSoFar + 1, rng);
284
+ if (delay === null) {
285
+ return {
286
+ success: false,
287
+ httpStatus: outcome.httpStatus,
288
+ responseBody: outcome.responseBody,
289
+ error: outcome.error,
290
+ durationMs: outcome.durationMs,
291
+ dead: true
292
+ };
293
+ }
294
+ return {
295
+ success: false,
296
+ httpStatus: outcome.httpStatus,
297
+ responseBody: outcome.responseBody,
298
+ error: outcome.error,
299
+ durationMs: outcome.durationMs,
300
+ nextRetryAt: now + delay
301
+ };
302
+ }
303
+
304
+ // src/dispatcher.ts
305
+ var WebhookDispatcher = class {
306
+ constructor(options) {
307
+ this.running = false;
308
+ const intervalMs = options.intervalMs ?? 250;
309
+ const lockTtlMs = options.lockTtlMs ?? intervalMs * 5;
310
+ this.opts = {
311
+ nodeId: options.nodeId,
312
+ cluster: options.cluster,
313
+ outbox: options.outbox,
314
+ partitionCount: options.partitionCount ?? 8,
315
+ batchSize: options.batchSize ?? 32,
316
+ intervalMs,
317
+ lockTtlMs,
318
+ claimTtlMs: options.claimTtlMs ?? lockTtlMs * 2,
319
+ onAttempt: options.onAttempt,
320
+ fetchImpl: options.fetchImpl,
321
+ rng: options.rng,
322
+ logger: options.logger
323
+ };
324
+ }
325
+ /** Begin the periodic loop. Safe to call once; subsequent calls are no-ops. */
326
+ start() {
327
+ if (this.running) return;
328
+ this.running = true;
329
+ this.scheduleTick();
330
+ this.timer = setInterval(() => this.scheduleTick(), this.opts.intervalMs);
89
331
  }
90
- async stop(ctx) {
91
- if (!this.realtime) return;
92
- for (const id of this.subscriptionIds) {
332
+ /** Stop the loop and wait for the in-flight tick to drain. */
333
+ async stop() {
334
+ if (!this.running) return;
335
+ this.running = false;
336
+ if (this.timer) {
337
+ clearInterval(this.timer);
338
+ this.timer = void 0;
339
+ }
340
+ if (this.inflightTick) {
93
341
  try {
94
- await this.realtime.unsubscribe(id);
95
- } catch (err) {
96
- ctx.logger.debug("WebhooksPlugin: unsubscribe failed", { id, err });
342
+ await this.inflightTick;
343
+ } catch {
97
344
  }
98
345
  }
99
- this.subscriptionIds = [];
100
346
  }
101
347
  /**
102
- * Resolve sinks from constructor options, falling back to env vars when
103
- * none provided. Exposed for testing.
348
+ * Run one full tick (all partitions, single attempt each). Exposed for
349
+ * deterministic tests that want to step the dispatcher manually.
104
350
  */
105
- resolveSinks() {
106
- if (this.options.sinks && this.options.sinks.length > 0) return this.options.sinks;
107
- const urlEnv = process.env.OBJECTSTACK_WEBHOOK_URL;
108
- if (!urlEnv) return [];
109
- const urls = urlEnv.split(",").map((s) => s.trim()).filter(Boolean);
110
- const secret = process.env.OBJECTSTACK_WEBHOOK_SECRET;
111
- const objectsEnv = process.env.OBJECTSTACK_WEBHOOK_OBJECTS;
112
- const eventsEnv = process.env.OBJECTSTACK_WEBHOOK_EVENTS;
113
- const objects = objectsEnv ? objectsEnv.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
114
- const eventTypes = eventsEnv ? eventsEnv.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
115
- return urls.map((url, idx) => ({
116
- id: `env-${idx + 1}`,
117
- url,
118
- ...secret ? { secret } : {},
119
- ...objects ? { objects } : {},
120
- ...eventTypes ? { eventTypes } : {}
121
- }));
351
+ async tick() {
352
+ await this.runTick();
122
353
  }
123
- /**
124
- * Dispatch a single event to a sink, with HMAC signing, timeout, and
125
- * exponential-backoff retry. Failures past the retry budget are logged
126
- * but never thrown — webhook delivery must never break the originating
127
- * mutation.
128
- */
129
- async dispatch(sink, event) {
130
- if (sink.objects && sink.objects.length > 0 && event.object && !sink.objects.includes(event.object)) {
131
- return;
354
+ scheduleTick() {
355
+ if (this.inflightTick) return;
356
+ this.inflightTick = this.runTick().catch((err) => {
357
+ this.opts.logger?.warn?.("webhook-dispatcher: tick failed", {
358
+ nodeId: this.opts.nodeId,
359
+ error: err?.message ?? String(err)
360
+ });
361
+ }).finally(() => {
362
+ this.inflightTick = void 0;
363
+ });
364
+ }
365
+ async runTick() {
366
+ const partitionCount = this.opts.partitionCount;
367
+ const offset = stableNodeOffset(this.opts.nodeId, partitionCount);
368
+ for (let step = 0; step < partitionCount; step++) {
369
+ const i = (offset + step) % partitionCount;
370
+ await this.runPartition(i);
132
371
  }
133
- if (sink.eventTypes && sink.eventTypes.length > 0 && !sink.eventTypes.includes(event.type)) {
134
- return;
372
+ }
373
+ async runPartition(index) {
374
+ const key = `webhook.dispatcher.partition.${index}`;
375
+ const handle = await this.opts.cluster.lock.acquire(key, {
376
+ ttlMs: this.opts.lockTtlMs,
377
+ // waitMs=0 → fail-fast; we'll try this partition again next tick.
378
+ waitMs: 0
379
+ });
380
+ if (!handle) return;
381
+ try {
382
+ const claimed = await this.opts.outbox.claim({
383
+ nodeId: this.opts.nodeId,
384
+ limit: this.opts.batchSize,
385
+ partition: { index, count: this.opts.partitionCount },
386
+ claimTtlMs: this.opts.claimTtlMs
387
+ });
388
+ if (claimed.length === 0) return;
389
+ await handle.renew(this.opts.lockTtlMs);
390
+ for (const row of claimed) {
391
+ if (!handle.isHeld()) break;
392
+ await this.processRow(row);
393
+ }
394
+ } finally {
395
+ await handle.release();
135
396
  }
136
- const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
397
+ }
398
+ async processRow(row) {
399
+ const fetchImpl = this.opts.fetchImpl ?? globalThis.fetch;
137
400
  if (!fetchImpl) {
138
- this.logger?.warn("WebhooksPlugin: no fetch implementation available \u2014 dropping event", { sinkId: sink.id });
401
+ this.opts.logger?.warn?.("webhook-dispatcher: no fetch impl available", {
402
+ rowId: row.id
403
+ });
404
+ await this.opts.outbox.ack(row.id, {
405
+ success: false,
406
+ error: "no fetch implementation",
407
+ durationMs: 0,
408
+ dead: true
409
+ });
139
410
  return;
140
411
  }
141
- const body = JSON.stringify(event);
142
- const headers = {
143
- "Content-Type": "application/json",
144
- "User-Agent": "ObjectStack-Webhooks/1.0",
145
- "X-Objectstack-Event": event.type,
146
- ...event.object ? { "X-Objectstack-Object": event.object } : {},
147
- "X-Objectstack-Delivery": import_node_crypto.default.randomUUID(),
148
- ...sink.headers ?? {}
412
+ const outcome = await sendOnce(row, fetchImpl);
413
+ const result = classifyAttempt(outcome, row.attempts, Date.now(), this.opts.rng);
414
+ await this.opts.outbox.ack(row.id, result);
415
+ this.opts.onAttempt?.(row, result.success);
416
+ }
417
+ };
418
+ function stableNodeOffset(nodeId, partitionCount) {
419
+ let h = 0;
420
+ for (let i = 0; i < nodeId.length; i++) {
421
+ h = h * 31 + nodeId.charCodeAt(i) | 0;
422
+ }
423
+ return Math.abs(h) % partitionCount;
424
+ }
425
+
426
+ // src/memory-outbox.ts
427
+ import { randomUUID as randomUUID2 } from "crypto";
428
+ var MemoryWebhookOutbox = class {
429
+ constructor() {
430
+ this.rows = /* @__PURE__ */ new Map();
431
+ /** Dedup index keyed by `${eventId}::${webhookId}` -> row id. */
432
+ this.dedup = /* @__PURE__ */ new Map();
433
+ }
434
+ async enqueue(input) {
435
+ const dedupKey = `${input.eventId}::${input.webhookId}`;
436
+ const existing = this.dedup.get(dedupKey);
437
+ if (existing) return existing;
438
+ const id = randomUUID2();
439
+ const now = Date.now();
440
+ const row = {
441
+ id,
442
+ webhookId: input.webhookId,
443
+ eventId: input.eventId,
444
+ eventType: input.eventType,
445
+ url: input.url,
446
+ method: input.method ?? "POST",
447
+ headers: input.headers,
448
+ secret: input.secret,
449
+ timeoutMs: input.timeoutMs,
450
+ payload: input.payload,
451
+ status: "pending",
452
+ attempts: 0,
453
+ createdAt: now,
454
+ updatedAt: now
149
455
  };
150
- if (sink.secret) {
151
- const sig = import_node_crypto.default.createHmac("sha256", sink.secret).update(body).digest("hex");
152
- headers["X-Objectstack-Signature"] = `sha256=${sig}`;
153
- }
154
- const timeoutMs = sink.timeoutMs ?? DEFAULT_TIMEOUT_MS;
155
- const maxAttempts = (sink.retries ?? DEFAULT_RETRIES) + 1;
156
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
157
- const controller = new AbortController();
158
- const timer = setTimeout(() => controller.abort(), timeoutMs);
456
+ this.rows.set(id, row);
457
+ this.dedup.set(dedupKey, id);
458
+ return id;
459
+ }
460
+ async claim(opts) {
461
+ const now = opts.now ?? Date.now();
462
+ const claimed = [];
463
+ for (const row of this.rows.values()) {
464
+ if (row.status === "in_flight" && row.claimedAt !== void 0 && now - row.claimedAt > opts.claimTtlMs) {
465
+ row.status = "pending";
466
+ row.claimedBy = void 0;
467
+ row.claimedAt = void 0;
468
+ row.updatedAt = now;
469
+ }
470
+ }
471
+ for (const row of this.rows.values()) {
472
+ if (claimed.length >= opts.limit) break;
473
+ if (row.status !== "pending") continue;
474
+ if (row.nextRetryAt !== void 0 && row.nextRetryAt > now) continue;
475
+ if (opts.partition) {
476
+ const p = hashPartition(row.webhookId, opts.partition.count);
477
+ if (p !== opts.partition.index) continue;
478
+ }
479
+ row.status = "in_flight";
480
+ row.claimedBy = opts.nodeId;
481
+ row.claimedAt = now;
482
+ row.updatedAt = now;
483
+ claimed.push({ ...row });
484
+ }
485
+ return claimed;
486
+ }
487
+ async ack(id, result) {
488
+ const row = this.rows.get(id);
489
+ if (!row) return;
490
+ const now = Date.now();
491
+ row.attempts += 1;
492
+ row.lastAttemptedAt = now;
493
+ row.updatedAt = now;
494
+ row.claimedBy = void 0;
495
+ row.claimedAt = void 0;
496
+ row.responseCode = result.httpStatus;
497
+ row.responseBody = result.responseBody;
498
+ let status;
499
+ if (result.success) {
500
+ status = "success";
501
+ row.nextRetryAt = void 0;
502
+ row.error = void 0;
503
+ } else if (result.dead) {
504
+ status = "dead";
505
+ row.error = result.error;
506
+ row.nextRetryAt = void 0;
507
+ } else {
508
+ status = "pending";
509
+ row.error = result.error;
510
+ row.nextRetryAt = result.nextRetryAt;
511
+ }
512
+ row.status = status;
513
+ }
514
+ async list(filter) {
515
+ const all = Array.from(this.rows.values()).map((r) => ({ ...r }));
516
+ return filter?.status ? all.filter((r) => r.status === filter.status) : all;
517
+ }
518
+ };
519
+
520
+ // src/retention.ts
521
+ var DEFAULTS = {
522
+ successTtlMs: 7 * 24 * 60 * 60 * 1e3,
523
+ deadTtlMs: 30 * 24 * 60 * 60 * 1e3,
524
+ sweepIntervalMs: 60 * 60 * 1e3
525
+ };
526
+ var DeliveryRetentionSweeper = class {
527
+ constructor(engine, opts = {}) {
528
+ this.engine = engine;
529
+ this.running = false;
530
+ this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;
531
+ this.successTtlMs = opts.successTtlMs ?? DEFAULTS.successTtlMs;
532
+ this.deadTtlMs = opts.deadTtlMs ?? DEFAULTS.deadTtlMs;
533
+ this.sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULTS.sweepIntervalMs;
534
+ this.logger = opts.logger ?? {};
535
+ }
536
+ start() {
537
+ if (this.running) return;
538
+ this.running = true;
539
+ this.timer = setInterval(() => {
540
+ this.sweep().catch(
541
+ (err) => this.logger.warn?.("[webhook-retention] sweep failed", err)
542
+ );
543
+ }, this.sweepIntervalMs);
544
+ this.timer.unref?.();
545
+ }
546
+ stop() {
547
+ if (!this.running) return;
548
+ this.running = false;
549
+ if (this.timer) clearInterval(this.timer);
550
+ this.timer = void 0;
551
+ }
552
+ /** Run one sweep immediately. Returns the number of rows deleted. */
553
+ async sweep(now = Date.now()) {
554
+ let successDeleted = 0;
555
+ let deadDeleted = 0;
556
+ if (this.successTtlMs > 0) {
159
557
  try {
160
- const res = await fetchImpl(sink.url, {
161
- method: "POST",
162
- headers,
163
- body,
164
- signal: controller.signal
165
- });
166
- clearTimeout(timer);
167
- if (res.ok || res.status >= 400 && res.status < 500) {
168
- const status = res.ok ? "ok" : "failed";
169
- this.options.onDelivery?.({
170
- sinkId: sink.id,
171
- url: sink.url,
172
- eventType: event.type,
173
- object: event.object,
174
- status,
175
- httpStatus: res.status,
176
- attempt
177
- });
178
- if (status === "failed") {
179
- this.logger?.warn("WebhooksPlugin: sink rejected event", {
180
- sinkId: sink.id,
181
- status: res.status,
182
- eventType: event.type
183
- });
558
+ const res = await this.engine.delete(this.objectName, {
559
+ where: {
560
+ status: "success",
561
+ updated_at: { $lt: now - this.successTtlMs }
184
562
  }
185
- return;
186
- }
187
- if (attempt === maxAttempts) {
188
- this.options.onDelivery?.({
189
- sinkId: sink.id,
190
- url: sink.url,
191
- eventType: event.type,
192
- object: event.object,
193
- status: "failed",
194
- httpStatus: res.status,
195
- attempt
196
- });
197
- this.logger?.warn("WebhooksPlugin: max retries exhausted", {
198
- sinkId: sink.id,
199
- status: res.status,
200
- eventType: event.type
201
- });
202
- return;
203
- }
204
- this.options.onDelivery?.({
205
- sinkId: sink.id,
206
- url: sink.url,
207
- eventType: event.type,
208
- object: event.object,
209
- status: "retrying",
210
- httpStatus: res.status,
211
- attempt
212
563
  });
564
+ successDeleted = res?.affected ?? 0;
213
565
  } catch (err) {
214
- clearTimeout(timer);
215
- const errMessage = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : err?.message ?? String(err);
216
- if (attempt === maxAttempts) {
217
- this.options.onDelivery?.({
218
- sinkId: sink.id,
219
- url: sink.url,
220
- eventType: event.type,
221
- object: event.object,
222
- status: "failed",
223
- attempt,
224
- error: errMessage
225
- });
226
- this.logger?.warn("WebhooksPlugin: delivery failed", {
227
- sinkId: sink.id,
228
- eventType: event.type,
229
- error: errMessage
230
- });
231
- return;
232
- }
233
- this.options.onDelivery?.({
234
- sinkId: sink.id,
235
- url: sink.url,
236
- eventType: event.type,
237
- object: event.object,
238
- status: "retrying",
239
- attempt,
240
- error: errMessage
566
+ this.logger.warn?.("[webhook-retention] success sweep failed", err);
567
+ }
568
+ }
569
+ if (this.deadTtlMs > 0) {
570
+ try {
571
+ const res = await this.engine.delete(this.objectName, {
572
+ where: {
573
+ status: "dead",
574
+ updated_at: { $lt: now - this.deadTtlMs }
575
+ }
241
576
  });
577
+ deadDeleted = res?.affected ?? 0;
578
+ } catch (err) {
579
+ this.logger.warn?.("[webhook-retention] dead sweep failed", err);
242
580
  }
243
- const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1));
244
- const jittered = Math.floor(Math.random() * delay);
245
- await new Promise((r) => setTimeout(r, jittered));
246
581
  }
582
+ if (successDeleted + deadDeleted > 0) {
583
+ this.logger.info?.("[webhook-retention] sweep complete", {
584
+ success: successDeleted,
585
+ dead: deadDeleted
586
+ });
587
+ }
588
+ return { success: successDeleted, dead: deadDeleted };
247
589
  }
248
590
  };
249
- // Annotate the CommonJS export names for ESM import in node:
250
- 0 && (module.exports = {
251
- WebhooksPlugin
252
- });
591
+
592
+ // src/webhook-outbox-plugin.ts
593
+ var WebhookOutboxPlugin = class {
594
+ constructor(options = {}) {
595
+ this.options = options;
596
+ this.name = "com.objectstack.plugin-webhook-outbox";
597
+ this.version = "1.1.0";
598
+ this.type = "standard";
599
+ this.dependencies = ["com.objectstack.service.cluster"];
600
+ }
601
+ async init(ctx) {
602
+ const cluster = ctx.getService("cluster");
603
+ if (!cluster) {
604
+ throw new Error(
605
+ 'WebhookOutboxPlugin: required service "cluster" not found \u2014 register ClusterServicePlugin first'
606
+ );
607
+ }
608
+ const manifest = ctx.getService("manifest");
609
+ if (manifest && typeof manifest.register === "function") {
610
+ manifest.register({
611
+ id: "com.objectstack.plugin-webhook-outbox.schema",
612
+ namespace: "sys",
613
+ version: this.version,
614
+ type: "plugin",
615
+ scope: "system",
616
+ name: "Webhook Outbox Schemas",
617
+ description: "Registers sys_webhook (configuration) and sys_webhook_delivery (durable outbox telemetry).",
618
+ objects: [SysWebhook, SysWebhookDelivery]
619
+ });
620
+ } else {
621
+ ctx.logger.warn?.(
622
+ "[webhook-outbox] manifest service unavailable \u2014 sys_webhook / sys_webhook_delivery will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin."
623
+ );
624
+ }
625
+ const outbox = this.resolveOutbox(ctx);
626
+ this.outboxInstance = outbox;
627
+ const nodeId = this.options.nodeId ?? process.env.OBJECTSTACK_NODE_ID ?? `node-${Math.random().toString(36).slice(2, 10)}`;
628
+ const dispatcher = new WebhookDispatcher({
629
+ nodeId,
630
+ cluster,
631
+ outbox,
632
+ partitionCount: this.options.partitionCount,
633
+ batchSize: this.options.batchSize,
634
+ intervalMs: this.options.intervalMs,
635
+ lockTtlMs: this.options.lockTtlMs,
636
+ claimTtlMs: this.options.claimTtlMs,
637
+ fetchImpl: this.options.fetchImpl,
638
+ onAttempt: this.options.onAttempt,
639
+ rng: this.options.rng,
640
+ logger: ctx.logger
641
+ });
642
+ this.dispatcher = dispatcher;
643
+ ctx.registerService("webhook.outbox", outbox);
644
+ ctx.registerService("webhook.dispatcher", dispatcher);
645
+ if (this.options.autoStart !== false) {
646
+ dispatcher.start();
647
+ }
648
+ const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
649
+ if (usingMemoryOutbox && process.env.NODE_ENV === "production") {
650
+ ctx.logger.warn?.(
651
+ '[webhook-outbox] MemoryWebhookOutbox in production \u2014 webhook deliveries WILL be lost on process exit. Pass `outbox: (ctx) => new SqlWebhookOutbox(ctx.getService("objectql"), { partitionCount: 8 })` from `@objectstack/plugin-webhooks/sql`.'
652
+ );
653
+ }
654
+ const autoEnqueueOpt = this.options.autoEnqueue ?? true;
655
+ const retentionOpt = this.options.retention ?? true;
656
+ const needsReadyHook = autoEnqueueOpt !== false || retentionOpt !== false;
657
+ if (needsReadyHook && typeof ctx.hook === "function") {
658
+ ctx.hook("kernel:ready", async () => {
659
+ await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
660
+ this.bootRetention(ctx, retentionOpt);
661
+ });
662
+ }
663
+ ctx.logger.info?.("[webhook-outbox] initialised", {
664
+ nodeId,
665
+ partitions: this.options.partitionCount ?? 8,
666
+ interval: this.options.intervalMs ?? 250,
667
+ autoEnqueue: autoEnqueueOpt !== false,
668
+ retention: retentionOpt !== false
669
+ });
670
+ }
671
+ async dispose() {
672
+ await this.autoEnqueuer?.stop();
673
+ this.retention?.stop();
674
+ await this.dispatcher?.stop();
675
+ }
676
+ resolveOutbox(ctx) {
677
+ const opt = this.options.outbox;
678
+ if (!opt) return new MemoryWebhookOutbox();
679
+ if (typeof opt === "function") return opt(ctx);
680
+ return opt;
681
+ }
682
+ async bootAutoEnqueue(ctx, opt) {
683
+ if (opt === false) return;
684
+ const engine = this.tryGetService(ctx, ["objectql", "data"]);
685
+ const realtime = this.tryGetService(ctx, ["realtime"]);
686
+ if (!engine || !realtime) {
687
+ ctx.logger.warn?.(
688
+ "[webhook-auto-enqueuer] disabled \u2014 ObjectQL or Realtime service not available",
689
+ { hasEngine: !!engine, hasRealtime: !!realtime }
690
+ );
691
+ return;
692
+ }
693
+ if (!this.outboxInstance) return;
694
+ const enqOpts = typeof opt === "object" ? opt : {};
695
+ this.autoEnqueuer = new AutoEnqueuer(
696
+ engine,
697
+ realtime,
698
+ this.outboxInstance,
699
+ { ...enqOpts, logger: ctx.logger }
700
+ );
701
+ await this.autoEnqueuer.start();
702
+ ctx.registerService("webhook.autoEnqueuer", this.autoEnqueuer);
703
+ ctx.logger.info?.("[webhook-auto-enqueuer] started");
704
+ }
705
+ bootRetention(ctx, opt) {
706
+ if (opt === false) return;
707
+ if (this.outboxInstance instanceof MemoryWebhookOutbox) return;
708
+ const engine = this.tryGetService(ctx, ["objectql", "data"]);
709
+ if (!engine) {
710
+ ctx.logger.warn?.(
711
+ "[webhook-retention] disabled \u2014 ObjectQL service not available"
712
+ );
713
+ return;
714
+ }
715
+ const retOpts = typeof opt === "object" ? opt : {};
716
+ this.retention = new DeliveryRetentionSweeper(engine, {
717
+ ...retOpts,
718
+ logger: ctx.logger
719
+ });
720
+ this.retention.start();
721
+ ctx.registerService("webhook.retention", this.retention);
722
+ ctx.logger.info?.("[webhook-retention] sweeper started");
723
+ }
724
+ tryGetService(ctx, names) {
725
+ for (const n of names) {
726
+ try {
727
+ const svc = ctx.getService(n);
728
+ if (svc) return svc;
729
+ } catch {
730
+ }
731
+ }
732
+ return void 0;
733
+ }
734
+ };
735
+ export {
736
+ AutoEnqueuer,
737
+ DEFAULT_TIMEOUT_MS,
738
+ DeliveryRetentionSweeper,
739
+ MemoryWebhookOutbox,
740
+ WebhookDispatcher,
741
+ WebhookOutboxPlugin,
742
+ classifyAttempt,
743
+ hashPartition,
744
+ nextRetryDelayMs,
745
+ sendOnce
746
+ };
253
747
  //# sourceMappingURL=index.js.map