@objectstack/plugin-webhooks 5.2.0 → 6.1.1
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 +28 -28
- package/CHANGELOG.md +31 -0
- package/dist/{chunk-JN76ZRWN.js → chunk-33LYZT7O.js} +21 -1
- package/dist/chunk-33LYZT7O.js.map +1 -0
- package/dist/chunk-BS2QTZH3.js +256 -0
- package/dist/chunk-BS2QTZH3.js.map +1 -0
- package/dist/chunk-FA66GQEO.cjs +256 -0
- package/dist/chunk-FA66GQEO.cjs.map +1 -0
- package/dist/{chunk-OW7ESXOK.cjs → chunk-MJZGD37S.cjs} +21 -1
- package/dist/chunk-MJZGD37S.cjs.map +1 -0
- package/dist/index.cjs +175 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -2
- package/dist/index.d.ts +16 -2
- package/dist/index.js +167 -6
- package/dist/index.js.map +1 -1
- package/dist/{outbox-bPQmKYPN.d.cts → outbox-CIn7LSyB.d.cts} +28 -1
- package/dist/{outbox-bPQmKYPN.d.ts → outbox-CIn7LSyB.d.ts} +28 -1
- package/dist/schema.cjs +2 -2
- package/dist/schema.d.cts +17 -1
- package/dist/schema.d.ts +17 -1
- package/dist/schema.js +1 -1
- package/dist/sql-outbox.cjs +4 -180
- package/dist/sql-outbox.cjs.map +1 -1
- package/dist/sql-outbox.d.cts +2 -1
- package/dist/sql-outbox.d.ts +2 -1
- package/dist/sql-outbox.js +3 -179
- package/dist/sql-outbox.js.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +1 -0
- package/src/memory-outbox.test.ts +86 -0
- package/src/memory-outbox.ts +28 -0
- package/src/outbox.ts +34 -0
- package/src/sql-outbox.test.ts +80 -0
- package/src/sql-outbox.ts +61 -0
- package/src/sys-webhook-delivery.object.ts +22 -0
- package/src/webhook-outbox-plugin.ts +167 -5
- package/dist/chunk-JN76ZRWN.js.map +0 -1
- package/dist/chunk-M4M5FWIH.cjs +0 -15
- package/dist/chunk-M4M5FWIH.cjs.map +0 -1
- package/dist/chunk-NYSUNT6X.js +0 -15
- package/dist/chunk-NYSUNT6X.js.map +0 -1
- package/dist/chunk-OW7ESXOK.cjs.map +0 -1
package/dist/sql-outbox.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/sql-outbox.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport { randomUUID } from 'node:crypto';\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport type {\n AckResult,\n ClaimOptions,\n DeliveryStatus,\n EnqueueInput,\n IWebhookOutbox,\n WebhookDelivery,\n} from './outbox.js';\nimport { hashPartition } from './partition.js';\nimport { SYS_WEBHOOK_DELIVERY } from './schema.js';\n\nexport interface SqlWebhookOutboxOptions {\n /**\n * Total partition count — MUST match the dispatcher's `partitionCount`.\n * Used at enqueue time to precompute `partition_key`.\n */\n partitionCount: number;\n /**\n * Object name to read/write. Defaults to `sys_webhook_delivery`. Override\n * only if you've registered the schema under a different name.\n */\n objectName?: string;\n}\n\ninterface DeliveryRow {\n id: string;\n webhook_id: string;\n event_id: string;\n event_type: string;\n url: string;\n method?: string | null;\n headers_json?: string | null;\n secret?: string | null;\n timeout_ms?: number | null;\n payload_json: string;\n partition_key: number;\n status: DeliveryStatus;\n attempts: number;\n claimed_by?: string | null;\n claimed_at?: number | null;\n next_retry_at?: number | null;\n last_attempted_at?: number | null;\n response_code?: number | null;\n response_body?: string | null;\n error?: string | null;\n created_at: number;\n updated_at: number;\n}\n\n/**\n * Durable `IWebhookOutbox` backed by ObjectQL — the production storage\n * impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)\n * because everything goes through the driver-agnostic `IDataEngine` API.\n *\n * **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that\n * SQL feature is Postgres-only. We get equivalent safety from two layers:\n *\n * 1. `cluster.lock` held per partition by the dispatcher (the primary\n * mutex). One node owns one partition at a time → no two claimers.\n * 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two\n * claimers slip through (e.g. admin reschedule + dispatcher), only\n * the first UPDATE matches each row.\n *\n * **Why precompute `partition_key` on enqueue?** ObjectQL has no\n * cross-driver `hash()` function in WHERE clauses. Storing the partition\n * as a column makes the claim query a plain indexed lookup.\n *\n * **Dedup race**: SELECT-then-INSERT has a tiny window where two\n * concurrent producers both miss the SELECT and both INSERT. The unique\n * index `(event_id, webhook_id)` on the table catches it — the second\n * INSERT errors, the producer ignores it. Receivers MUST be idempotent\n * on the `X-Objectstack-Delivery` header anyway.\n */\nexport class SqlWebhookOutbox implements IWebhookOutbox {\n private readonly objectName: string;\n private readonly partitionCount: number;\n\n constructor(\n private readonly engine: IDataEngine,\n opts: SqlWebhookOutboxOptions,\n ) {\n if (opts.partitionCount <= 0) {\n throw new Error('SqlWebhookOutbox: partitionCount must be > 0');\n }\n this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;\n this.partitionCount = opts.partitionCount;\n }\n\n async enqueue(input: EnqueueInput): Promise<string> {\n // Cheap pre-check to absorb most duplicates without hitting the\n // unique-index error path. Race window with the INSERT below is\n // intentional and documented.\n const existing = await this.engine.findOne(this.objectName, {\n where: { event_id: input.eventId, webhook_id: input.webhookId },\n fields: ['id'],\n });\n if (existing?.id) return existing.id as string;\n\n const id = randomUUID();\n const now = Date.now();\n const row: Omit<DeliveryRow, 'response_body' | 'error'> = {\n id,\n webhook_id: input.webhookId,\n event_id: input.eventId,\n event_type: input.eventType,\n url: input.url,\n method: input.method ?? 'POST',\n headers_json: input.headers ? JSON.stringify(input.headers) : undefined,\n secret: input.secret,\n timeout_ms: input.timeoutMs,\n payload_json: JSON.stringify(input.payload ?? null),\n partition_key: hashPartition(input.webhookId, this.partitionCount),\n status: 'pending',\n attempts: 0,\n created_at: now,\n updated_at: now,\n };\n try {\n await this.engine.insert(this.objectName, row);\n return id;\n } catch (err) {\n // Unique-index collision (dedup race) → look up the winner and\n // return its id. Any other error propagates.\n const winner = await this.engine.findOne(this.objectName, {\n where: { event_id: input.eventId, webhook_id: input.webhookId },\n fields: ['id'],\n });\n if (winner?.id) return winner.id as string;\n throw err;\n }\n }\n\n async claim(opts: ClaimOptions): Promise<WebhookDelivery[]> {\n const now = opts.now ?? Date.now();\n\n // 1. Reap stale in_flight rows — visibility-timeout recovery.\n await this.engine.update(\n this.objectName,\n { status: 'pending', claimed_by: null, claimed_at: null, updated_at: now },\n {\n where: {\n status: 'in_flight',\n claimed_at: { $lt: now - opts.claimTtlMs },\n },\n multi: true,\n },\n );\n\n // 2. Pick candidate ids.\n const partitionFilter = opts.partition\n ? { partition_key: opts.partition.index }\n : {};\n const candidates = await this.engine.find(this.objectName, {\n where: {\n status: 'pending',\n ...partitionFilter,\n // next_retry_at <= now OR null\n $or: [\n { next_retry_at: null },\n { next_retry_at: { $lte: now } },\n ],\n },\n fields: ['id'],\n // No orderBy for portability — drivers handle the natural insert order.\n limit: opts.limit,\n });\n if (candidates.length === 0) return [];\n\n const ids = (candidates as Array<{ id: string }>).map((c) => c.id);\n\n // 3. Atomic claim. WHERE status='pending' rejects any rows another\n // worker swept up between steps 2 and 3.\n await this.engine.update(\n this.objectName,\n {\n status: 'in_flight',\n claimed_by: opts.nodeId,\n claimed_at: now,\n updated_at: now,\n },\n {\n where: { id: { $in: ids }, status: 'pending' },\n multi: true,\n },\n );\n\n // 4. Read back the rows we actually own.\n const claimed = (await this.engine.find(this.objectName, {\n where: {\n id: { $in: ids },\n claimed_by: opts.nodeId,\n claimed_at: now,\n status: 'in_flight',\n },\n })) as DeliveryRow[];\n\n return claimed.map((r) => this.toDelivery(r));\n }\n\n async ack(id: string, result: AckResult): Promise<void> {\n // ObjectQL has no atomic $inc across drivers, so read-then-write.\n // Safe enough: ack is single-writer per row (only the claimer acks).\n const current = (await this.engine.findOne(this.objectName, {\n where: { id },\n fields: ['attempts'],\n })) as { attempts?: number } | null;\n if (!current) return;\n\n const now = Date.now();\n let status: DeliveryStatus;\n let nextRetryAt: number | null;\n let error: string | null;\n\n if (result.success) {\n status = 'success';\n nextRetryAt = null;\n error = null;\n } else if (result.dead) {\n status = 'dead';\n nextRetryAt = null;\n error = result.error ?? null;\n } else {\n status = 'pending';\n nextRetryAt = result.nextRetryAt ?? null;\n error = result.error ?? null;\n }\n\n await this.engine.update(\n this.objectName,\n {\n status,\n attempts: (current.attempts ?? 0) + 1,\n last_attempted_at: now,\n claimed_by: null,\n claimed_at: null,\n response_code: result.httpStatus ?? null,\n response_body: result.responseBody ?? null,\n next_retry_at: nextRetryAt,\n error,\n updated_at: now,\n },\n { where: { id }, multi: false },\n );\n }\n\n async list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]> {\n const rows = (await this.engine.find(this.objectName, {\n where: filter?.status ? { status: filter.status } : {},\n })) as DeliveryRow[];\n return rows.map((r) => this.toDelivery(r));\n }\n\n private toDelivery(r: DeliveryRow): WebhookDelivery {\n return {\n id: r.id,\n webhookId: r.webhook_id,\n eventId: r.event_id,\n eventType: r.event_type,\n url: r.url,\n method: r.method ?? undefined,\n headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,\n secret: r.secret ?? undefined,\n timeoutMs: r.timeout_ms ?? undefined,\n payload: JSON.parse(r.payload_json),\n status: r.status,\n attempts: r.attempts,\n claimedBy: r.claimed_by ?? undefined,\n claimedAt: r.claimed_at ?? undefined,\n nextRetryAt: r.next_retry_at ?? undefined,\n lastAttemptedAt: r.last_attempted_at ?? undefined,\n responseCode: r.response_code ?? undefined,\n responseBody: r.response_body ?? undefined,\n error: r.error ?? undefined,\n createdAt: r.created_at,\n updatedAt: r.updated_at,\n };\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-webhooks",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.1.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.",
|
|
6
6
|
"type": "module",
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@objectstack/core": "
|
|
28
|
-
"@objectstack/platform-objects": "
|
|
29
|
-
"@objectstack/spec": "
|
|
30
|
-
"@objectstack/service-cluster": "5.1.
|
|
27
|
+
"@objectstack/core": "6.1.1",
|
|
28
|
+
"@objectstack/platform-objects": "6.1.1",
|
|
29
|
+
"@objectstack/spec": "6.1.1",
|
|
30
|
+
"@objectstack/service-cluster": "5.1.4"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^25.9.1",
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MemoryWebhookOutbox — focused tests for behaviours not already covered
|
|
5
|
+
* via `dispatcher.test.ts`. Today that's just `redeliver()` — the rest of
|
|
6
|
+
* the contract is exercised end-to-end through the dispatcher path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { MemoryWebhookOutbox } from './memory-outbox.js';
|
|
11
|
+
import type { EnqueueInput } from './outbox.js';
|
|
12
|
+
|
|
13
|
+
function input(webhookId: string, eventId: string): EnqueueInput {
|
|
14
|
+
return {
|
|
15
|
+
webhookId,
|
|
16
|
+
eventId,
|
|
17
|
+
eventType: 'data.record.created',
|
|
18
|
+
url: 'https://example.test/hook',
|
|
19
|
+
payload: { hello: 'world' },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('MemoryWebhookOutbox.redeliver', () => {
|
|
24
|
+
it('resets a success row back to pending with attempts=0', async () => {
|
|
25
|
+
const outbox = new MemoryWebhookOutbox();
|
|
26
|
+
const id = await outbox.enqueue(input('wh-1', 'ev-1'));
|
|
27
|
+
await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
|
|
28
|
+
await outbox.ack(id, { success: true, httpStatus: 200, durationMs: 1 });
|
|
29
|
+
|
|
30
|
+
const row = await outbox.redeliver(id);
|
|
31
|
+
expect(row.status).toBe('pending');
|
|
32
|
+
expect(row.attempts).toBe(0);
|
|
33
|
+
expect(row.claimedBy).toBeUndefined();
|
|
34
|
+
expect(row.claimedAt).toBeUndefined();
|
|
35
|
+
expect(row.nextRetryAt).toBeUndefined();
|
|
36
|
+
expect(row.error).toBeUndefined();
|
|
37
|
+
expect(row.responseCode).toBeUndefined();
|
|
38
|
+
expect(row.responseBody).toBeUndefined();
|
|
39
|
+
expect(row.url).toBe('https://example.test/hook');
|
|
40
|
+
expect(row.payload).toEqual({ hello: 'world' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('resets a dead row and makes it claimable again', async () => {
|
|
44
|
+
const outbox = new MemoryWebhookOutbox();
|
|
45
|
+
const id = await outbox.enqueue(input('wh-1', 'ev-1'));
|
|
46
|
+
await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
|
|
47
|
+
await outbox.ack(id, {
|
|
48
|
+
success: false,
|
|
49
|
+
error: 'final',
|
|
50
|
+
dead: true,
|
|
51
|
+
durationMs: 5,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await outbox.redeliver(id);
|
|
55
|
+
const claimed = await outbox.claim({
|
|
56
|
+
nodeId: 'B',
|
|
57
|
+
limit: 10,
|
|
58
|
+
claimTtlMs: 60_000,
|
|
59
|
+
});
|
|
60
|
+
expect(claimed.map((r) => r.id)).toContain(id);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('throws not_found when row does not exist', async () => {
|
|
64
|
+
const outbox = new MemoryWebhookOutbox();
|
|
65
|
+
await expect(outbox.redeliver('missing')).rejects.toMatchObject({
|
|
66
|
+
code: 'not_found',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('throws not_eligible for pending rows', async () => {
|
|
71
|
+
const outbox = new MemoryWebhookOutbox();
|
|
72
|
+
const id = await outbox.enqueue(input('wh-1', 'ev-1'));
|
|
73
|
+
await expect(outbox.redeliver(id)).rejects.toMatchObject({
|
|
74
|
+
code: 'not_eligible',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('throws not_eligible for in_flight rows', async () => {
|
|
79
|
+
const outbox = new MemoryWebhookOutbox();
|
|
80
|
+
const id = await outbox.enqueue(input('wh-1', 'ev-1'));
|
|
81
|
+
await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
|
|
82
|
+
await expect(outbox.redeliver(id)).rejects.toMatchObject({
|
|
83
|
+
code: 'not_eligible',
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/memory-outbox.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
IWebhookOutbox,
|
|
10
10
|
WebhookDelivery,
|
|
11
11
|
} from './outbox.js';
|
|
12
|
+
import { RedeliverError } from './outbox.js';
|
|
12
13
|
import { hashPartition } from './partition.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -124,4 +125,31 @@ export class MemoryWebhookOutbox implements IWebhookOutbox {
|
|
|
124
125
|
const all = Array.from(this.rows.values()).map((r) => ({ ...r }));
|
|
125
126
|
return filter?.status ? all.filter((r) => r.status === filter.status) : all;
|
|
126
127
|
}
|
|
128
|
+
|
|
129
|
+
async redeliver(id: string): Promise<WebhookDelivery> {
|
|
130
|
+
const row = this.rows.get(id);
|
|
131
|
+
if (!row) {
|
|
132
|
+
throw new RedeliverError(
|
|
133
|
+
`Delivery row '${id}' not found`,
|
|
134
|
+
'not_found',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (row.status !== 'success' && row.status !== 'failed' && row.status !== 'dead') {
|
|
138
|
+
throw new RedeliverError(
|
|
139
|
+
`Delivery row '${id}' is '${row.status}', expected one of: success, failed, dead`,
|
|
140
|
+
'not_eligible',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
row.status = 'pending';
|
|
145
|
+
row.attempts = 0;
|
|
146
|
+
row.claimedBy = undefined;
|
|
147
|
+
row.claimedAt = undefined;
|
|
148
|
+
row.nextRetryAt = undefined;
|
|
149
|
+
row.error = undefined;
|
|
150
|
+
row.responseCode = undefined;
|
|
151
|
+
row.responseBody = undefined;
|
|
152
|
+
row.updatedAt = now;
|
|
153
|
+
return { ...row };
|
|
154
|
+
}
|
|
127
155
|
}
|
package/src/outbox.ts
CHANGED
|
@@ -113,6 +113,22 @@ export interface AckFailure {
|
|
|
113
113
|
|
|
114
114
|
export type AckResult = AckSuccess | AckFailure;
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Error raised by `IWebhookOutbox.redeliver` when the requested row is
|
|
118
|
+
* either missing or in a non-terminal state. The dispatcher / admin UI
|
|
119
|
+
* surfaces this verbatim to the caller — never throw it for transient
|
|
120
|
+
* conditions (transport errors should bubble as native `Error`).
|
|
121
|
+
*/
|
|
122
|
+
export class RedeliverError extends Error {
|
|
123
|
+
constructor(
|
|
124
|
+
message: string,
|
|
125
|
+
readonly code: 'not_found' | 'not_eligible',
|
|
126
|
+
) {
|
|
127
|
+
super(message);
|
|
128
|
+
this.name = 'RedeliverError';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
116
132
|
/**
|
|
117
133
|
* Pluggable storage backend for delivery rows. Implementations MUST make
|
|
118
134
|
* `claim()` atomic across concurrent callers — that property is the
|
|
@@ -138,4 +154,22 @@ export interface IWebhookOutbox {
|
|
|
138
154
|
|
|
139
155
|
/** Snapshot accessor for tests / admin tooling. */
|
|
140
156
|
list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]>;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reset a terminal row back to `pending` so the dispatcher will pick
|
|
160
|
+
* it up again on its next tick.
|
|
161
|
+
*
|
|
162
|
+
* - Eligible source states: `success`, `failed`, `dead`.
|
|
163
|
+
* - Rejects `pending` / `in_flight` rows — replaying those would
|
|
164
|
+
* double-deliver because they're either already queued or actively
|
|
165
|
+
* being sent.
|
|
166
|
+
* - Resets `attempts=0` so the retry budget restarts.
|
|
167
|
+
* - Clears `claimed_by`, `claimed_at`, `next_retry_at`, `error`,
|
|
168
|
+
* `response_code`, `response_body`. URL / payload / secret are NOT
|
|
169
|
+
* touched — replay reproduces the original POST byte-for-byte.
|
|
170
|
+
*
|
|
171
|
+
* Throws `RedeliverError` with code `not_found` or `not_eligible`.
|
|
172
|
+
* Returns the post-reset row.
|
|
173
|
+
*/
|
|
174
|
+
redeliver(id: string): Promise<WebhookDelivery>;
|
|
141
175
|
}
|
package/src/sql-outbox.test.ts
CHANGED
|
@@ -407,4 +407,84 @@ describe('SqlWebhookOutbox', () => {
|
|
|
407
407
|
const inFlight = await outbox.list({ status: 'in_flight' });
|
|
408
408
|
expect(inFlight).toHaveLength(1);
|
|
409
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
|
+
});
|
|
410
490
|
});
|
package/src/sql-outbox.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
IWebhookOutbox,
|
|
11
11
|
WebhookDelivery,
|
|
12
12
|
} from './outbox.js';
|
|
13
|
+
import { RedeliverError } from './outbox.js';
|
|
13
14
|
import { hashPartition } from './partition.js';
|
|
14
15
|
import { SYS_WEBHOOK_DELIVERY } from './schema.js';
|
|
15
16
|
|
|
@@ -254,6 +255,66 @@ export class SqlWebhookOutbox implements IWebhookOutbox {
|
|
|
254
255
|
return rows.map((r) => this.toDelivery(r));
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
async redeliver(id: string): Promise<WebhookDelivery> {
|
|
259
|
+
const current = (await this.engine.findOne(this.objectName, {
|
|
260
|
+
where: { id },
|
|
261
|
+
})) as DeliveryRow | null;
|
|
262
|
+
if (!current) {
|
|
263
|
+
throw new RedeliverError(
|
|
264
|
+
`Delivery row '${id}' not found`,
|
|
265
|
+
'not_found',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (
|
|
269
|
+
current.status !== 'success' &&
|
|
270
|
+
current.status !== 'failed' &&
|
|
271
|
+
current.status !== 'dead'
|
|
272
|
+
) {
|
|
273
|
+
throw new RedeliverError(
|
|
274
|
+
`Delivery row '${id}' is '${current.status}', expected one of: success, failed, dead`,
|
|
275
|
+
'not_eligible',
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
// Guarded UPDATE — re-check status server-side so two concurrent
|
|
280
|
+
// redeliver calls cannot both flip the row, and so a dispatcher
|
|
281
|
+
// tick that flipped the row to in_flight between our SELECT and
|
|
282
|
+
// UPDATE cannot be clobbered.
|
|
283
|
+
await this.engine.update(
|
|
284
|
+
this.objectName,
|
|
285
|
+
{
|
|
286
|
+
status: 'pending',
|
|
287
|
+
attempts: 0,
|
|
288
|
+
claimed_by: null,
|
|
289
|
+
claimed_at: null,
|
|
290
|
+
next_retry_at: null,
|
|
291
|
+
last_attempted_at: null,
|
|
292
|
+
response_code: null,
|
|
293
|
+
response_body: null,
|
|
294
|
+
error: null,
|
|
295
|
+
updated_at: now,
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
where: {
|
|
299
|
+
id,
|
|
300
|
+
status: { $in: ['success', 'failed', 'dead'] },
|
|
301
|
+
},
|
|
302
|
+
multi: false,
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
const after = (await this.engine.findOne(this.objectName, {
|
|
306
|
+
where: { id },
|
|
307
|
+
})) as DeliveryRow | null;
|
|
308
|
+
if (!after || after.status !== 'pending') {
|
|
309
|
+
// Lost the race — another writer flipped the row.
|
|
310
|
+
throw new RedeliverError(
|
|
311
|
+
`Delivery row '${id}' state changed during redeliver`,
|
|
312
|
+
'not_eligible',
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return this.toDelivery(after);
|
|
316
|
+
}
|
|
317
|
+
|
|
257
318
|
private toDelivery(r: DeliveryRow): WebhookDelivery {
|
|
258
319
|
return {
|
|
259
320
|
id: r.id,
|
|
@@ -42,6 +42,28 @@ export const SysWebhookDelivery = ObjectSchema.create({
|
|
|
42
42
|
titleFormat: '{event_type} → {url}',
|
|
43
43
|
compactLayout: ['event_type', 'url', 'status', 'attempts', 'next_retry_at'],
|
|
44
44
|
|
|
45
|
+
actions: [
|
|
46
|
+
{
|
|
47
|
+
name: 'redeliver',
|
|
48
|
+
label: 'Redeliver',
|
|
49
|
+
icon: 'refresh-cw',
|
|
50
|
+
variant: 'secondary',
|
|
51
|
+
locations: ['list_item', 'record_header'],
|
|
52
|
+
type: 'api',
|
|
53
|
+
target: '/api/v1/webhooks/redeliver',
|
|
54
|
+
method: 'POST',
|
|
55
|
+
recordIdParam: 'deliveryId',
|
|
56
|
+
confirmText:
|
|
57
|
+
'Replay this delivery? The receiver will get the original payload again — they must be idempotent on the X-Objectstack-Delivery header.',
|
|
58
|
+
successMessage: 'Queued for redelivery',
|
|
59
|
+
refreshAfter: true,
|
|
60
|
+
// Only terminal rows are safe to replay. Pending / in_flight rows
|
|
61
|
+
// are either already queued or actively being sent — replaying
|
|
62
|
+
// would double-deliver.
|
|
63
|
+
disabled: "!(status in ['success', 'failed', 'dead'])",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
|
|
45
67
|
listViews: {
|
|
46
68
|
recent: {
|
|
47
69
|
type: 'grid',
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DeliveryRetentionSweeper,
|
|
16
16
|
type DeliveryRetentionOptions,
|
|
17
17
|
} from './retention.js';
|
|
18
|
+
import { SqlWebhookOutbox } from './sql-outbox.js';
|
|
18
19
|
import { SysWebhookDelivery } from './sys-webhook-delivery.object.js';
|
|
19
20
|
|
|
20
21
|
export interface WebhookOutboxPluginOptions
|
|
@@ -171,11 +172,13 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
// Loud warning when running with the in-memory outbox in production —
|
|
174
|
-
// it loses data on restart and never shares rows across nodes.
|
|
175
|
+
// it loses data on restart and never shares rows across nodes. With
|
|
176
|
+
// the auto-pick logic above this only fires when no IDataEngine is
|
|
177
|
+
// available, but flag it loudly anyway.
|
|
175
178
|
const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
|
|
176
179
|
if (usingMemoryOutbox && process.env.NODE_ENV === 'production') {
|
|
177
180
|
ctx.logger.warn?.(
|
|
178
|
-
'[webhook-outbox] MemoryWebhookOutbox in production — webhook deliveries WILL be lost on process exit.
|
|
181
|
+
'[webhook-outbox] MemoryWebhookOutbox in production — webhook deliveries WILL be lost on process exit. Ensure ObjectQL is registered before WebhookOutboxPlugin so the SQL outbox can auto-select.',
|
|
179
182
|
);
|
|
180
183
|
}
|
|
181
184
|
|
|
@@ -192,6 +195,20 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
192
195
|
});
|
|
193
196
|
}
|
|
194
197
|
|
|
198
|
+
// Admin REST endpoint — POST /api/v1/webhooks/redeliver { deliveryId }.
|
|
199
|
+
// Wired in `kernel:ready` so the auth + http services are guaranteed
|
|
200
|
+
// resolvable. Gated on a session cookie so anonymous callers cannot
|
|
201
|
+
// replay deliveries; finer-grained RBAC (e.g. "only admins") can be
|
|
202
|
+
// layered on later — for now any signed-in user with access to the
|
|
203
|
+
// Setup app can redeliver. The action is also `disabled`-gated by
|
|
204
|
+
// status on the Studio side so the button only lights up on
|
|
205
|
+
// success / failed / dead rows.
|
|
206
|
+
if (typeof (ctx as any).hook === 'function') {
|
|
207
|
+
(ctx as any).hook('kernel:ready', () => {
|
|
208
|
+
this.registerAdminRoutes(ctx);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
195
212
|
ctx.logger.info?.('[webhook-outbox] initialised', {
|
|
196
213
|
nodeId,
|
|
197
214
|
partitions: this.options.partitionCount ?? 8,
|
|
@@ -209,9 +226,32 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
209
226
|
|
|
210
227
|
private resolveOutbox(ctx: PluginContext): IWebhookOutbox {
|
|
211
228
|
const opt = this.options.outbox;
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
229
|
+
if (opt) {
|
|
230
|
+
return typeof opt === 'function'
|
|
231
|
+
? (opt as (c: PluginContext) => IWebhookOutbox)(ctx)
|
|
232
|
+
: opt;
|
|
233
|
+
}
|
|
234
|
+
// No explicit override — auto-pick the right backend for the host.
|
|
235
|
+
// SqlWebhookOutbox needs an `IDataEngine`; if one is resolvable
|
|
236
|
+
// (the usual case in CLI-served stacks), use it so durable rows
|
|
237
|
+
// in `sys_webhook_delivery` actually round-trip through the
|
|
238
|
+
// dispatcher and the redeliver REST endpoint. Memory is only a
|
|
239
|
+
// last-resort fallback for tests / edge environments without an
|
|
240
|
+
// engine.
|
|
241
|
+
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
|
|
242
|
+
if (engine) {
|
|
243
|
+
const partitionCount = this.options.partitionCount ?? 8;
|
|
244
|
+
const sql = new SqlWebhookOutbox(engine, { partitionCount });
|
|
245
|
+
ctx.logger.info?.(
|
|
246
|
+
'[webhook-outbox] auto-selected SqlWebhookOutbox (objectql engine detected)',
|
|
247
|
+
{ partitionCount },
|
|
248
|
+
);
|
|
249
|
+
return sql;
|
|
250
|
+
}
|
|
251
|
+
ctx.logger.warn?.(
|
|
252
|
+
'[webhook-outbox] no IDataEngine available — falling back to MemoryWebhookOutbox. Deliveries will NOT survive process restart and the redeliver REST endpoint will not see rows written through ObjectQL.',
|
|
253
|
+
);
|
|
254
|
+
return new MemoryWebhookOutbox();
|
|
215
255
|
}
|
|
216
256
|
|
|
217
257
|
private async bootAutoEnqueue(
|
|
@@ -277,4 +317,126 @@ export class WebhookOutboxPlugin implements Plugin {
|
|
|
277
317
|
}
|
|
278
318
|
return undefined;
|
|
279
319
|
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one
|
|
323
|
+
* is available. Silently no-ops in environments without an HTTP
|
|
324
|
+
* server (MSW, edge tests, pure library use). Auth is delegated to
|
|
325
|
+
* the better-auth session cookie — every authenticated user counts.
|
|
326
|
+
*/
|
|
327
|
+
private registerAdminRoutes(ctx: PluginContext): void {
|
|
328
|
+
const http = this.tryGetService<any>(ctx, ['http-server']);
|
|
329
|
+
if (!http || typeof http.getRawApp !== 'function') {
|
|
330
|
+
ctx.logger.debug?.(
|
|
331
|
+
'[webhook-outbox] HTTP server not available; redeliver REST endpoint not mounted',
|
|
332
|
+
);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const rawApp = http.getRawApp();
|
|
336
|
+
const outbox = this.outboxInstance;
|
|
337
|
+
if (!rawApp || !outbox) return;
|
|
338
|
+
|
|
339
|
+
rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {
|
|
340
|
+
// Auth gate — require a signed-in session.
|
|
341
|
+
const userId = await this.resolveSessionUserId(ctx, c);
|
|
342
|
+
if (!userId) {
|
|
343
|
+
return c.json(
|
|
344
|
+
{
|
|
345
|
+
success: false,
|
|
346
|
+
error: 'unauthenticated',
|
|
347
|
+
message: 'Sign in to redeliver webhook deliveries.',
|
|
348
|
+
},
|
|
349
|
+
401,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
let body: any;
|
|
353
|
+
try {
|
|
354
|
+
body = await c.req.json();
|
|
355
|
+
} catch {
|
|
356
|
+
return c.json(
|
|
357
|
+
{
|
|
358
|
+
success: false,
|
|
359
|
+
error: 'invalid_body',
|
|
360
|
+
message: 'Request body must be JSON.',
|
|
361
|
+
},
|
|
362
|
+
400,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
const deliveryId =
|
|
366
|
+
typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';
|
|
367
|
+
if (!deliveryId) {
|
|
368
|
+
return c.json(
|
|
369
|
+
{
|
|
370
|
+
success: false,
|
|
371
|
+
error: 'missing_delivery_id',
|
|
372
|
+
message: 'Body must include `deliveryId: string`.',
|
|
373
|
+
},
|
|
374
|
+
400,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const row = await outbox.redeliver(deliveryId);
|
|
379
|
+
ctx.logger.info?.('[webhook-outbox] redelivered', {
|
|
380
|
+
deliveryId,
|
|
381
|
+
requestedBy: userId,
|
|
382
|
+
});
|
|
383
|
+
return c.json({ success: true, data: { id: row.id, status: row.status } });
|
|
384
|
+
} catch (err: any) {
|
|
385
|
+
const code = err?.code;
|
|
386
|
+
if (code === 'not_found') {
|
|
387
|
+
return c.json(
|
|
388
|
+
{ success: false, error: 'not_found', message: err.message },
|
|
389
|
+
404,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (code === 'not_eligible') {
|
|
393
|
+
return c.json(
|
|
394
|
+
{ success: false, error: 'not_eligible', message: err.message },
|
|
395
|
+
409,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
ctx.logger.error?.(
|
|
399
|
+
'[webhook-outbox] redeliver failed',
|
|
400
|
+
err as Error,
|
|
401
|
+
);
|
|
402
|
+
return c.json(
|
|
403
|
+
{
|
|
404
|
+
success: false,
|
|
405
|
+
error: 'internal_error',
|
|
406
|
+
message: err?.message ?? String(err),
|
|
407
|
+
},
|
|
408
|
+
500,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
ctx.logger.info?.(
|
|
414
|
+
'[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver',
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Resolve the requesting user's id from a better-auth session cookie.
|
|
420
|
+
* Returns `undefined` for anonymous callers — the caller decides
|
|
421
|
+
* whether that's a 401.
|
|
422
|
+
*/
|
|
423
|
+
private async resolveSessionUserId(
|
|
424
|
+
ctx: PluginContext,
|
|
425
|
+
c: any,
|
|
426
|
+
): Promise<string | undefined> {
|
|
427
|
+
try {
|
|
428
|
+
const authService: any = this.tryGetService<any>(ctx, ['auth']);
|
|
429
|
+
if (!authService) return undefined;
|
|
430
|
+
let api: any = authService.api;
|
|
431
|
+
if (!api && typeof authService.getApi === 'function') {
|
|
432
|
+
api = await authService.getApi();
|
|
433
|
+
}
|
|
434
|
+
if (!api?.getSession) return undefined;
|
|
435
|
+
const session = await api.getSession({ headers: c.req.raw.headers });
|
|
436
|
+
const uid = session?.user?.id;
|
|
437
|
+
return typeof uid === 'string' && uid.length > 0 ? uid : undefined;
|
|
438
|
+
} catch {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
280
442
|
}
|