@orthacms/webhooks-server 0.5.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +19 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +33 -0
  6. package/dist/lib/application/dto/list-deliveries-query.dto.d.ts +8 -0
  7. package/dist/lib/application/dto/list-deliveries-query.dto.d.ts.map +1 -0
  8. package/dist/lib/application/dto/list-deliveries-query.dto.js +64 -0
  9. package/dist/lib/application/dto/save-webhook-endpoint.dto.d.ts +23 -0
  10. package/dist/lib/application/dto/save-webhook-endpoint.dto.d.ts.map +1 -0
  11. package/dist/lib/application/dto/save-webhook-endpoint.dto.js +127 -0
  12. package/dist/lib/application/webhook-endpoints.service.d.ts +82 -0
  13. package/dist/lib/application/webhook-endpoints.service.d.ts.map +1 -0
  14. package/dist/lib/application/webhook-endpoints.service.js +213 -0
  15. package/dist/lib/docs/describe-webhooks-api.d.ts +16 -0
  16. package/dist/lib/docs/describe-webhooks-api.d.ts.map +1 -0
  17. package/dist/lib/docs/describe-webhooks-api.js +170 -0
  18. package/dist/lib/docs/webhook-schemas.d.ts +21 -0
  19. package/dist/lib/docs/webhook-schemas.d.ts.map +1 -0
  20. package/dist/lib/docs/webhook-schemas.js +360 -0
  21. package/dist/lib/domain/errors/index.d.ts +3 -0
  22. package/dist/lib/domain/errors/index.d.ts.map +1 -0
  23. package/dist/lib/domain/errors/index.js +7 -0
  24. package/dist/lib/domain/errors/webhook-delivery-not-found.error.d.ts +12 -0
  25. package/dist/lib/domain/errors/webhook-delivery-not-found.error.d.ts.map +1 -0
  26. package/dist/lib/domain/errors/webhook-delivery-not-found.error.js +19 -0
  27. package/dist/lib/domain/errors/webhook-endpoint-not-found.error.d.ts +6 -0
  28. package/dist/lib/domain/errors/webhook-endpoint-not-found.error.d.ts.map +1 -0
  29. package/dist/lib/domain/errors/webhook-endpoint-not-found.error.js +13 -0
  30. package/dist/lib/domain/webhook-secret.d.ts +29 -0
  31. package/dist/lib/domain/webhook-secret.d.ts.map +1 -0
  32. package/dist/lib/domain/webhook-secret.js +34 -0
  33. package/dist/lib/domain/webhook-views.d.ts +85 -0
  34. package/dist/lib/domain/webhook-views.d.ts.map +1 -0
  35. package/dist/lib/domain/webhook-views.js +2 -0
  36. package/dist/lib/http/controllers/webhook-deliveries.controller.d.ts +28 -0
  37. package/dist/lib/http/controllers/webhook-deliveries.controller.d.ts.map +1 -0
  38. package/dist/lib/http/controllers/webhook-deliveries.controller.js +110 -0
  39. package/dist/lib/http/controllers/webhook-endpoints.controller.d.ts +61 -0
  40. package/dist/lib/http/controllers/webhook-endpoints.controller.d.ts.map +1 -0
  41. package/dist/lib/http/controllers/webhook-endpoints.controller.js +213 -0
  42. package/dist/lib/http/controllers/webhook-events.controller.d.ts +13 -0
  43. package/dist/lib/http/controllers/webhook-events.controller.d.ts.map +1 -0
  44. package/dist/lib/http/controllers/webhook-events.controller.js +38 -0
  45. package/dist/lib/infrastructure/event-mapping.d.ts +13 -0
  46. package/dist/lib/infrastructure/event-mapping.d.ts.map +1 -0
  47. package/dist/lib/infrastructure/event-mapping.js +69 -0
  48. package/dist/lib/infrastructure/purge/webhook-workspaces.purger.d.ts +42 -0
  49. package/dist/lib/infrastructure/purge/webhook-workspaces.purger.d.ts.map +1 -0
  50. package/dist/lib/infrastructure/purge/webhook-workspaces.purger.js +66 -0
  51. package/dist/lib/infrastructure/schema/index.d.ts +3 -0
  52. package/dist/lib/infrastructure/schema/index.d.ts.map +1 -0
  53. package/dist/lib/infrastructure/schema/index.js +8 -0
  54. package/dist/lib/infrastructure/schema/webhook-deliveries.d.ts +332 -0
  55. package/dist/lib/infrastructure/schema/webhook-deliveries.d.ts.map +1 -0
  56. package/dist/lib/infrastructure/schema/webhook-deliveries.js +92 -0
  57. package/dist/lib/infrastructure/schema/webhook-endpoints.d.ts +330 -0
  58. package/dist/lib/infrastructure/schema/webhook-endpoints.d.ts.map +1 -0
  59. package/dist/lib/infrastructure/schema/webhook-endpoints.js +94 -0
  60. package/dist/lib/infrastructure/webhook-delivery.repository.d.ts +99 -0
  61. package/dist/lib/infrastructure/webhook-delivery.repository.d.ts.map +1 -0
  62. package/dist/lib/infrastructure/webhook-delivery.repository.js +255 -0
  63. package/dist/lib/infrastructure/webhook-delivery.worker.d.ts +72 -0
  64. package/dist/lib/infrastructure/webhook-delivery.worker.d.ts.map +1 -0
  65. package/dist/lib/infrastructure/webhook-delivery.worker.js +216 -0
  66. package/dist/lib/infrastructure/webhook-endpoint.repository.d.ts +88 -0
  67. package/dist/lib/infrastructure/webhook-endpoint.repository.d.ts.map +1 -0
  68. package/dist/lib/infrastructure/webhook-endpoint.repository.js +306 -0
  69. package/dist/lib/infrastructure/webhook-fanout.subscriber.d.ts +36 -0
  70. package/dist/lib/infrastructure/webhook-fanout.subscriber.d.ts.map +1 -0
  71. package/dist/lib/infrastructure/webhook-fanout.subscriber.js +83 -0
  72. package/dist/lib/infrastructure/webhook-http.client.d.ts +76 -0
  73. package/dist/lib/infrastructure/webhook-http.client.d.ts.map +1 -0
  74. package/dist/lib/infrastructure/webhook-http.client.js +225 -0
  75. package/dist/lib/types/webhooks-config.d.ts +64 -0
  76. package/dist/lib/types/webhooks-config.d.ts.map +1 -0
  77. package/dist/lib/types/webhooks-config.js +24 -0
  78. package/dist/lib/utils/webhooks-plugin.d.ts +35 -0
  79. package/dist/lib/utils/webhooks-plugin.d.ts.map +1 -0
  80. package/dist/lib/utils/webhooks-plugin.js +90 -0
  81. package/dist/lib/webhooks.module.d.ts +18 -0
  82. package/dist/lib/webhooks.module.d.ts.map +1 -0
  83. package/dist/lib/webhooks.module.js +72 -0
  84. package/dist/lib/webhooks.tokens.d.ts +5 -0
  85. package/dist/lib/webhooks.tokens.d.ts.map +1 -0
  86. package/dist/lib/webhooks.tokens.js +9 -0
  87. package/migrations/0000_init_webhooks.sql +53 -0
  88. package/migrations/meta/0000_snapshot.json +424 -0
  89. package/migrations/meta/_journal.json +13 -0
  90. package/package.json +45 -0
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebhookDeliveryRepository = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@nestjs/common");
6
+ const database_1 = require("@orthacms/database");
7
+ const drizzle_orm_1 = require("drizzle-orm");
8
+ const webhook_deliveries_1 = require("./schema/webhook-deliveries");
9
+ /** The queue and the log, over one table. */
10
+ let WebhookDeliveryRepository = class WebhookDeliveryRepository {
11
+ db;
12
+ constructor(db) {
13
+ this.db = db;
14
+ }
15
+ /**
16
+ * Queues one delivery per endpoint, ignoring rows that already exist.
17
+ *
18
+ * `ON CONFLICT DO NOTHING` against `unique(endpoint_id, event_id)` is the
19
+ * whole idempotency story for fan-out: outbox delivery is at-least-once, so
20
+ * this subscriber may be handed the same event twice, and the second pass
21
+ * must be a no-op rather than a duplicate POST.
22
+ */
23
+ async enqueue(deliveries) {
24
+ if (deliveries.length === 0)
25
+ return 0;
26
+ const rows = await this.db
27
+ .insert(webhook_deliveries_1.webhookDeliveries)
28
+ .values(deliveries.map((delivery) => ({
29
+ id: delivery.id,
30
+ endpointId: delivery.endpointId,
31
+ eventId: delivery.eventId,
32
+ eventKind: delivery.eventKind,
33
+ workspaceId: delivery.workspaceId,
34
+ contentType: delivery.contentType,
35
+ payload: delivery.payload,
36
+ status: 'pending'
37
+ })))
38
+ .onConflictDoNothing({
39
+ // Names the partial index explicitly: without the predicate
40
+ // Postgres cannot tell which of the table's unique indexes this
41
+ // arbiter refers to.
42
+ target: [
43
+ webhook_deliveries_1.webhookDeliveries.endpointId,
44
+ webhook_deliveries_1.webhookDeliveries.eventId
45
+ ],
46
+ where: (0, drizzle_orm_1.sql) `${webhook_deliveries_1.webhookDeliveries.redeliveryOf} is null`
47
+ })
48
+ .returning({ id: webhook_deliveries_1.webhookDeliveries.id });
49
+ return rows.length;
50
+ }
51
+ /** Queues a single delivery and returns its id (used by redelivery). */
52
+ async enqueueOne(delivery) {
53
+ const [row] = await this.db
54
+ .insert(webhook_deliveries_1.webhookDeliveries)
55
+ .values({
56
+ id: delivery.id,
57
+ endpointId: delivery.endpointId,
58
+ eventId: delivery.eventId,
59
+ eventKind: delivery.eventKind,
60
+ workspaceId: delivery.workspaceId,
61
+ contentType: delivery.contentType,
62
+ payload: delivery.payload,
63
+ redeliveryOf: delivery.redeliveryOf ?? null,
64
+ status: 'pending'
65
+ })
66
+ .returning({ id: webhook_deliveries_1.webhookDeliveries.id });
67
+ return row.id;
68
+ }
69
+ /**
70
+ * Claims up to `limit` deliveries and marks them `delivering`.
71
+ *
72
+ * The transaction covers **only the claim**. That is the point of the whole
73
+ * design: the HTTP request happens after this returns, with nothing open,
74
+ * so a slow receiver cannot hold a database transaction or a pool client.
75
+ *
76
+ * `FOR UPDATE SKIP LOCKED` lets several API processes claim disjoint rows
77
+ * without blocking each other, exactly as the outbox drain does.
78
+ *
79
+ * Rows stuck in `delivering` past `claimTimeoutMs` are reclaimed here
80
+ * rather than by a separate reaper: a worker that died mid-request left the
81
+ * row with no error, no retry and no way out, and folding the recovery into
82
+ * the claim means there is one query to get right instead of two.
83
+ */
84
+ async claim(limit, claimTimeoutMs) {
85
+ const now = new Date();
86
+ const staleBefore = new Date(now.getTime() - claimTimeoutMs);
87
+ return this.db.transaction(async (tx) => {
88
+ const rows = await tx
89
+ .select({
90
+ id: webhook_deliveries_1.webhookDeliveries.id,
91
+ endpointId: webhook_deliveries_1.webhookDeliveries.endpointId,
92
+ eventId: webhook_deliveries_1.webhookDeliveries.eventId,
93
+ eventKind: webhook_deliveries_1.webhookDeliveries.eventKind,
94
+ workspaceId: webhook_deliveries_1.webhookDeliveries.workspaceId,
95
+ payload: webhook_deliveries_1.webhookDeliveries.payload,
96
+ attempts: webhook_deliveries_1.webhookDeliveries.attempts
97
+ })
98
+ .from(webhook_deliveries_1.webhookDeliveries)
99
+ .where((0, drizzle_orm_1.or)((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(webhook_deliveries_1.webhookDeliveries.status, [
100
+ 'pending',
101
+ 'failed'
102
+ ]), (0, drizzle_orm_1.or)((0, drizzle_orm_1.sql) `${webhook_deliveries_1.webhookDeliveries.nextAttemptAt} is null`, (0, drizzle_orm_1.lte)(webhook_deliveries_1.webhookDeliveries.nextAttemptAt, now))), (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(webhook_deliveries_1.webhookDeliveries.status, 'delivering'), (0, drizzle_orm_1.lt)(webhook_deliveries_1.webhookDeliveries.claimedAt, staleBefore))))
103
+ .orderBy(webhook_deliveries_1.webhookDeliveries.createdAt)
104
+ .limit(limit)
105
+ .for('update', { skipLocked: true });
106
+ if (rows.length === 0)
107
+ return [];
108
+ await tx
109
+ .update(webhook_deliveries_1.webhookDeliveries)
110
+ .set({ status: 'delivering', claimedAt: now })
111
+ .where((0, drizzle_orm_1.inArray)(webhook_deliveries_1.webhookDeliveries.id, rows.map((row) => row.id)));
112
+ return rows;
113
+ });
114
+ }
115
+ /** Records a successful attempt. */
116
+ async markSucceeded(id, outcome) {
117
+ await this.db
118
+ .update(webhook_deliveries_1.webhookDeliveries)
119
+ .set({
120
+ status: 'succeeded',
121
+ attempts: (0, drizzle_orm_1.sql) `${webhook_deliveries_1.webhookDeliveries.attempts} + 1`,
122
+ claimedAt: null,
123
+ nextAttemptAt: null,
124
+ lastStatusCode: outcome.statusCode,
125
+ lastError: null,
126
+ responseSnippet: outcome.responseSnippet,
127
+ durationMs: outcome.durationMs,
128
+ completedAt: new Date()
129
+ })
130
+ .where((0, drizzle_orm_1.eq)(webhook_deliveries_1.webhookDeliveries.id, id));
131
+ }
132
+ /** Records a failed attempt that will be tried again at `nextAttemptAt`. */
133
+ async markRetrying(id, outcome, nextAttemptAt) {
134
+ await this.db
135
+ .update(webhook_deliveries_1.webhookDeliveries)
136
+ .set({
137
+ status: 'failed',
138
+ attempts: (0, drizzle_orm_1.sql) `${webhook_deliveries_1.webhookDeliveries.attempts} + 1`,
139
+ claimedAt: null,
140
+ nextAttemptAt,
141
+ lastStatusCode: outcome.statusCode,
142
+ lastError: outcome.error,
143
+ responseSnippet: outcome.responseSnippet,
144
+ durationMs: outcome.durationMs
145
+ })
146
+ .where((0, drizzle_orm_1.eq)(webhook_deliveries_1.webhookDeliveries.id, id));
147
+ }
148
+ /** Records a final failure — no further attempt will be made. */
149
+ async markDead(id, outcome) {
150
+ await this.db
151
+ .update(webhook_deliveries_1.webhookDeliveries)
152
+ .set({
153
+ status: 'dead',
154
+ attempts: (0, drizzle_orm_1.sql) `${webhook_deliveries_1.webhookDeliveries.attempts} + 1`,
155
+ claimedAt: null,
156
+ nextAttemptAt: null,
157
+ lastStatusCode: outcome.statusCode,
158
+ lastError: outcome.error,
159
+ responseSnippet: outcome.responseSnippet,
160
+ durationMs: outcome.durationMs,
161
+ completedAt: new Date()
162
+ })
163
+ .where((0, drizzle_orm_1.eq)(webhook_deliveries_1.webhookDeliveries.id, id));
164
+ }
165
+ /** One page of an endpoint's delivery log, newest first. */
166
+ async list(endpointId, query) {
167
+ const filters = [(0, drizzle_orm_1.eq)(webhook_deliveries_1.webhookDeliveries.endpointId, endpointId)];
168
+ if (query.status) {
169
+ filters.push((0, drizzle_orm_1.eq)(webhook_deliveries_1.webhookDeliveries.status, query.status));
170
+ }
171
+ if (query.eventKind) {
172
+ filters.push((0, drizzle_orm_1.eq)(webhook_deliveries_1.webhookDeliveries.eventKind, query.eventKind));
173
+ }
174
+ const where = (0, drizzle_orm_1.and)(...filters);
175
+ const [{ total }] = await this.db
176
+ .select({ total: (0, drizzle_orm_1.count)() })
177
+ .from(webhook_deliveries_1.webhookDeliveries)
178
+ .where(where);
179
+ const pageCount = Math.max(1, Math.ceil(total / query.pageSize));
180
+ // Clamp rather than return an empty page: the log shrinks under the
181
+ // reader (retention, a redelivery finishing), and a page number that
182
+ // was valid a second ago should show the last page, not nothing.
183
+ const page = Math.min(Math.max(query.page, 1), pageCount);
184
+ const rows = await this.db
185
+ .select()
186
+ .from(webhook_deliveries_1.webhookDeliveries)
187
+ .where(where)
188
+ .orderBy((0, drizzle_orm_1.desc)(webhook_deliveries_1.webhookDeliveries.createdAt))
189
+ .limit(query.pageSize)
190
+ .offset((page - 1) * query.pageSize);
191
+ return {
192
+ items: rows.map(toView),
193
+ total,
194
+ page,
195
+ pageSize: query.pageSize,
196
+ pageCount
197
+ };
198
+ }
199
+ /**
200
+ * One delivery with its bodies — scoped to its endpoint so an id from
201
+ * another endpoint reads as "not found" rather than leaking across.
202
+ */
203
+ async findDetail(endpointId, deliveryId) {
204
+ const [row] = await this.db
205
+ .select()
206
+ .from(webhook_deliveries_1.webhookDeliveries)
207
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(webhook_deliveries_1.webhookDeliveries.id, deliveryId), (0, drizzle_orm_1.eq)(webhook_deliveries_1.webhookDeliveries.endpointId, endpointId)))
208
+ .limit(1);
209
+ if (!row)
210
+ return null;
211
+ return {
212
+ ...toView(row),
213
+ payload: row.payload,
214
+ responseSnippet: row.responseSnippet
215
+ };
216
+ }
217
+ /**
218
+ * Deletes completed rows older than `before`.
219
+ *
220
+ * Only `succeeded` and `dead` are eligible: a `failed` row is still
221
+ * scheduled, and a `delivering` row may be in flight.
222
+ */
223
+ async pruneCompletedBefore(before) {
224
+ const rows = await this.db
225
+ .delete(webhook_deliveries_1.webhookDeliveries)
226
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.inArray)(webhook_deliveries_1.webhookDeliveries.status, ['succeeded', 'dead']), (0, drizzle_orm_1.isNotNull)(webhook_deliveries_1.webhookDeliveries.completedAt), (0, drizzle_orm_1.lt)(webhook_deliveries_1.webhookDeliveries.completedAt, before)))
227
+ .returning({ id: webhook_deliveries_1.webhookDeliveries.id });
228
+ return rows.length;
229
+ }
230
+ };
231
+ exports.WebhookDeliveryRepository = WebhookDeliveryRepository;
232
+ exports.WebhookDeliveryRepository = WebhookDeliveryRepository = tslib_1.__decorate([
233
+ (0, common_1.Injectable)(),
234
+ tslib_1.__param(0, (0, database_1.InjectDatabase)()),
235
+ tslib_1.__metadata("design:paramtypes", [Object])
236
+ ], WebhookDeliveryRepository);
237
+ /** Row → the API shape, minus the bodies. */
238
+ function toView(row) {
239
+ return {
240
+ id: row.id,
241
+ endpointId: row.endpointId,
242
+ eventId: row.eventId,
243
+ eventKind: row.eventKind,
244
+ workspaceId: row.workspaceId,
245
+ contentType: row.contentType,
246
+ status: row.status,
247
+ attempts: row.attempts,
248
+ nextAttemptAt: row.nextAttemptAt?.toISOString() ?? null,
249
+ statusCode: row.lastStatusCode,
250
+ error: row.lastError,
251
+ durationMs: row.durationMs,
252
+ createdAt: row.createdAt.toISOString(),
253
+ completedAt: row.completedAt?.toISOString() ?? null
254
+ };
255
+ }
@@ -0,0 +1,72 @@
1
+ import { type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common';
2
+ import type { ResolvedWebhooksConfig } from '../types/webhooks-config';
3
+ import { WebhookDeliveryRepository } from './webhook-delivery.repository';
4
+ import { WebhookEndpointRepository } from './webhook-endpoint.repository';
5
+ import { WebhookHttpClient } from './webhook-http.client';
6
+ /**
7
+ * Sends queued deliveries.
8
+ *
9
+ * **The one structural rule:** the claim runs in a transaction, the POST does
10
+ * not. `WebhookDeliveryRepository.claim` opens a short transaction, marks a
11
+ * batch `delivering` and commits; only then does this worker touch the network.
12
+ * A receiver that takes the full timeout therefore costs one row's progress,
13
+ * not a held pool client and not the outbox.
14
+ *
15
+ * **Why a plain interval and not a scheduler.** `OutboxDispatcher`'s poll
16
+ * backstop and the alarms sweep set the precedent: one timer in-process, with
17
+ * the work behind it made safe to run concurrently. `FOR UPDATE SKIP LOCKED`
18
+ * means several API processes running this take disjoint rows, so scaling out
19
+ * simply sends faster.
20
+ *
21
+ * Deliveries in one batch run **serially**. Twenty parallel requests would be a
22
+ * burst aimed at whichever receivers happen to be subscribed, and the whole
23
+ * point of the retry schedule's jitter is not to do that.
24
+ */
25
+ export declare class WebhookDeliveryWorker implements OnApplicationBootstrap, OnModuleDestroy {
26
+ private readonly deliveries;
27
+ private readonly endpoints;
28
+ private readonly http;
29
+ private readonly config;
30
+ private readonly logger;
31
+ private timer;
32
+ /** The tick in flight, so shutdown can wait for it. */
33
+ private running;
34
+ private stopped;
35
+ /** When the retention sweep last ran, so it is not run every tick. */
36
+ private lastPrunedAt;
37
+ constructor(deliveries: WebhookDeliveryRepository, endpoints: WebhookEndpointRepository, http: WebhookHttpClient, config: ResolvedWebhooksConfig);
38
+ /** Arms the interval, unless delivery is switched off. */
39
+ onApplicationBootstrap(): void;
40
+ /**
41
+ * Stops the timer and waits for the tick in flight.
42
+ *
43
+ * Waiting matters: a tick abandoned mid-batch leaves rows in `delivering`
44
+ * that only the claim's stale-row recovery will free, so the deliveries it
45
+ * had already sent would be retried after the timeout for no reason.
46
+ */
47
+ onModuleDestroy(): Promise<void>;
48
+ /** One guarded tick — skips if the previous one is still going. */
49
+ private tick;
50
+ /**
51
+ * Claims one batch, sends each delivery, then prunes if it is time.
52
+ *
53
+ * Public because "send what is claimable now" is a real operation and not
54
+ * only the timer's business: a deployment that runs the sender out of
55
+ * process, and the e2e suites, both need to drive a batch on demand rather
56
+ * than waiting for an interval to come round.
57
+ */
58
+ runOnce(): Promise<void>;
59
+ /** Sends one delivery and records what happened. */
60
+ private deliver;
61
+ /** Counts a dead delivery against the endpoint, disabling it at the cap. */
62
+ private penalise;
63
+ /**
64
+ * Deletes completed deliveries past the retention window, at most once an
65
+ * hour.
66
+ *
67
+ * The log is the only table here that nothing else prunes; without this it
68
+ * grows for the life of the deployment at the rate content is edited.
69
+ */
70
+ private pruneIfDue;
71
+ }
72
+ //# sourceMappingURL=webhook-delivery.worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-delivery.worker.d.ts","sourceRoot":"","sources":["../../../src/lib/infrastructure/webhook-delivery.worker.ts"],"names":[],"mappings":"AAAA,OAAO,EAGH,KAAK,sBAAsB,EAC3B,KAAK,eAAe,EACvB,MAAM,gBAAgB,CAAC;AAQxB,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AACvE,OAAO,EACH,yBAAyB,EAG5B,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;AAC1E,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBACa,qBACT,YAAW,sBAAsB,EAAE,eAAe;IAW9C,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,IAAI;IAErB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAb3B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0C;IACjE,OAAO,CAAC,KAAK,CAA+C;IAC5D,uDAAuD;IACvD,OAAO,CAAC,OAAO,CAA8B;IAC7C,OAAO,CAAC,OAAO,CAAS;IACxB,sEAAsE;IACtE,OAAO,CAAC,YAAY,CAAK;gBAGJ,UAAU,EAAE,yBAAyB,EACrC,SAAS,EAAE,yBAAyB,EACpC,IAAI,EAAE,iBAAiB,EAEvB,MAAM,EAAE,sBAAsB;IAGnD,0DAA0D;IAC1D,sBAAsB,IAAI,IAAI;IAa9B;;;;;;OAMG;IACG,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;IAStC,mEAAmE;YACrD,IAAI;IAQlB;;;;;;;OAOG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAuB9B,oDAAoD;YACtC,OAAO;IAwFrB,4EAA4E;YAC9D,QAAQ;IAgBtB;;;;;;OAMG;YACW,UAAU;CAgB3B"}
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ var WebhookDeliveryWorker_1;
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.WebhookDeliveryWorker = void 0;
5
+ const tslib_1 = require("tslib");
6
+ const common_1 = require("@nestjs/common");
7
+ const webhooks_domain_1 = require("@orthacms/webhooks-domain");
8
+ const webhooks_tokens_1 = require("../webhooks.tokens");
9
+ const webhook_delivery_repository_1 = require("./webhook-delivery.repository");
10
+ const webhook_endpoint_repository_1 = require("./webhook-endpoint.repository");
11
+ const webhook_http_client_1 = require("./webhook-http.client");
12
+ /**
13
+ * Sends queued deliveries.
14
+ *
15
+ * **The one structural rule:** the claim runs in a transaction, the POST does
16
+ * not. `WebhookDeliveryRepository.claim` opens a short transaction, marks a
17
+ * batch `delivering` and commits; only then does this worker touch the network.
18
+ * A receiver that takes the full timeout therefore costs one row's progress,
19
+ * not a held pool client and not the outbox.
20
+ *
21
+ * **Why a plain interval and not a scheduler.** `OutboxDispatcher`'s poll
22
+ * backstop and the alarms sweep set the precedent: one timer in-process, with
23
+ * the work behind it made safe to run concurrently. `FOR UPDATE SKIP LOCKED`
24
+ * means several API processes running this take disjoint rows, so scaling out
25
+ * simply sends faster.
26
+ *
27
+ * Deliveries in one batch run **serially**. Twenty parallel requests would be a
28
+ * burst aimed at whichever receivers happen to be subscribed, and the whole
29
+ * point of the retry schedule's jitter is not to do that.
30
+ */
31
+ let WebhookDeliveryWorker = WebhookDeliveryWorker_1 = class WebhookDeliveryWorker {
32
+ deliveries;
33
+ endpoints;
34
+ http;
35
+ config;
36
+ logger = new common_1.Logger(WebhookDeliveryWorker_1.name);
37
+ timer = null;
38
+ /** The tick in flight, so shutdown can wait for it. */
39
+ running = null;
40
+ stopped = false;
41
+ /** When the retention sweep last ran, so it is not run every tick. */
42
+ lastPrunedAt = 0;
43
+ constructor(deliveries, endpoints, http, config) {
44
+ this.deliveries = deliveries;
45
+ this.endpoints = endpoints;
46
+ this.http = http;
47
+ this.config = config;
48
+ }
49
+ /** Arms the interval, unless delivery is switched off. */
50
+ onApplicationBootstrap() {
51
+ const interval = this.config.deliveryIntervalMs;
52
+ if (interval <= 0) {
53
+ this.logger.log('Webhook delivery is disabled (deliveryIntervalMs is 0); events will queue but nothing will be sent from this process.');
54
+ return;
55
+ }
56
+ this.timer = setInterval(() => void this.tick(), interval);
57
+ // Never hold the process open for a background sender.
58
+ this.timer.unref?.();
59
+ }
60
+ /**
61
+ * Stops the timer and waits for the tick in flight.
62
+ *
63
+ * Waiting matters: a tick abandoned mid-batch leaves rows in `delivering`
64
+ * that only the claim's stale-row recovery will free, so the deliveries it
65
+ * had already sent would be retried after the timeout for no reason.
66
+ */
67
+ async onModuleDestroy() {
68
+ this.stopped = true;
69
+ if (this.timer) {
70
+ clearInterval(this.timer);
71
+ this.timer = null;
72
+ }
73
+ if (this.running)
74
+ await this.running;
75
+ }
76
+ /** One guarded tick — skips if the previous one is still going. */
77
+ async tick() {
78
+ if (this.running || this.stopped)
79
+ return;
80
+ this.running = this.runOnce().finally(() => {
81
+ this.running = null;
82
+ });
83
+ await this.running;
84
+ }
85
+ /**
86
+ * Claims one batch, sends each delivery, then prunes if it is time.
87
+ *
88
+ * Public because "send what is claimable now" is a real operation and not
89
+ * only the timer's business: a deployment that runs the sender out of
90
+ * process, and the e2e suites, both need to drive a batch on demand rather
91
+ * than waiting for an interval to come round.
92
+ */
93
+ async runOnce() {
94
+ try {
95
+ const claimed = await this.deliveries.claim(this.config.batchSize, this.config.claimTimeoutMs);
96
+ for (const delivery of claimed) {
97
+ if (this.stopped)
98
+ break;
99
+ await this.deliver(delivery);
100
+ }
101
+ await this.pruneIfDue();
102
+ }
103
+ catch (error) {
104
+ // A failing tick must never kill the interval; the next one retries
105
+ // from whatever state the database is in.
106
+ this.logger.error('A webhook delivery tick failed', error instanceof Error ? error.stack : String(error));
107
+ }
108
+ }
109
+ /** Sends one delivery and records what happened. */
110
+ async deliver(delivery) {
111
+ const endpoint = await this.endpoints.findForDelivery(delivery.endpointId);
112
+ if (!endpoint) {
113
+ // The endpoint was deleted between the claim and now. The row is on
114
+ // its way out by cascade; closing it keeps it from being re-claimed
115
+ // in the meantime.
116
+ await this.deliveries.markDead(delivery.id, {
117
+ statusCode: null,
118
+ error: 'The endpoint was deleted before this delivery was sent.',
119
+ responseSnippet: null,
120
+ durationMs: 0
121
+ });
122
+ return;
123
+ }
124
+ if (!endpoint.enabled) {
125
+ // Switched off — by hand or automatically — after this row was
126
+ // queued. Sending anyway would defeat the switch.
127
+ await this.deliveries.markDead(delivery.id, {
128
+ statusCode: null,
129
+ error: 'The endpoint was disabled before this delivery was sent.',
130
+ responseSnippet: null,
131
+ durationMs: 0
132
+ });
133
+ return;
134
+ }
135
+ const attempt = delivery.attempts + 1;
136
+ const response = await this.http.send({
137
+ url: endpoint.url,
138
+ secret: endpoint.secret,
139
+ headers: endpoint.headers,
140
+ body: delivery.payload,
141
+ eventKind: delivery.eventKind,
142
+ deliveryId: delivery.id,
143
+ eventId: delivery.eventId,
144
+ workspaceId: delivery.workspaceId,
145
+ attempt
146
+ });
147
+ const outcome = {
148
+ statusCode: response.statusCode,
149
+ error: response.error,
150
+ responseSnippet: response.responseSnippet,
151
+ durationMs: response.durationMs
152
+ };
153
+ // No status at all — a timeout, a refused connection, a rejected
154
+ // address — is a transport failure, which is retryable in the same way
155
+ // a 5xx is.
156
+ const verdict = response.statusCode === null
157
+ ? { outcome: 'retry' }
158
+ : (0, webhooks_domain_1.classifyStatus)(response.statusCode, response.retryAfterMs);
159
+ if (verdict.outcome === 'succeeded') {
160
+ await this.deliveries.markSucceeded(delivery.id, outcome);
161
+ await this.endpoints.recordSuccess(endpoint.id);
162
+ return;
163
+ }
164
+ if (verdict.outcome === 'dead' ||
165
+ (0, webhooks_domain_1.isExhausted)(attempt, this.config.maxAttempts)) {
166
+ await this.deliveries.markDead(delivery.id, {
167
+ ...outcome,
168
+ error: verdict.outcome === 'dead'
169
+ ? verdict.reason
170
+ : (outcome.error ??
171
+ `Gave up after ${attempt} attempts.`)
172
+ });
173
+ await this.penalise(endpoint.id, endpoint.name);
174
+ return;
175
+ }
176
+ const delayMs = verdict.retryAfterMs ?? (0, webhooks_domain_1.nextAttemptDelayMs)(attempt);
177
+ await this.deliveries.markRetrying(delivery.id, outcome, new Date(Date.now() + delayMs));
178
+ }
179
+ /** Counts a dead delivery against the endpoint, disabling it at the cap. */
180
+ async penalise(endpointId, endpointName) {
181
+ const disabled = await this.endpoints.recordFailure(endpointId, this.config.autoDisableAfter);
182
+ if (disabled) {
183
+ this.logger.warn(`Webhook endpoint "${endpointName}" (${endpointId}) was disabled after ` +
184
+ `${this.config.autoDisableAfter} consecutive failed deliveries.`);
185
+ }
186
+ }
187
+ /**
188
+ * Deletes completed deliveries past the retention window, at most once an
189
+ * hour.
190
+ *
191
+ * The log is the only table here that nothing else prunes; without this it
192
+ * grows for the life of the deployment at the rate content is edited.
193
+ */
194
+ async pruneIfDue() {
195
+ const retentionDays = this.config.retentionDays;
196
+ if (retentionDays <= 0)
197
+ return;
198
+ const hour = 60 * 60_000;
199
+ if (Date.now() - this.lastPrunedAt < hour)
200
+ return;
201
+ this.lastPrunedAt = Date.now();
202
+ const cutoff = new Date(Date.now() - retentionDays * 24 * hour);
203
+ const removed = await this.deliveries.pruneCompletedBefore(cutoff);
204
+ if (removed > 0) {
205
+ this.logger.log(`Pruned ${removed} webhook deliveries older than ${retentionDays} days.`);
206
+ }
207
+ }
208
+ };
209
+ exports.WebhookDeliveryWorker = WebhookDeliveryWorker;
210
+ exports.WebhookDeliveryWorker = WebhookDeliveryWorker = WebhookDeliveryWorker_1 = tslib_1.__decorate([
211
+ (0, common_1.Injectable)(),
212
+ tslib_1.__param(3, (0, webhooks_tokens_1.InjectWebhooksConfig)()),
213
+ tslib_1.__metadata("design:paramtypes", [webhook_delivery_repository_1.WebhookDeliveryRepository,
214
+ webhook_endpoint_repository_1.WebhookEndpointRepository,
215
+ webhook_http_client_1.WebhookHttpClient, Object])
216
+ ], WebhookDeliveryWorker);
@@ -0,0 +1,88 @@
1
+ import { type Database } from '@orthacms/database';
2
+ import type { WebhookEndpointView } from '../domain/webhook-views';
3
+ /** The fields a create or update writes. */
4
+ export interface EndpointWriteModel {
5
+ name: string;
6
+ url: string;
7
+ enabled: boolean;
8
+ eventKinds: string[];
9
+ contentTypes: string[];
10
+ allWorkspaces: boolean;
11
+ workspaceIds: string[];
12
+ headers: Record<string, string>;
13
+ }
14
+ /** An endpoint as the delivery path needs it — secret included. */
15
+ export interface EndpointWithSecret {
16
+ id: string;
17
+ name: string;
18
+ url: string;
19
+ secret: string;
20
+ headers: Record<string, string>;
21
+ enabled: boolean;
22
+ }
23
+ /** The subscription-relevant columns, for fan-out. */
24
+ export interface EndpointSubscriptionRow {
25
+ id: string;
26
+ enabled: boolean;
27
+ allWorkspaces: boolean;
28
+ workspaceIds: string[];
29
+ eventKinds: string[];
30
+ contentTypes: string[];
31
+ }
32
+ /** Reads and writes over the two endpoint tables. */
33
+ export declare class WebhookEndpointRepository {
34
+ private readonly db;
35
+ constructor(db: Database);
36
+ /**
37
+ * Every enabled endpoint with its workspace set, for fan-out.
38
+ *
39
+ * One query with an aggregated sub-select rather than a join: a join would
40
+ * return one row per workspace and force the caller to regroup, and this
41
+ * runs on every content write.
42
+ */
43
+ listSubscriptions(): Promise<EndpointSubscriptionRow[]>;
44
+ /** The endpoint the worker is about to POST to, or null when it is gone. */
45
+ findForDelivery(id: string): Promise<EndpointWithSecret | null>;
46
+ /** Every endpoint, newest first, each with its last delivery. */
47
+ list(): Promise<WebhookEndpointView[]>;
48
+ /** One endpoint, or null. */
49
+ findById(id: string): Promise<WebhookEndpointView | null>;
50
+ /**
51
+ * Creates an endpoint and its workspace set in one transaction, so an
52
+ * endpoint can never exist with half its subscription.
53
+ */
54
+ create(model: EndpointWriteModel, secret: string, createdBy: string | null): Promise<string>;
55
+ /** Applies a full write model to an existing endpoint. */
56
+ update(id: string, model: EndpointWriteModel): Promise<void>;
57
+ /** Rotates the signing secret. */
58
+ replaceSecret(id: string, secret: string): Promise<void>;
59
+ /** Deletes the endpoint; its workspaces and deliveries cascade. */
60
+ delete(id: string): Promise<void>;
61
+ /** Whether an endpoint with this id exists. */
62
+ exists(id: string): Promise<boolean>;
63
+ /** Clears the consecutive-failure count after a delivery succeeded. */
64
+ recordSuccess(id: string): Promise<void>;
65
+ /**
66
+ * Counts one dead delivery against the endpoint and switches it off once
67
+ * `threshold` of them have arrived in a row.
68
+ *
69
+ * The increment and the test are one statement so two workers finishing
70
+ * failed deliveries at the same moment cannot both read the old count and
71
+ * both decide not to disable.
72
+ *
73
+ * @returns whether this failure switched the endpoint off.
74
+ */
75
+ recordFailure(id: string, threshold: number): Promise<boolean>;
76
+ /** Replaces an endpoint's workspace rows to match `model`. */
77
+ private replaceWorkspaces;
78
+ /** Workspace ids per endpoint, for a set of endpoints. */
79
+ private workspacesFor;
80
+ /**
81
+ * The most recent delivery per endpoint.
82
+ *
83
+ * `DISTINCT ON` rather than a window function or N queries: one index scan
84
+ * over `(endpoint_id, created_at)` answers the whole list page.
85
+ */
86
+ private lastDeliveriesFor;
87
+ }
88
+ //# sourceMappingURL=webhook-endpoint.repository.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-endpoint.repository.d.ts","sourceRoot":"","sources":["../../../src/lib/infrastructure/webhook-endpoint.repository.ts"],"names":[],"mappings":"AACA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAGnE,OAAO,KAAK,EACR,mBAAmB,EAEtB,MAAM,yBAAyB,CAAC;AAQjC,4CAA4C;AAC5C,MAAM,WAAW,kBAAkB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC;AAED,mEAAmE;AACnE,MAAM,WAAW,kBAAkB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,OAAO,EAAE,OAAO,CAAC;CACpB;AAED,sDAAsD;AACtD,MAAM,WAAW,uBAAuB;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,YAAY,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,qDAAqD;AACrD,qBACa,yBAAyB;IACJ,OAAO,CAAC,QAAQ,CAAC,EAAE;gBAAF,EAAE,EAAE,QAAQ;IAE3D;;;;;;OAMG;IACG,iBAAiB,IAAI,OAAO,CAAC,uBAAuB,EAAE,CAAC;IAyB7D,4EAA4E;IACtE,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAkBrE,iEAAiE;IAC3D,IAAI,IAAI,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAuB5C,6BAA6B;IACvB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;IAoB/D;;;OAGG;IACG,MAAM,CACR,KAAK,EAAE,kBAAkB,EACzB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,GAAG,IAAI,GACzB,OAAO,CAAC,MAAM,CAAC;IAuBlB,0DAA0D;IACpD,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAwBlE,kCAAkC;IAC5B,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW9D,mEAAmE;IAC7D,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMvC,+CAA+C;IACzC,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAS1C,uEAAuE;IACjE,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO9C;;;;;;;;;OASG;IACG,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAepE,8DAA8D;YAChD,iBAAiB;IAqB/B,0DAA0D;YAC5C,aAAa;IAiB3B;;;;;OAKG;YACW,iBAAiB;CAgClC"}