@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.
- package/.turbo/turbo-build.log +35 -13
- package/CHANGELOG.md +9 -37
- package/dist/chunk-JN76ZRWN.js +164 -0
- package/dist/chunk-JN76ZRWN.js.map +1 -0
- package/dist/chunk-M4M5FWIH.cjs +15 -0
- package/dist/chunk-M4M5FWIH.cjs.map +1 -0
- package/dist/chunk-NYSUNT6X.js +15 -0
- package/dist/chunk-NYSUNT6X.js.map +1 -0
- package/dist/chunk-OW7ESXOK.cjs +164 -0
- package/dist/chunk-OW7ESXOK.cjs.map +1 -0
- package/dist/index.cjs +747 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +455 -0
- package/dist/index.d.ts +425 -74
- package/dist/index.js +712 -218
- package/dist/index.js.map +1 -1
- package/dist/outbox-bPQmKYPN.d.cts +128 -0
- package/dist/outbox-bPQmKYPN.d.ts +128 -0
- package/dist/schema.cjs +9 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +4772 -0
- package/dist/schema.d.ts +4772 -0
- package/dist/schema.js +9 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql-outbox.cjs +184 -0
- package/dist/sql-outbox.cjs.map +1 -0
- package/dist/sql-outbox.d.cts +54 -0
- package/dist/sql-outbox.d.ts +54 -0
- package/dist/sql-outbox.js +184 -0
- package/dist/sql-outbox.js.map +1 -0
- package/package.json +30 -10
- package/src/auto-enqueuer.test.ts +391 -0
- package/src/auto-enqueuer.ts +335 -0
- package/src/dispatcher.test.ts +324 -0
- package/src/dispatcher.ts +218 -0
- package/src/http-sender.ts +187 -0
- package/src/index.ts +48 -12
- package/src/memory-outbox.ts +127 -0
- package/src/outbox.ts +141 -0
- package/src/partition.ts +19 -0
- package/src/retention.test.ts +116 -0
- package/src/retention.ts +144 -0
- package/src/schema.ts +22 -0
- package/src/sql-outbox.test.ts +410 -0
- package/src/sql-outbox.ts +282 -0
- package/src/sys-webhook-delivery.object.ts +202 -0
- package/src/webhook-outbox-plugin.ts +280 -0
- package/tsconfig.json +5 -13
- package/tsup.config.ts +14 -0
- package/dist/index.d.mts +0 -104
- package/dist/index.mjs +0 -216
- package/dist/index.mjs.map +0 -1
- package/src/webhooks-plugin.test.ts +0 -218
- 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
|
+
}
|