@objectstack/plugin-webhooks 7.4.1 → 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.
- package/.turbo/turbo-build.log +20 -32
- package/CHANGELOG.md +59 -0
- package/dist/chunk-HWFTXTTI.js +138 -0
- package/dist/chunk-HWFTXTTI.js.map +1 -0
- package/dist/chunk-KPKLAXNA.cjs +138 -0
- package/dist/chunk-KPKLAXNA.cjs.map +1 -0
- package/dist/index.cjs +62 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -325
- package/dist/index.d.ts +41 -325
- package/dist/index.js +52 -606
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +2 -6
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +5 -4764
- package/dist/schema.d.ts +5 -4764
- package/dist/schema.js +3 -7
- package/package.json +5 -12
- package/src/auto-enqueuer.test.ts +83 -116
- package/src/auto-enqueuer.ts +38 -27
- package/src/index.ts +13 -40
- package/src/schema.ts +11 -16
- package/src/webhook-outbox-plugin.ts +80 -296
- package/tsup.config.ts +1 -1
- package/dist/chunk-7HS5DLU2.js +0 -319
- package/dist/chunk-7HS5DLU2.js.map +0 -1
- package/dist/chunk-HF7CCDPB.cjs +0 -256
- package/dist/chunk-HF7CCDPB.cjs.map +0 -1
- package/dist/chunk-KNGLLSSP.js +0 -256
- package/dist/chunk-KNGLLSSP.js.map +0 -1
- package/dist/chunk-TDSI7UHY.cjs +0 -319
- package/dist/chunk-TDSI7UHY.cjs.map +0 -1
- package/dist/outbox-CIn7LSyB.d.cts +0 -155
- package/dist/outbox-CIn7LSyB.d.ts +0 -155
- package/dist/sql-outbox.cjs +0 -8
- package/dist/sql-outbox.cjs.map +0 -1
- package/dist/sql-outbox.d.cts +0 -55
- package/dist/sql-outbox.d.ts +0 -55
- package/dist/sql-outbox.js +0 -8
- package/dist/sql-outbox.js.map +0 -1
- package/src/dispatcher.test.ts +0 -324
- package/src/dispatcher.ts +0 -218
- package/src/http-sender.ts +0 -187
- package/src/memory-outbox.test.ts +0 -86
- package/src/memory-outbox.ts +0 -155
- package/src/outbox.ts +0 -175
- package/src/partition.ts +0 -19
- package/src/retention.test.ts +0 -116
- package/src/retention.ts +0 -144
- package/src/sql-outbox.test.ts +0 -490
- package/src/sql-outbox.ts +0 -343
- package/src/sys-webhook-delivery.object.ts +0 -224
package/src/sql-outbox.test.ts
DELETED
|
@@ -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
|
-
});
|