@objectstack/plugin-webhooks 7.5.0 → 7.6.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 (52) hide show
  1. package/.turbo/turbo-build.log +20 -32
  2. package/CHANGELOG.md +49 -0
  3. package/dist/chunk-HWFTXTTI.js +138 -0
  4. package/dist/chunk-HWFTXTTI.js.map +1 -0
  5. package/dist/chunk-KPKLAXNA.cjs +138 -0
  6. package/dist/chunk-KPKLAXNA.cjs.map +1 -0
  7. package/dist/index.cjs +62 -616
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +41 -325
  10. package/dist/index.d.ts +41 -325
  11. package/dist/index.js +52 -606
  12. package/dist/index.js.map +1 -1
  13. package/dist/schema.cjs +2 -6
  14. package/dist/schema.cjs.map +1 -1
  15. package/dist/schema.d.cts +5 -4764
  16. package/dist/schema.d.ts +5 -4764
  17. package/dist/schema.js +3 -7
  18. package/package.json +4 -11
  19. package/src/auto-enqueuer.test.ts +83 -116
  20. package/src/auto-enqueuer.ts +38 -27
  21. package/src/index.ts +13 -40
  22. package/src/schema.ts +11 -16
  23. package/src/webhook-outbox-plugin.ts +80 -296
  24. package/tsup.config.ts +1 -1
  25. package/dist/chunk-7HS5DLU2.js +0 -319
  26. package/dist/chunk-7HS5DLU2.js.map +0 -1
  27. package/dist/chunk-HF7CCDPB.cjs +0 -256
  28. package/dist/chunk-HF7CCDPB.cjs.map +0 -1
  29. package/dist/chunk-KNGLLSSP.js +0 -256
  30. package/dist/chunk-KNGLLSSP.js.map +0 -1
  31. package/dist/chunk-TDSI7UHY.cjs +0 -319
  32. package/dist/chunk-TDSI7UHY.cjs.map +0 -1
  33. package/dist/outbox-CIn7LSyB.d.cts +0 -155
  34. package/dist/outbox-CIn7LSyB.d.ts +0 -155
  35. package/dist/sql-outbox.cjs +0 -8
  36. package/dist/sql-outbox.cjs.map +0 -1
  37. package/dist/sql-outbox.d.cts +0 -55
  38. package/dist/sql-outbox.d.ts +0 -55
  39. package/dist/sql-outbox.js +0 -8
  40. package/dist/sql-outbox.js.map +0 -1
  41. package/src/dispatcher.test.ts +0 -324
  42. package/src/dispatcher.ts +0 -218
  43. package/src/http-sender.ts +0 -187
  44. package/src/memory-outbox.test.ts +0 -86
  45. package/src/memory-outbox.ts +0 -155
  46. package/src/outbox.ts +0 -175
  47. package/src/partition.ts +0 -19
  48. package/src/retention.test.ts +0 -116
  49. package/src/retention.ts +0 -144
  50. package/src/sql-outbox.test.ts +0 -490
  51. package/src/sql-outbox.ts +0 -343
  52. package/src/sys-webhook-delivery.object.ts +0 -224
@@ -1,490 +0,0 @@
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
-
411
- describe('redeliver', () => {
412
- it('resets a success row back to pending with attempts=0', async () => {
413
- const { outbox } = newOutbox();
414
- const id = await outbox.enqueue(input('wh-1', 'ev-1'));
415
- await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
416
- await outbox.ack(id, { success: true, httpStatus: 200, durationMs: 5 });
417
-
418
- const row = await outbox.redeliver(id);
419
- expect(row.status).toBe('pending');
420
- expect(row.attempts).toBe(0);
421
- expect(row.claimedBy).toBeUndefined();
422
- expect(row.claimedAt).toBeUndefined();
423
- expect(row.nextRetryAt).toBeUndefined();
424
- expect(row.error).toBeUndefined();
425
- expect(row.responseCode).toBeUndefined();
426
- expect(row.responseBody).toBeUndefined();
427
- // Original immutable fields preserved
428
- expect(row.url).toBe('https://example.test/hook');
429
- expect(row.payload).toEqual({ hello: 'world' });
430
- });
431
-
432
- it('resets a dead row back to pending and clears retry backoff', async () => {
433
- const { outbox } = newOutbox();
434
- const id = await outbox.enqueue(input('wh-1', 'ev-1'));
435
- await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
436
- await outbox.ack(id, {
437
- success: false,
438
- error: 'final',
439
- dead: true,
440
- durationMs: 5,
441
- });
442
-
443
- const row = await outbox.redeliver(id);
444
- expect(row.status).toBe('pending');
445
- expect(row.attempts).toBe(0);
446
- expect(row.error).toBeUndefined();
447
- expect(row.nextRetryAt).toBeUndefined();
448
- });
449
-
450
- it('throws not_found when row does not exist', async () => {
451
- const { outbox } = newOutbox();
452
- await expect(outbox.redeliver('missing')).rejects.toMatchObject({
453
- code: 'not_found',
454
- });
455
- });
456
-
457
- it('throws not_eligible for pending rows', async () => {
458
- const { outbox } = newOutbox();
459
- const id = await outbox.enqueue(input('wh-1', 'ev-1'));
460
- await expect(outbox.redeliver(id)).rejects.toMatchObject({
461
- code: 'not_eligible',
462
- });
463
- });
464
-
465
- it('throws not_eligible for in_flight rows', async () => {
466
- const { outbox } = newOutbox();
467
- const id = await outbox.enqueue(input('wh-1', 'ev-1'));
468
- await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
469
- await expect(outbox.redeliver(id)).rejects.toMatchObject({
470
- code: 'not_eligible',
471
- });
472
- });
473
-
474
- it('redelivered row is immediately claimable again', async () => {
475
- const { outbox } = newOutbox();
476
- const id = await outbox.enqueue(input('wh-1', 'ev-1'));
477
- await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
478
- await outbox.ack(id, { success: true, httpStatus: 200, durationMs: 1 });
479
-
480
- await outbox.redeliver(id);
481
-
482
- const claimed = await outbox.claim({
483
- nodeId: 'B',
484
- limit: 10,
485
- claimTtlMs: 60_000,
486
- });
487
- expect(claimed.map((r) => r.id)).toContain(id);
488
- });
489
- });
490
- });