@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.
- package/.turbo/turbo-build.log +20 -32
- package/CHANGELOG.md +49 -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 +4 -11
- 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
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Webhook outbox contracts.
|
|
3
|
-
*
|
|
4
|
-
* The outbox stores webhook delivery rows that must be POSTed exactly once
|
|
5
|
-
* (modulo at-least-once + receiver-side idempotency). Implementations are
|
|
6
|
-
* pluggable so the same dispatcher can run against an in-memory test store
|
|
7
|
-
* or a SQL-backed production table.
|
|
8
|
-
*
|
|
9
|
-
* See `content/docs/concepts/webhook-delivery.mdx` §3.2 for the full schema.
|
|
10
|
-
*/
|
|
11
|
-
type DeliveryStatus = 'pending' | 'in_flight' | 'success' | 'failed' | 'dead';
|
|
12
|
-
interface WebhookDelivery {
|
|
13
|
-
/** UUID — also doubles as the receiver-side idempotency key. */
|
|
14
|
-
id: string;
|
|
15
|
-
/** FK to sys_webhook.id — opaque to the dispatcher; only used for hashing. */
|
|
16
|
-
webhookId: string;
|
|
17
|
-
/** Origin event id. UNIQUE(event_id, webhook_id) prevents double-enqueue. */
|
|
18
|
-
eventId: string;
|
|
19
|
-
/** Origin event type, e.g. `data.record.created`. */
|
|
20
|
-
eventType: string;
|
|
21
|
-
/** Destination URL (snapshotted on enqueue — config edits don't rewrite live rows). */
|
|
22
|
-
url: string;
|
|
23
|
-
/** HTTP method — defaults to POST. */
|
|
24
|
-
method?: string;
|
|
25
|
-
/** Custom headers configured on the sink. */
|
|
26
|
-
headers?: Record<string, string>;
|
|
27
|
-
/** HMAC-SHA256 secret. If present, signature is added. */
|
|
28
|
-
secret?: string;
|
|
29
|
-
/** Per-request timeout in ms. */
|
|
30
|
-
timeoutMs?: number;
|
|
31
|
-
/** JSON-serialisable body. */
|
|
32
|
-
payload: unknown;
|
|
33
|
-
/** Lifecycle state. */
|
|
34
|
-
status: DeliveryStatus;
|
|
35
|
-
/** Number of POST attempts made so far (0 before first attempt). */
|
|
36
|
-
attempts: number;
|
|
37
|
-
/** Node id currently working on this row, when `status = in_flight`. */
|
|
38
|
-
claimedBy?: string;
|
|
39
|
-
/** Wall-clock ms when the row was claimed. */
|
|
40
|
-
claimedAt?: number;
|
|
41
|
-
/** Earliest ms at which this row becomes eligible for the next attempt. */
|
|
42
|
-
nextRetryAt?: number;
|
|
43
|
-
/** Wall-clock ms of the last attempt (success or fail). */
|
|
44
|
-
lastAttemptedAt?: number;
|
|
45
|
-
/** HTTP status code from the most recent attempt. */
|
|
46
|
-
responseCode?: number;
|
|
47
|
-
/** Truncated response body for diagnostics. */
|
|
48
|
-
responseBody?: string;
|
|
49
|
-
/** Last transport / timeout error message. */
|
|
50
|
-
error?: string;
|
|
51
|
-
createdAt: number;
|
|
52
|
-
updatedAt: number;
|
|
53
|
-
}
|
|
54
|
-
interface EnqueueInput {
|
|
55
|
-
webhookId: string;
|
|
56
|
-
eventId: string;
|
|
57
|
-
eventType: string;
|
|
58
|
-
url: string;
|
|
59
|
-
method?: string;
|
|
60
|
-
headers?: Record<string, string>;
|
|
61
|
-
secret?: string;
|
|
62
|
-
timeoutMs?: number;
|
|
63
|
-
payload: unknown;
|
|
64
|
-
}
|
|
65
|
-
interface ClaimOptions {
|
|
66
|
-
/** Identifier of the node doing the claim (for `claimedBy`). */
|
|
67
|
-
nodeId: string;
|
|
68
|
-
/** Max rows to claim per call. */
|
|
69
|
-
limit: number;
|
|
70
|
-
/**
|
|
71
|
-
* Partition assignment for this worker. Only rows whose
|
|
72
|
-
* `hash(webhookId) mod count === index` are claimed. Omit to claim
|
|
73
|
-
* across all partitions (single-node mode).
|
|
74
|
-
*/
|
|
75
|
-
partition?: {
|
|
76
|
-
index: number;
|
|
77
|
-
count: number;
|
|
78
|
-
};
|
|
79
|
-
/** Visibility timeout — claimed rows revert to pending after this many ms. */
|
|
80
|
-
claimTtlMs: number;
|
|
81
|
-
/** "Now" reference, ms since epoch. Defaults to Date.now(). */
|
|
82
|
-
now?: number;
|
|
83
|
-
}
|
|
84
|
-
interface AckSuccess {
|
|
85
|
-
success: true;
|
|
86
|
-
httpStatus: number;
|
|
87
|
-
responseBody?: string;
|
|
88
|
-
durationMs: number;
|
|
89
|
-
}
|
|
90
|
-
interface AckFailure {
|
|
91
|
-
success: false;
|
|
92
|
-
httpStatus?: number;
|
|
93
|
-
responseBody?: string;
|
|
94
|
-
error?: string;
|
|
95
|
-
durationMs: number;
|
|
96
|
-
/** Computed by the dispatcher per the retry schedule, or undefined for dead. */
|
|
97
|
-
nextRetryAt?: number;
|
|
98
|
-
/** Marks the row terminal — no more attempts. */
|
|
99
|
-
dead?: boolean;
|
|
100
|
-
}
|
|
101
|
-
type AckResult = AckSuccess | AckFailure;
|
|
102
|
-
/**
|
|
103
|
-
* Error raised by `IWebhookOutbox.redeliver` when the requested row is
|
|
104
|
-
* either missing or in a non-terminal state. The dispatcher / admin UI
|
|
105
|
-
* surfaces this verbatim to the caller — never throw it for transient
|
|
106
|
-
* conditions (transport errors should bubble as native `Error`).
|
|
107
|
-
*/
|
|
108
|
-
declare class RedeliverError extends Error {
|
|
109
|
-
readonly code: 'not_found' | 'not_eligible';
|
|
110
|
-
constructor(message: string, code: 'not_found' | 'not_eligible');
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Pluggable storage backend for delivery rows. Implementations MUST make
|
|
114
|
-
* `claim()` atomic across concurrent callers — that property is the
|
|
115
|
-
* exactly-once guarantee.
|
|
116
|
-
*/
|
|
117
|
-
interface IWebhookOutbox {
|
|
118
|
-
/**
|
|
119
|
-
* Insert a new delivery row. Implementations MUST treat
|
|
120
|
-
* `(eventId, webhookId)` as unique and silently drop duplicates.
|
|
121
|
-
* Returns the row id (existing or new).
|
|
122
|
-
*/
|
|
123
|
-
enqueue(input: EnqueueInput): Promise<string>;
|
|
124
|
-
/**
|
|
125
|
-
* Atomically claim up to `limit` rows whose `nextRetryAt <= now` (or
|
|
126
|
-
* null) and matching the partition predicate. Claimed rows MUST be
|
|
127
|
-
* marked `in_flight` so concurrent claimers don't see them.
|
|
128
|
-
*/
|
|
129
|
-
claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
|
|
130
|
-
/** Record the outcome of an attempt. */
|
|
131
|
-
ack(id: string, result: AckResult): Promise<void>;
|
|
132
|
-
/** Snapshot accessor for tests / admin tooling. */
|
|
133
|
-
list(filter?: {
|
|
134
|
-
status?: DeliveryStatus;
|
|
135
|
-
}): Promise<WebhookDelivery[]>;
|
|
136
|
-
/**
|
|
137
|
-
* Reset a terminal row back to `pending` so the dispatcher will pick
|
|
138
|
-
* it up again on its next tick.
|
|
139
|
-
*
|
|
140
|
-
* - Eligible source states: `success`, `failed`, `dead`.
|
|
141
|
-
* - Rejects `pending` / `in_flight` rows — replaying those would
|
|
142
|
-
* double-deliver because they're either already queued or actively
|
|
143
|
-
* being sent.
|
|
144
|
-
* - Resets `attempts=0` so the retry budget restarts.
|
|
145
|
-
* - Clears `claimed_by`, `claimed_at`, `next_retry_at`, `error`,
|
|
146
|
-
* `response_code`, `response_body`. URL / payload / secret are NOT
|
|
147
|
-
* touched — replay reproduces the original POST byte-for-byte.
|
|
148
|
-
*
|
|
149
|
-
* Throws `RedeliverError` with code `not_found` or `not_eligible`.
|
|
150
|
-
* Returns the post-reset row.
|
|
151
|
-
*/
|
|
152
|
-
redeliver(id: string): Promise<WebhookDelivery>;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export { type AckFailure as A, type ClaimOptions as C, type DeliveryStatus as D, type EnqueueInput as E, type IWebhookOutbox as I, RedeliverError as R, type WebhookDelivery as W, type AckResult as a, type AckSuccess as b };
|
package/dist/sql-outbox.cjs
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports, "__esModule", {value: true});
|
|
2
|
-
|
|
3
|
-
var _chunkHF7CCDPBcjs = require('./chunk-HF7CCDPB.cjs');
|
|
4
|
-
require('./chunk-TDSI7UHY.cjs');
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
exports.SqlWebhookOutbox = _chunkHF7CCDPBcjs.SqlWebhookOutbox;
|
|
8
|
-
//# sourceMappingURL=sql-outbox.cjs.map
|
package/dist/sql-outbox.cjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/sql-outbox.cjs"],"names":[],"mappings":"AAAA;AACE;AACF,wDAA6B;AAC7B,gCAA6B;AAC7B;AACE;AACF,8DAAC","file":"/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/sql-outbox.cjs"}
|
package/dist/sql-outbox.d.cts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { IDataEngine } from '@objectstack/spec/contracts';
|
|
2
|
-
import { I as IWebhookOutbox, E as EnqueueInput, C as ClaimOptions, W as WebhookDelivery, a as AckResult, D as DeliveryStatus } from './outbox-CIn7LSyB.cjs';
|
|
3
|
-
|
|
4
|
-
interface SqlWebhookOutboxOptions {
|
|
5
|
-
/**
|
|
6
|
-
* Total partition count — MUST match the dispatcher's `partitionCount`.
|
|
7
|
-
* Used at enqueue time to precompute `partition_key`.
|
|
8
|
-
*/
|
|
9
|
-
partitionCount: number;
|
|
10
|
-
/**
|
|
11
|
-
* Object name to read/write. Defaults to `sys_webhook_delivery`. Override
|
|
12
|
-
* only if you've registered the schema under a different name.
|
|
13
|
-
*/
|
|
14
|
-
objectName?: string;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Durable `IWebhookOutbox` backed by ObjectQL — the production storage
|
|
18
|
-
* impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)
|
|
19
|
-
* because everything goes through the driver-agnostic `IDataEngine` API.
|
|
20
|
-
*
|
|
21
|
-
* **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that
|
|
22
|
-
* SQL feature is Postgres-only. We get equivalent safety from two layers:
|
|
23
|
-
*
|
|
24
|
-
* 1. `cluster.lock` held per partition by the dispatcher (the primary
|
|
25
|
-
* mutex). One node owns one partition at a time → no two claimers.
|
|
26
|
-
* 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two
|
|
27
|
-
* claimers slip through (e.g. admin reschedule + dispatcher), only
|
|
28
|
-
* the first UPDATE matches each row.
|
|
29
|
-
*
|
|
30
|
-
* **Why precompute `partition_key` on enqueue?** ObjectQL has no
|
|
31
|
-
* cross-driver `hash()` function in WHERE clauses. Storing the partition
|
|
32
|
-
* as a column makes the claim query a plain indexed lookup.
|
|
33
|
-
*
|
|
34
|
-
* **Dedup race**: SELECT-then-INSERT has a tiny window where two
|
|
35
|
-
* concurrent producers both miss the SELECT and both INSERT. The unique
|
|
36
|
-
* index `(event_id, webhook_id)` on the table catches it — the second
|
|
37
|
-
* INSERT errors, the producer ignores it. Receivers MUST be idempotent
|
|
38
|
-
* on the `X-Objectstack-Delivery` header anyway.
|
|
39
|
-
*/
|
|
40
|
-
declare class SqlWebhookOutbox implements IWebhookOutbox {
|
|
41
|
-
private readonly engine;
|
|
42
|
-
private readonly objectName;
|
|
43
|
-
private readonly partitionCount;
|
|
44
|
-
constructor(engine: IDataEngine, opts: SqlWebhookOutboxOptions);
|
|
45
|
-
enqueue(input: EnqueueInput): Promise<string>;
|
|
46
|
-
claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
|
|
47
|
-
ack(id: string, result: AckResult): Promise<void>;
|
|
48
|
-
list(filter?: {
|
|
49
|
-
status?: DeliveryStatus;
|
|
50
|
-
}): Promise<WebhookDelivery[]>;
|
|
51
|
-
redeliver(id: string): Promise<WebhookDelivery>;
|
|
52
|
-
private toDelivery;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export { SqlWebhookOutbox, type SqlWebhookOutboxOptions };
|
package/dist/sql-outbox.d.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { IDataEngine } from '@objectstack/spec/contracts';
|
|
2
|
-
import { I as IWebhookOutbox, E as EnqueueInput, C as ClaimOptions, W as WebhookDelivery, a as AckResult, D as DeliveryStatus } from './outbox-CIn7LSyB.js';
|
|
3
|
-
|
|
4
|
-
interface SqlWebhookOutboxOptions {
|
|
5
|
-
/**
|
|
6
|
-
* Total partition count — MUST match the dispatcher's `partitionCount`.
|
|
7
|
-
* Used at enqueue time to precompute `partition_key`.
|
|
8
|
-
*/
|
|
9
|
-
partitionCount: number;
|
|
10
|
-
/**
|
|
11
|
-
* Object name to read/write. Defaults to `sys_webhook_delivery`. Override
|
|
12
|
-
* only if you've registered the schema under a different name.
|
|
13
|
-
*/
|
|
14
|
-
objectName?: string;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Durable `IWebhookOutbox` backed by ObjectQL — the production storage
|
|
18
|
-
* impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)
|
|
19
|
-
* because everything goes through the driver-agnostic `IDataEngine` API.
|
|
20
|
-
*
|
|
21
|
-
* **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that
|
|
22
|
-
* SQL feature is Postgres-only. We get equivalent safety from two layers:
|
|
23
|
-
*
|
|
24
|
-
* 1. `cluster.lock` held per partition by the dispatcher (the primary
|
|
25
|
-
* mutex). One node owns one partition at a time → no two claimers.
|
|
26
|
-
* 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two
|
|
27
|
-
* claimers slip through (e.g. admin reschedule + dispatcher), only
|
|
28
|
-
* the first UPDATE matches each row.
|
|
29
|
-
*
|
|
30
|
-
* **Why precompute `partition_key` on enqueue?** ObjectQL has no
|
|
31
|
-
* cross-driver `hash()` function in WHERE clauses. Storing the partition
|
|
32
|
-
* as a column makes the claim query a plain indexed lookup.
|
|
33
|
-
*
|
|
34
|
-
* **Dedup race**: SELECT-then-INSERT has a tiny window where two
|
|
35
|
-
* concurrent producers both miss the SELECT and both INSERT. The unique
|
|
36
|
-
* index `(event_id, webhook_id)` on the table catches it — the second
|
|
37
|
-
* INSERT errors, the producer ignores it. Receivers MUST be idempotent
|
|
38
|
-
* on the `X-Objectstack-Delivery` header anyway.
|
|
39
|
-
*/
|
|
40
|
-
declare class SqlWebhookOutbox implements IWebhookOutbox {
|
|
41
|
-
private readonly engine;
|
|
42
|
-
private readonly objectName;
|
|
43
|
-
private readonly partitionCount;
|
|
44
|
-
constructor(engine: IDataEngine, opts: SqlWebhookOutboxOptions);
|
|
45
|
-
enqueue(input: EnqueueInput): Promise<string>;
|
|
46
|
-
claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
|
|
47
|
-
ack(id: string, result: AckResult): Promise<void>;
|
|
48
|
-
list(filter?: {
|
|
49
|
-
status?: DeliveryStatus;
|
|
50
|
-
}): Promise<WebhookDelivery[]>;
|
|
51
|
-
redeliver(id: string): Promise<WebhookDelivery>;
|
|
52
|
-
private toDelivery;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export { SqlWebhookOutbox, type SqlWebhookOutboxOptions };
|
package/dist/sql-outbox.js
DELETED
package/dist/sql-outbox.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/src/dispatcher.test.ts
DELETED
|
@@ -1,324 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Cross-node webhook dispatcher contract test.
|
|
5
|
-
*
|
|
6
|
-
* Builds two `WebhookDispatcher` instances that share one in-memory outbox
|
|
7
|
-
* AND one cluster `ILock`/`IPubSub` (simulating two nodes sharing one
|
|
8
|
-
* Redis/Postgres). Asserts:
|
|
9
|
-
*
|
|
10
|
-
* 1. Every enqueued delivery is POSTed *exactly once* (no double-fire).
|
|
11
|
-
* 2. Work is distributed across both nodes (no starvation).
|
|
12
|
-
* 3. 5xx responses are retried per the Stripe-style schedule.
|
|
13
|
-
* 4. 4xx (permanent) responses go straight to `dead`.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, expect, it } from 'vitest';
|
|
17
|
-
import {
|
|
18
|
-
ComposedClusterService,
|
|
19
|
-
MemoryCounter,
|
|
20
|
-
MemoryKV,
|
|
21
|
-
MemoryLock,
|
|
22
|
-
MemoryPubSub,
|
|
23
|
-
} from '@objectstack/service-cluster';
|
|
24
|
-
import type { IClusterService } from '@objectstack/spec/contracts';
|
|
25
|
-
import { WebhookDispatcher } from './dispatcher.js';
|
|
26
|
-
import type { FetchImpl } from './http-sender.js';
|
|
27
|
-
import { MemoryWebhookOutbox } from './memory-outbox.js';
|
|
28
|
-
import { hashPartition } from './partition.js';
|
|
29
|
-
|
|
30
|
-
interface SharedCluster {
|
|
31
|
-
nodeA: IClusterService;
|
|
32
|
-
nodeB: IClusterService;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function makeSharedCluster(): SharedCluster {
|
|
36
|
-
// ONE lock + pubsub shared by both "nodes" — this is what makes the test
|
|
37
|
-
// a realistic cross-node simulation.
|
|
38
|
-
const lock = new MemoryLock();
|
|
39
|
-
const pubsub = new MemoryPubSub();
|
|
40
|
-
const kv = new MemoryKV();
|
|
41
|
-
const counter = new MemoryCounter();
|
|
42
|
-
return {
|
|
43
|
-
nodeA: new ComposedClusterService('node-A', 'memory', pubsub, lock, kv, counter),
|
|
44
|
-
nodeB: new ComposedClusterService('node-B', 'memory', pubsub, lock, kv, counter),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function makeFetchImpl(opts: {
|
|
49
|
-
status?: number;
|
|
50
|
-
log?: { url: string; deliveryId: string }[];
|
|
51
|
-
}): FetchImpl {
|
|
52
|
-
const status = opts.status ?? 200;
|
|
53
|
-
return async (url, init) => {
|
|
54
|
-
opts.log?.push({
|
|
55
|
-
url,
|
|
56
|
-
deliveryId: init.headers['X-Objectstack-Delivery'] ?? '',
|
|
57
|
-
});
|
|
58
|
-
return {
|
|
59
|
-
ok: status >= 200 && status < 300,
|
|
60
|
-
status,
|
|
61
|
-
async text() {
|
|
62
|
-
return '';
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function flushTicks(...dispatchers: WebhookDispatcher[]): Promise<void> {
|
|
69
|
-
// Run several rounds with the nodes ticking *concurrently* so they
|
|
70
|
-
// genuinely contend for the cluster lock — sequential ticks would let
|
|
71
|
-
// whichever node ran first drain every partition.
|
|
72
|
-
for (let round = 0; round < 6; round++) {
|
|
73
|
-
await Promise.all(dispatchers.map((d) => d.tick()));
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
describe('WebhookDispatcher cross-node', () => {
|
|
78
|
-
it('exactly-once: 50 deliveries across 2 nodes → 50 POSTs total', async () => {
|
|
79
|
-
const cluster = makeSharedCluster();
|
|
80
|
-
const outbox = new MemoryWebhookOutbox();
|
|
81
|
-
const log: { url: string; deliveryId: string }[] = [];
|
|
82
|
-
const fetchImpl = makeFetchImpl({ status: 200, log });
|
|
83
|
-
|
|
84
|
-
const partitionCount = 4;
|
|
85
|
-
const a = new WebhookDispatcher({
|
|
86
|
-
nodeId: 'node-A',
|
|
87
|
-
cluster: cluster.nodeA,
|
|
88
|
-
outbox,
|
|
89
|
-
fetchImpl,
|
|
90
|
-
partitionCount,
|
|
91
|
-
intervalMs: 1_000_000, // disable timer; we drive with tick()
|
|
92
|
-
});
|
|
93
|
-
const b = new WebhookDispatcher({
|
|
94
|
-
nodeId: 'node-B',
|
|
95
|
-
cluster: cluster.nodeB,
|
|
96
|
-
outbox,
|
|
97
|
-
fetchImpl,
|
|
98
|
-
partitionCount,
|
|
99
|
-
intervalMs: 1_000_000,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
for (let i = 0; i < 50; i++) {
|
|
103
|
-
await outbox.enqueue({
|
|
104
|
-
webhookId: `wh-${i % 10}`, // 10 webhooks → spread across 4 partitions
|
|
105
|
-
eventId: `evt-${i}`,
|
|
106
|
-
eventType: 'data.record.created',
|
|
107
|
-
url: `https://example.test/${i}`,
|
|
108
|
-
payload: { i },
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
await flushTicks(a, b);
|
|
113
|
-
|
|
114
|
-
expect(log).toHaveLength(50);
|
|
115
|
-
const uniqueIds = new Set(log.map((l) => l.deliveryId));
|
|
116
|
-
expect(uniqueIds.size).toBe(50);
|
|
117
|
-
|
|
118
|
-
const success = await outbox.list({ status: 'success' });
|
|
119
|
-
expect(success).toHaveLength(50);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('partition affinity: dispatcher only claims rows for partitions it locked', async () => {
|
|
123
|
-
const cluster = makeSharedCluster();
|
|
124
|
-
const outbox = new MemoryWebhookOutbox();
|
|
125
|
-
const partitionCount = 8;
|
|
126
|
-
|
|
127
|
-
// For each attempt, record (nodeId, partitionForWebhook).
|
|
128
|
-
const observed: { nodeId: string; partition: number }[] = [];
|
|
129
|
-
|
|
130
|
-
const make = (nodeId: string, c: IClusterService) =>
|
|
131
|
-
new WebhookDispatcher({
|
|
132
|
-
nodeId,
|
|
133
|
-
cluster: c,
|
|
134
|
-
outbox,
|
|
135
|
-
fetchImpl: makeFetchImpl({ status: 200 }),
|
|
136
|
-
partitionCount,
|
|
137
|
-
intervalMs: 1_000_000,
|
|
138
|
-
onAttempt: (delivery) => {
|
|
139
|
-
observed.push({
|
|
140
|
-
nodeId,
|
|
141
|
-
partition: hashPartition(delivery.webhookId, partitionCount),
|
|
142
|
-
});
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const a = make('node-A', cluster.nodeA);
|
|
147
|
-
const b = make('node-B', cluster.nodeB);
|
|
148
|
-
|
|
149
|
-
for (let i = 0; i < 30; i++) {
|
|
150
|
-
await outbox.enqueue({
|
|
151
|
-
webhookId: `wh-${i % 5}`,
|
|
152
|
-
eventId: `evt-${i}`,
|
|
153
|
-
eventType: 't',
|
|
154
|
-
url: 'https://example.test/x',
|
|
155
|
-
payload: { i },
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
await flushTicks(a, b);
|
|
159
|
-
|
|
160
|
-
expect(observed).toHaveLength(30);
|
|
161
|
-
// Each row's partition came from hash(webhookId, 8) — only 5 distinct
|
|
162
|
-
// webhook ids → at most 5 distinct partitions.
|
|
163
|
-
const partitionsTouched = new Set(observed.map((o) => o.partition));
|
|
164
|
-
expect(partitionsTouched.size).toBeLessThanOrEqual(5);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('load distribution: both nodes process some rows', async () => {
|
|
168
|
-
const cluster = makeSharedCluster();
|
|
169
|
-
const outbox = new MemoryWebhookOutbox();
|
|
170
|
-
const partitionCount = 8;
|
|
171
|
-
const counts: Record<string, number> = { 'node-A': 0, 'node-B': 0 };
|
|
172
|
-
|
|
173
|
-
const make = (nodeId: string, c: IClusterService) =>
|
|
174
|
-
new WebhookDispatcher({
|
|
175
|
-
nodeId,
|
|
176
|
-
cluster: c,
|
|
177
|
-
outbox,
|
|
178
|
-
fetchImpl: makeFetchImpl({ status: 200 }),
|
|
179
|
-
partitionCount,
|
|
180
|
-
intervalMs: 1_000_000,
|
|
181
|
-
onAttempt: () => {
|
|
182
|
-
counts[nodeId] += 1;
|
|
183
|
-
},
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
const a = make('node-A', cluster.nodeA);
|
|
187
|
-
const b = make('node-B', cluster.nodeB);
|
|
188
|
-
|
|
189
|
-
// Lots of distinct webhookIds → spreads work across many partitions.
|
|
190
|
-
for (let i = 0; i < 200; i++) {
|
|
191
|
-
await outbox.enqueue({
|
|
192
|
-
webhookId: `wh-${i}`,
|
|
193
|
-
eventId: `evt-${i}`,
|
|
194
|
-
eventType: 't',
|
|
195
|
-
url: 'https://example.test/x',
|
|
196
|
-
payload: { i },
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
await flushTicks(a, b);
|
|
200
|
-
|
|
201
|
-
// Each node should have processed at least one row — proving the
|
|
202
|
-
// rotation/offset isn't pinning all work to node A.
|
|
203
|
-
expect(counts['node-A']).toBeGreaterThan(0);
|
|
204
|
-
expect(counts['node-B']).toBeGreaterThan(0);
|
|
205
|
-
expect(counts['node-A'] + counts['node-B']).toBe(200);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('5xx is retried: row stays pending with future nextRetryAt', async () => {
|
|
209
|
-
const cluster = makeSharedCluster();
|
|
210
|
-
const outbox = new MemoryWebhookOutbox();
|
|
211
|
-
const a = new WebhookDispatcher({
|
|
212
|
-
nodeId: 'node-A',
|
|
213
|
-
cluster: cluster.nodeA,
|
|
214
|
-
outbox,
|
|
215
|
-
fetchImpl: makeFetchImpl({ status: 503 }),
|
|
216
|
-
partitionCount: 1,
|
|
217
|
-
intervalMs: 1_000_000,
|
|
218
|
-
rng: () => 0.5,
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
await outbox.enqueue({
|
|
222
|
-
webhookId: 'wh-x',
|
|
223
|
-
eventId: 'evt-x',
|
|
224
|
-
eventType: 't',
|
|
225
|
-
url: 'https://example.test/fail',
|
|
226
|
-
payload: {},
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
await a.tick();
|
|
230
|
-
const rows = await outbox.list();
|
|
231
|
-
expect(rows[0].status).toBe('pending');
|
|
232
|
-
expect(rows[0].attempts).toBe(1);
|
|
233
|
-
expect(rows[0].nextRetryAt).toBeGreaterThan(Date.now());
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it('4xx is permanent: row moves to dead', async () => {
|
|
237
|
-
const cluster = makeSharedCluster();
|
|
238
|
-
const outbox = new MemoryWebhookOutbox();
|
|
239
|
-
const a = new WebhookDispatcher({
|
|
240
|
-
nodeId: 'node-A',
|
|
241
|
-
cluster: cluster.nodeA,
|
|
242
|
-
outbox,
|
|
243
|
-
fetchImpl: makeFetchImpl({ status: 404 }),
|
|
244
|
-
partitionCount: 1,
|
|
245
|
-
intervalMs: 1_000_000,
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
await outbox.enqueue({
|
|
249
|
-
webhookId: 'wh-x',
|
|
250
|
-
eventId: 'evt-x',
|
|
251
|
-
eventType: 't',
|
|
252
|
-
url: 'https://example.test/missing',
|
|
253
|
-
payload: {},
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
await a.tick();
|
|
257
|
-
const dead = await outbox.list({ status: 'dead' });
|
|
258
|
-
expect(dead).toHaveLength(1);
|
|
259
|
-
expect(dead[0].responseCode).toBe(404);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it('dedup: identical (eventId, webhookId) enqueues collapse to one row', async () => {
|
|
263
|
-
const outbox = new MemoryWebhookOutbox();
|
|
264
|
-
const id1 = await outbox.enqueue({
|
|
265
|
-
webhookId: 'wh-1',
|
|
266
|
-
eventId: 'evt-dup',
|
|
267
|
-
eventType: 't',
|
|
268
|
-
url: 'https://example.test/',
|
|
269
|
-
payload: {},
|
|
270
|
-
});
|
|
271
|
-
const id2 = await outbox.enqueue({
|
|
272
|
-
webhookId: 'wh-1',
|
|
273
|
-
eventId: 'evt-dup',
|
|
274
|
-
eventType: 't',
|
|
275
|
-
url: 'https://example.test/',
|
|
276
|
-
payload: {},
|
|
277
|
-
});
|
|
278
|
-
expect(id1).toBe(id2);
|
|
279
|
-
const rows = await outbox.list();
|
|
280
|
-
expect(rows).toHaveLength(1);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('lock prevents same partition being claimed twice in a tick', async () => {
|
|
284
|
-
const cluster = makeSharedCluster();
|
|
285
|
-
const outbox = new MemoryWebhookOutbox();
|
|
286
|
-
const log: { url: string; deliveryId: string }[] = [];
|
|
287
|
-
const fetchImpl = makeFetchImpl({ status: 200, log });
|
|
288
|
-
|
|
289
|
-
// Single partition → both nodes contend for the same lock.
|
|
290
|
-
const a = new WebhookDispatcher({
|
|
291
|
-
nodeId: 'node-A',
|
|
292
|
-
cluster: cluster.nodeA,
|
|
293
|
-
outbox,
|
|
294
|
-
fetchImpl,
|
|
295
|
-
partitionCount: 1,
|
|
296
|
-
intervalMs: 1_000_000,
|
|
297
|
-
});
|
|
298
|
-
const b = new WebhookDispatcher({
|
|
299
|
-
nodeId: 'node-B',
|
|
300
|
-
cluster: cluster.nodeB,
|
|
301
|
-
outbox,
|
|
302
|
-
fetchImpl,
|
|
303
|
-
partitionCount: 1,
|
|
304
|
-
intervalMs: 1_000_000,
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
for (let i = 0; i < 5; i++) {
|
|
308
|
-
await outbox.enqueue({
|
|
309
|
-
webhookId: 'wh-1',
|
|
310
|
-
eventId: `evt-${i}`,
|
|
311
|
-
eventType: 't',
|
|
312
|
-
url: 'https://example.test/',
|
|
313
|
-
payload: { i },
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Fire both ticks "simultaneously" — only one should claim the partition.
|
|
318
|
-
await Promise.all([a.tick(), b.tick()]);
|
|
319
|
-
|
|
320
|
-
expect(log).toHaveLength(5);
|
|
321
|
-
const uniqueIds = new Set(log.map((l) => l.deliveryId));
|
|
322
|
-
expect(uniqueIds.size).toBe(5);
|
|
323
|
-
});
|
|
324
|
-
});
|