@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
@@ -0,0 +1,410 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * SqlWebhookOutbox contract test.
5
+ *
6
+ * Validates that `SqlWebhookOutbox` honours the same `IWebhookOutbox`
7
+ * semantics as `MemoryWebhookOutbox`, but on top of `IDataEngine`. We use
8
+ * a hand-rolled `FakeDataEngine` instead of booting ObjectQL + a real
9
+ * driver because:
10
+ *
11
+ * 1. The interesting bug surface is the *claim race* (UPDATE ... WHERE
12
+ * status='pending' must reject losers atomically). FakeDataEngine
13
+ * models this exactly.
14
+ * 2. Faster + zero glue.
15
+ *
16
+ * Coverage:
17
+ * - enqueue dedup (by event_id + webhook_id)
18
+ * - claim → ack happy path
19
+ * - claim ignores rows in other partitions
20
+ * - claim ignores rows whose next_retry_at is in the future
21
+ * - claim reaps stale in_flight rows past claim_ttl
22
+ * - ack(failure) increments attempts and schedules retry
23
+ * - ack(dead) marks terminal
24
+ * - concurrent claim() from many "workers" never double-claims a row
25
+ */
26
+
27
+ import { describe, expect, it } from 'vitest';
28
+ import type { IDataEngine } from '@objectstack/spec/contracts';
29
+ import { SqlWebhookOutbox } from './sql-outbox.js';
30
+ import { hashPartition } from './partition.js';
31
+ import type { EnqueueInput } from './outbox.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // FakeDataEngine — models the subset of ObjectQL semantics SqlWebhookOutbox
35
+ // relies on. Atomic per-call: every `update` claims the JS event loop until
36
+ // it returns, mirroring how a single SQL statement holds row locks.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface AnyRow {
40
+ [k: string]: any;
41
+ }
42
+
43
+ class FakeDataEngine implements IDataEngine {
44
+ readonly tables = new Map<string, AnyRow[]>();
45
+
46
+ private get(table: string): AnyRow[] {
47
+ if (!this.tables.has(table)) this.tables.set(table, []);
48
+ return this.tables.get(table)!;
49
+ }
50
+
51
+ async find(table: string, q?: any): Promise<any[]> {
52
+ const rows = this.get(table).filter((r) => matchWhere(r, q?.where));
53
+ const limit = q?.limit ?? rows.length;
54
+ return rows.slice(0, limit).map((r) => projectFields(r, q?.fields));
55
+ }
56
+
57
+ async findOne(table: string, q?: any): Promise<any> {
58
+ const rows = await this.find(table, { ...q, limit: 1 });
59
+ return rows[0] ?? null;
60
+ }
61
+
62
+ async insert(table: string, data: any): Promise<any> {
63
+ const arr = Array.isArray(data) ? data : [data];
64
+ for (const row of arr) {
65
+ // Enforce the unique index that the real SQL schema declares.
66
+ if (
67
+ this.get(table).some(
68
+ (r) =>
69
+ r.event_id === row.event_id &&
70
+ r.webhook_id === row.webhook_id,
71
+ )
72
+ ) {
73
+ throw new Error('UNIQUE constraint: event_id+webhook_id');
74
+ }
75
+ this.get(table).push({ ...row });
76
+ }
77
+ return arr;
78
+ }
79
+
80
+ async update(table: string, data: any, opts?: any): Promise<any> {
81
+ const rows = this.get(table);
82
+ let n = 0;
83
+ for (const r of rows) {
84
+ if (matchWhere(r, opts?.where)) {
85
+ Object.assign(r, data);
86
+ n += 1;
87
+ if (!opts?.multi) break;
88
+ }
89
+ }
90
+ return { affected: n };
91
+ }
92
+
93
+ async delete(table: string, opts?: any): Promise<any> {
94
+ const rows = this.get(table);
95
+ const keep = rows.filter((r) => !matchWhere(r, opts?.where));
96
+ const n = rows.length - keep.length;
97
+ this.tables.set(table, keep);
98
+ return { affected: n };
99
+ }
100
+
101
+ async count(table: string, q?: any): Promise<number> {
102
+ return this.get(table).filter((r) => matchWhere(r, q?.where)).length;
103
+ }
104
+
105
+ async aggregate(): Promise<any[]> {
106
+ throw new Error('not implemented for tests');
107
+ }
108
+ }
109
+
110
+ function projectFields(row: AnyRow, fields?: string[]): AnyRow {
111
+ if (!fields || fields.length === 0) return { ...row };
112
+ const out: AnyRow = {};
113
+ for (const f of fields) out[f] = row[f];
114
+ return out;
115
+ }
116
+
117
+ function matchWhere(row: AnyRow, where: any): boolean {
118
+ if (!where || Object.keys(where).length === 0) return true;
119
+ for (const [key, cond] of Object.entries(where)) {
120
+ if (key === '$or') {
121
+ const arr = cond as any[];
122
+ if (!arr.some((c) => matchWhere(row, c))) return false;
123
+ continue;
124
+ }
125
+ if (key === '$and') {
126
+ const arr = cond as any[];
127
+ if (!arr.every((c) => matchWhere(row, c))) return false;
128
+ continue;
129
+ }
130
+ if (cond === null) {
131
+ if (row[key] != null) return false;
132
+ continue;
133
+ }
134
+ if (typeof cond === 'object' && !Array.isArray(cond)) {
135
+ for (const [op, val] of Object.entries(cond as any)) {
136
+ switch (op) {
137
+ case '$lt':
138
+ if (!(row[key] != null && row[key] < (val as any))) return false;
139
+ break;
140
+ case '$lte':
141
+ if (!(row[key] != null && row[key] <= (val as any))) return false;
142
+ break;
143
+ case '$gt':
144
+ if (!(row[key] != null && row[key] > (val as any))) return false;
145
+ break;
146
+ case '$gte':
147
+ if (!(row[key] != null && row[key] >= (val as any))) return false;
148
+ break;
149
+ case '$in':
150
+ if (!(val as any[]).includes(row[key])) return false;
151
+ break;
152
+ case '$ne':
153
+ if (row[key] === val) return false;
154
+ break;
155
+ default:
156
+ throw new Error(`FakeDataEngine: unsupported op ${op}`);
157
+ }
158
+ }
159
+ continue;
160
+ }
161
+ if (row[key] !== cond) return false;
162
+ }
163
+ return true;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Tests
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const PARTITIONS = 8;
171
+
172
+ function newOutbox() {
173
+ const engine = new FakeDataEngine();
174
+ const outbox = new SqlWebhookOutbox(engine, { partitionCount: PARTITIONS });
175
+ return { engine, outbox };
176
+ }
177
+
178
+ function input(webhookId: string, eventId: string): EnqueueInput {
179
+ return {
180
+ webhookId,
181
+ eventId,
182
+ eventType: 'data.record.created',
183
+ url: 'https://example.test/hook',
184
+ payload: { hello: 'world' },
185
+ };
186
+ }
187
+
188
+ describe('SqlWebhookOutbox', () => {
189
+ it('enqueue inserts a row with precomputed partition_key', async () => {
190
+ const { engine, outbox } = newOutbox();
191
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
192
+
193
+ const stored = await engine.findOne('sys_webhook_delivery', {
194
+ where: { id },
195
+ });
196
+ expect(stored.partition_key).toBe(hashPartition('wh-1', PARTITIONS));
197
+ expect(stored.status).toBe('pending');
198
+ });
199
+
200
+ it('enqueue dedups by (event_id, webhook_id)', async () => {
201
+ const { outbox } = newOutbox();
202
+ const a = await outbox.enqueue(input('wh-1', 'ev-1'));
203
+ const b = await outbox.enqueue(input('wh-1', 'ev-1'));
204
+ expect(a).toBe(b);
205
+ });
206
+
207
+ it('enqueue tolerates concurrent dup INSERTs via unique-index fallback', async () => {
208
+ const { engine, outbox } = newOutbox();
209
+ // Pre-seed a winner row, then make the SqlOutbox think no row exists
210
+ // by inserting *after* its findOne — to simulate a real race we just
211
+ // call enqueue twice and confirm both return the same id.
212
+ const [a, b] = await Promise.all([
213
+ outbox.enqueue(input('wh-1', 'ev-1')),
214
+ outbox.enqueue(input('wh-1', 'ev-1')),
215
+ ]);
216
+ expect(a).toBe(b);
217
+ const all = await engine.find('sys_webhook_delivery', {});
218
+ expect(all).toHaveLength(1);
219
+ });
220
+
221
+ it('claim returns a row and marks it in_flight', async () => {
222
+ const { engine, outbox } = newOutbox();
223
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
224
+
225
+ const claimed = await outbox.claim({
226
+ nodeId: 'node-A',
227
+ limit: 10,
228
+ claimTtlMs: 60_000,
229
+ });
230
+ expect(claimed.map((c) => c.id)).toEqual([id]);
231
+
232
+ const stored = await engine.findOne('sys_webhook_delivery', {
233
+ where: { id },
234
+ });
235
+ expect(stored.status).toBe('in_flight');
236
+ expect(stored.claimed_by).toBe('node-A');
237
+ });
238
+
239
+ it('claim filters by partition', async () => {
240
+ const { outbox } = newOutbox();
241
+ // Find two webhook ids that fall in different partitions.
242
+ const ids: string[] = [];
243
+ for (let i = 0; i < 50 && ids.length < 2; i++) {
244
+ const wh = `wh-${i}`;
245
+ const p = hashPartition(wh, PARTITIONS);
246
+ if (ids.length === 0) ids.push(wh);
247
+ else if (hashPartition(ids[0], PARTITIONS) !== p) ids.push(wh);
248
+ }
249
+ const [whP0, whP1] = ids;
250
+ const p0 = hashPartition(whP0, PARTITIONS);
251
+ const p1 = hashPartition(whP1, PARTITIONS);
252
+
253
+ await outbox.enqueue(input(whP0, 'ev-a'));
254
+ await outbox.enqueue(input(whP1, 'ev-b'));
255
+
256
+ const claimed = await outbox.claim({
257
+ nodeId: 'node-A',
258
+ limit: 10,
259
+ claimTtlMs: 60_000,
260
+ partition: { index: p0, count: PARTITIONS },
261
+ });
262
+ expect(claimed).toHaveLength(1);
263
+ expect(claimed[0].webhookId).toBe(whP0);
264
+ expect(p0).not.toBe(p1);
265
+ });
266
+
267
+ it('claim skips rows whose next_retry_at is in the future', async () => {
268
+ const { engine, outbox } = newOutbox();
269
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
270
+ // Manually set a future retry.
271
+ await engine.update(
272
+ 'sys_webhook_delivery',
273
+ { next_retry_at: Date.now() + 60_000 },
274
+ { where: { id } },
275
+ );
276
+
277
+ const claimed = await outbox.claim({
278
+ nodeId: 'node-A',
279
+ limit: 10,
280
+ claimTtlMs: 60_000,
281
+ });
282
+ expect(claimed).toHaveLength(0);
283
+ });
284
+
285
+ it('claim reaps stale in_flight rows past claim_ttl', async () => {
286
+ const { engine, outbox } = newOutbox();
287
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
288
+ // Manually pretend a dead worker claimed it 5 minutes ago.
289
+ await engine.update(
290
+ 'sys_webhook_delivery',
291
+ {
292
+ status: 'in_flight',
293
+ claimed_by: 'dead-node',
294
+ claimed_at: Date.now() - 300_000,
295
+ },
296
+ { where: { id } },
297
+ );
298
+
299
+ const claimed = await outbox.claim({
300
+ nodeId: 'node-A',
301
+ limit: 10,
302
+ claimTtlMs: 60_000,
303
+ });
304
+ expect(claimed.map((c) => c.id)).toEqual([id]);
305
+ expect(claimed[0].claimedBy).toBe('node-A');
306
+ });
307
+
308
+ it('ack(success) marks success and increments attempts', async () => {
309
+ const { engine, outbox } = newOutbox();
310
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
311
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
312
+ await outbox.ack(id, { success: true, httpStatus: 200, durationMs: 12 });
313
+
314
+ const stored = await engine.findOne('sys_webhook_delivery', {
315
+ where: { id },
316
+ });
317
+ expect(stored.status).toBe('success');
318
+ expect(stored.attempts).toBe(1);
319
+ expect(stored.claimed_by).toBeNull();
320
+ });
321
+
322
+ it('ack(failure) schedules retry with status=pending', async () => {
323
+ const { engine, outbox } = newOutbox();
324
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
325
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
326
+ const retryAt = Date.now() + 5_000;
327
+ await outbox.ack(id, {
328
+ success: false,
329
+ httpStatus: 503,
330
+ error: 'upstream',
331
+ nextRetryAt: retryAt,
332
+ durationMs: 15,
333
+ });
334
+
335
+ const stored = await engine.findOne('sys_webhook_delivery', {
336
+ where: { id },
337
+ });
338
+ expect(stored.status).toBe('pending');
339
+ expect(stored.attempts).toBe(1);
340
+ expect(stored.next_retry_at).toBe(retryAt);
341
+ expect(stored.error).toBe('upstream');
342
+ });
343
+
344
+ it('ack(dead) marks terminal', async () => {
345
+ const { engine, outbox } = newOutbox();
346
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
347
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
348
+ await outbox.ack(id, {
349
+ success: false,
350
+ httpStatus: 400,
351
+ error: 'bad request',
352
+ dead: true,
353
+ durationMs: 5,
354
+ });
355
+
356
+ const stored = await engine.findOne('sys_webhook_delivery', {
357
+ where: { id },
358
+ });
359
+ expect(stored.status).toBe('dead');
360
+ expect(stored.next_retry_at).toBeNull();
361
+ });
362
+
363
+ it('concurrent claim() never double-claims a row', async () => {
364
+ // 200 rows, 10 "workers" all racing on the same partition. Each row
365
+ // must be claimed by exactly one worker.
366
+ const { engine, outbox } = newOutbox();
367
+ const target = hashPartition('wh-fixed', PARTITIONS);
368
+ for (let i = 0; i < 200; i++) {
369
+ await outbox.enqueue(input('wh-fixed', `ev-${i}`));
370
+ }
371
+
372
+ const workers = Array.from({ length: 10 }, (_, i) =>
373
+ outbox.claim({
374
+ nodeId: `worker-${i}`,
375
+ limit: 1000,
376
+ claimTtlMs: 60_000,
377
+ partition: { index: target, count: PARTITIONS },
378
+ }),
379
+ );
380
+ const results = await Promise.all(workers);
381
+ const allClaimed = results.flat();
382
+
383
+ // Total rows claimed equals 200 (no row missed)
384
+ expect(allClaimed.length).toBe(200);
385
+ // Each id appears exactly once across all workers
386
+ const ids = new Set(allClaimed.map((r) => r.id));
387
+ expect(ids.size).toBe(200);
388
+
389
+ // Every persisted row is now in_flight with claimed_by set
390
+ const stored = await engine.find('sys_webhook_delivery', {});
391
+ for (const r of stored) {
392
+ expect(r.status).toBe('in_flight');
393
+ expect(r.claimed_by).toMatch(/^worker-\d$/);
394
+ }
395
+ });
396
+
397
+ it('list filters by status', async () => {
398
+ const { outbox } = newOutbox();
399
+ const id1 = await outbox.enqueue(input('wh-1', 'ev-1'));
400
+ await outbox.enqueue(input('wh-2', 'ev-2'));
401
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
402
+ await outbox.ack(id1, { success: true, httpStatus: 200, durationMs: 1 });
403
+
404
+ const success = await outbox.list({ status: 'success' });
405
+ expect(success.map((r) => r.id)).toEqual([id1]);
406
+
407
+ const inFlight = await outbox.list({ status: 'in_flight' });
408
+ expect(inFlight).toHaveLength(1);
409
+ });
410
+ });
@@ -0,0 +1,282 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { randomUUID } from 'node:crypto';
4
+ import type { IDataEngine } from '@objectstack/spec/contracts';
5
+ import type {
6
+ AckResult,
7
+ ClaimOptions,
8
+ DeliveryStatus,
9
+ EnqueueInput,
10
+ IWebhookOutbox,
11
+ WebhookDelivery,
12
+ } from './outbox.js';
13
+ import { hashPartition } from './partition.js';
14
+ import { SYS_WEBHOOK_DELIVERY } from './schema.js';
15
+
16
+ export interface SqlWebhookOutboxOptions {
17
+ /**
18
+ * Total partition count — MUST match the dispatcher's `partitionCount`.
19
+ * Used at enqueue time to precompute `partition_key`.
20
+ */
21
+ partitionCount: number;
22
+ /**
23
+ * Object name to read/write. Defaults to `sys_webhook_delivery`. Override
24
+ * only if you've registered the schema under a different name.
25
+ */
26
+ objectName?: string;
27
+ }
28
+
29
+ interface DeliveryRow {
30
+ id: string;
31
+ webhook_id: string;
32
+ event_id: string;
33
+ event_type: string;
34
+ url: string;
35
+ method?: string | null;
36
+ headers_json?: string | null;
37
+ secret?: string | null;
38
+ timeout_ms?: number | null;
39
+ payload_json: string;
40
+ partition_key: number;
41
+ status: DeliveryStatus;
42
+ attempts: number;
43
+ claimed_by?: string | null;
44
+ claimed_at?: number | null;
45
+ next_retry_at?: number | null;
46
+ last_attempted_at?: number | null;
47
+ response_code?: number | null;
48
+ response_body?: string | null;
49
+ error?: string | null;
50
+ created_at: number;
51
+ updated_at: number;
52
+ }
53
+
54
+ /**
55
+ * Durable `IWebhookOutbox` backed by ObjectQL — the production storage
56
+ * impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)
57
+ * because everything goes through the driver-agnostic `IDataEngine` API.
58
+ *
59
+ * **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that
60
+ * SQL feature is Postgres-only. We get equivalent safety from two layers:
61
+ *
62
+ * 1. `cluster.lock` held per partition by the dispatcher (the primary
63
+ * mutex). One node owns one partition at a time → no two claimers.
64
+ * 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two
65
+ * claimers slip through (e.g. admin reschedule + dispatcher), only
66
+ * the first UPDATE matches each row.
67
+ *
68
+ * **Why precompute `partition_key` on enqueue?** ObjectQL has no
69
+ * cross-driver `hash()` function in WHERE clauses. Storing the partition
70
+ * as a column makes the claim query a plain indexed lookup.
71
+ *
72
+ * **Dedup race**: SELECT-then-INSERT has a tiny window where two
73
+ * concurrent producers both miss the SELECT and both INSERT. The unique
74
+ * index `(event_id, webhook_id)` on the table catches it — the second
75
+ * INSERT errors, the producer ignores it. Receivers MUST be idempotent
76
+ * on the `X-Objectstack-Delivery` header anyway.
77
+ */
78
+ export class SqlWebhookOutbox implements IWebhookOutbox {
79
+ private readonly objectName: string;
80
+ private readonly partitionCount: number;
81
+
82
+ constructor(
83
+ private readonly engine: IDataEngine,
84
+ opts: SqlWebhookOutboxOptions,
85
+ ) {
86
+ if (opts.partitionCount <= 0) {
87
+ throw new Error('SqlWebhookOutbox: partitionCount must be > 0');
88
+ }
89
+ this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;
90
+ this.partitionCount = opts.partitionCount;
91
+ }
92
+
93
+ async enqueue(input: EnqueueInput): Promise<string> {
94
+ // Cheap pre-check to absorb most duplicates without hitting the
95
+ // unique-index error path. Race window with the INSERT below is
96
+ // intentional and documented.
97
+ const existing = await this.engine.findOne(this.objectName, {
98
+ where: { event_id: input.eventId, webhook_id: input.webhookId },
99
+ fields: ['id'],
100
+ });
101
+ if (existing?.id) return existing.id as string;
102
+
103
+ const id = randomUUID();
104
+ const now = Date.now();
105
+ const row: Omit<DeliveryRow, 'response_body' | 'error'> = {
106
+ id,
107
+ webhook_id: input.webhookId,
108
+ event_id: input.eventId,
109
+ event_type: input.eventType,
110
+ url: input.url,
111
+ method: input.method ?? 'POST',
112
+ headers_json: input.headers ? JSON.stringify(input.headers) : undefined,
113
+ secret: input.secret,
114
+ timeout_ms: input.timeoutMs,
115
+ payload_json: JSON.stringify(input.payload ?? null),
116
+ partition_key: hashPartition(input.webhookId, this.partitionCount),
117
+ status: 'pending',
118
+ attempts: 0,
119
+ created_at: now,
120
+ updated_at: now,
121
+ };
122
+ try {
123
+ await this.engine.insert(this.objectName, row);
124
+ return id;
125
+ } catch (err) {
126
+ // Unique-index collision (dedup race) → look up the winner and
127
+ // return its id. Any other error propagates.
128
+ const winner = await this.engine.findOne(this.objectName, {
129
+ where: { event_id: input.eventId, webhook_id: input.webhookId },
130
+ fields: ['id'],
131
+ });
132
+ if (winner?.id) return winner.id as string;
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ async claim(opts: ClaimOptions): Promise<WebhookDelivery[]> {
138
+ const now = opts.now ?? Date.now();
139
+
140
+ // 1. Reap stale in_flight rows — visibility-timeout recovery.
141
+ await this.engine.update(
142
+ this.objectName,
143
+ { status: 'pending', claimed_by: null, claimed_at: null, updated_at: now },
144
+ {
145
+ where: {
146
+ status: 'in_flight',
147
+ claimed_at: { $lt: now - opts.claimTtlMs },
148
+ },
149
+ multi: true,
150
+ },
151
+ );
152
+
153
+ // 2. Pick candidate ids.
154
+ const partitionFilter = opts.partition
155
+ ? { partition_key: opts.partition.index }
156
+ : {};
157
+ const candidates = await this.engine.find(this.objectName, {
158
+ where: {
159
+ status: 'pending',
160
+ ...partitionFilter,
161
+ // next_retry_at <= now OR null
162
+ $or: [
163
+ { next_retry_at: null },
164
+ { next_retry_at: { $lte: now } },
165
+ ],
166
+ },
167
+ fields: ['id'],
168
+ // No orderBy for portability — drivers handle the natural insert order.
169
+ limit: opts.limit,
170
+ });
171
+ if (candidates.length === 0) return [];
172
+
173
+ const ids = (candidates as Array<{ id: string }>).map((c) => c.id);
174
+
175
+ // 3. Atomic claim. WHERE status='pending' rejects any rows another
176
+ // worker swept up between steps 2 and 3.
177
+ await this.engine.update(
178
+ this.objectName,
179
+ {
180
+ status: 'in_flight',
181
+ claimed_by: opts.nodeId,
182
+ claimed_at: now,
183
+ updated_at: now,
184
+ },
185
+ {
186
+ where: { id: { $in: ids }, status: 'pending' },
187
+ multi: true,
188
+ },
189
+ );
190
+
191
+ // 4. Read back the rows we actually own.
192
+ const claimed = (await this.engine.find(this.objectName, {
193
+ where: {
194
+ id: { $in: ids },
195
+ claimed_by: opts.nodeId,
196
+ claimed_at: now,
197
+ status: 'in_flight',
198
+ },
199
+ })) as DeliveryRow[];
200
+
201
+ return claimed.map((r) => this.toDelivery(r));
202
+ }
203
+
204
+ async ack(id: string, result: AckResult): Promise<void> {
205
+ // ObjectQL has no atomic $inc across drivers, so read-then-write.
206
+ // Safe enough: ack is single-writer per row (only the claimer acks).
207
+ const current = (await this.engine.findOne(this.objectName, {
208
+ where: { id },
209
+ fields: ['attempts'],
210
+ })) as { attempts?: number } | null;
211
+ if (!current) return;
212
+
213
+ const now = Date.now();
214
+ let status: DeliveryStatus;
215
+ let nextRetryAt: number | null;
216
+ let error: string | null;
217
+
218
+ if (result.success) {
219
+ status = 'success';
220
+ nextRetryAt = null;
221
+ error = null;
222
+ } else if (result.dead) {
223
+ status = 'dead';
224
+ nextRetryAt = null;
225
+ error = result.error ?? null;
226
+ } else {
227
+ status = 'pending';
228
+ nextRetryAt = result.nextRetryAt ?? null;
229
+ error = result.error ?? null;
230
+ }
231
+
232
+ await this.engine.update(
233
+ this.objectName,
234
+ {
235
+ status,
236
+ attempts: (current.attempts ?? 0) + 1,
237
+ last_attempted_at: now,
238
+ claimed_by: null,
239
+ claimed_at: null,
240
+ response_code: result.httpStatus ?? null,
241
+ response_body: result.responseBody ?? null,
242
+ next_retry_at: nextRetryAt,
243
+ error,
244
+ updated_at: now,
245
+ },
246
+ { where: { id }, multi: false },
247
+ );
248
+ }
249
+
250
+ async list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]> {
251
+ const rows = (await this.engine.find(this.objectName, {
252
+ where: filter?.status ? { status: filter.status } : {},
253
+ })) as DeliveryRow[];
254
+ return rows.map((r) => this.toDelivery(r));
255
+ }
256
+
257
+ private toDelivery(r: DeliveryRow): WebhookDelivery {
258
+ return {
259
+ id: r.id,
260
+ webhookId: r.webhook_id,
261
+ eventId: r.event_id,
262
+ eventType: r.event_type,
263
+ url: r.url,
264
+ method: r.method ?? undefined,
265
+ headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
266
+ secret: r.secret ?? undefined,
267
+ timeoutMs: r.timeout_ms ?? undefined,
268
+ payload: JSON.parse(r.payload_json),
269
+ status: r.status,
270
+ attempts: r.attempts,
271
+ claimedBy: r.claimed_by ?? undefined,
272
+ claimedAt: r.claimed_at ?? undefined,
273
+ nextRetryAt: r.next_retry_at ?? undefined,
274
+ lastAttemptedAt: r.last_attempted_at ?? undefined,
275
+ responseCode: r.response_code ?? undefined,
276
+ responseBody: r.response_body ?? undefined,
277
+ error: r.error ?? undefined,
278
+ createdAt: r.created_at,
279
+ updatedAt: r.updated_at,
280
+ };
281
+ }
282
+ }