Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions branch_structure.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"default_branch": "main",
"branches": ["feat/issue-698-persist-webhook-execution-attempts-and-stable", "main", "origin"],
"parent": {
"feat/issue-698-persist-webhook-execution-attempts-and-stable": "main",
"main": null,
"origin": "main"
},
"order": ["main", "feat/issue-698-persist-webhook-execution-attempts-and-stable", "origin"]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "rm -rf dist && npx rolldown --config rolldown.config.mjs && npx tsc -p tsconfig.types.json && node scripts/fix-dts-imports.mjs",
"build": "rm -rf dist && bun build src/index.ts --outdir dist --target node --format esm --packages external && bunx tsc -p tsconfig.types.json && node scripts/fix-dts-imports.mjs",
"check:aliases": "node scripts/check-alias-imports.mjs",
"check:consumer": "node fixtures/consumer/verify.mjs && bun run check:package-consumer",
"check:package-consumer": "bun run build && node scripts/check-package-consumer.mjs",
Expand Down
272 changes: 260 additions & 12 deletions src/runtime/database/sql-database-adapter.ts

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions src/runtime/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ export interface WebhookEventRecord {
errorMessage: string | null;
processedAt: string | null;
createdAt: string;
/** Stable identity for the external operation this event represents. Reused across all attempts and retries. */
operationKey: string;
/** Monotonically incrementing attempt counter for this event. */
attemptCount: number;
/** Identity of the currently claimed attempt (if any). */
attemptId: string | null;
/** Owner token of the current processing lease (if any). */
leaseOwner: string | null;
/** Expiration instant of the current processing lease (if any). */
leaseExpiresAt: string | null;
}

export interface WatcherTaskRecord {
Expand All @@ -103,6 +113,26 @@ export interface WatcherTaskRecord {
createdAt: string;
}

/** Typed context passed to the webhook callback for a specific attempt. */
export interface WebhookCallbackContext {
/** Stable operation key shared by all attempts for this event. */
operationKey: string;
/** Unique identity of this attempt. */
attemptId: string;
/** Monotonically increasing attempt counter for this event. */
attemptCount: number;
/** Owner token of the processing lease held by this attempt. */
leaseOwner: string;
/** Expiration instant of the processing lease held by this attempt. */
leaseExpiresAt: string;
/** Time the underlying webhook event was received. */
receivedAt: string;
/** Optional raw request body. */
rawBody: string | Buffer | Uint8Array;
/** Optional signature provided with the request. */
signature?: string;
}

export interface SettlementJobRecord {
id: string;
operationId: string;
Expand Down Expand Up @@ -198,7 +228,30 @@ export interface DatabaseAdapter {
eventId: string;
provider: string;
payload: Record<string, unknown>;
/** Stable operation key for this event. Must be reused by all attempts and retries. */
operationKey?: string;
}): Promise<{ record: WebhookEventRecord; inserted: boolean }>;
/**
* Atomically claims the next attempt for a webhook event. Returns null when the event is already
* processed or is currently leased by another owner. On success the returned record carries a
* fresh attemptId, attemptCount, leaseOwner and leaseExpiresAt.
*/
claimWebhookAttempt(input: {
id: string;
leaseOwner: string;
leaseDurationMs: number;
}): Promise<WebhookEventRecord | null>;
/**
* Records the final outcome of an attempt. Only the current lease owner may complete the
* attempt; stale owners receive false and must not retry the financial side effect.
*/
completeWebhookAttempt(input: {
id: string;
attemptId: string;
leaseOwner: string;
status: 'processed' | 'failed' | 'unresolved';
errorMessage?: string;
}): Promise<boolean>;
updateWebhookEventStatus(input: {
id: string;
status: 'processed' | 'failed' | 'unresolved';
Expand Down Expand Up @@ -266,5 +319,11 @@ export interface WebhookProcessor {
eventId: string;
provider: string;
status?: WebhookEventRecord['status'];
/** Stable operation key for this event (reused by all attempts). */
operationKey?: string;
/** Identity of the attempt that was claimed for this call, if any. */
attemptId?: string;
/** Monotonically increasing attempt counter for this event. */
attemptCount?: number;
}>;
}
100 changes: 82 additions & 18 deletions src/runtime/webhooks/default-webhook-processor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
DatabaseAdapter,
WebhookCallbackContext,
WebhookEventRecord,
WebhookProcessor,
} from '@/runtime/interfaces.ts';
Expand All @@ -12,8 +13,10 @@ interface DefaultWebhookProcessorOptions {
}

const DEFAULT_CALLBACK_TIMEOUT_MS = 30_000;
const DEFAULT_LEASE_DURATION_MS = 60_000;
const CALLBACK_FAILURE_MESSAGE = 'Webhook callback failed';
const CALLBACK_TIMEOUT_MESSAGE = 'Webhook callback timed out';
const CALLBACK_LOCK_LOST_MESSAGE = 'Webhook attempt lost its processing lease';

function toComparableBuffer(value: string): Buffer {
return Buffer.from(value, 'utf8');
Expand All @@ -30,6 +33,15 @@ function safeEquals(left: string, right: string): boolean {
return timingSafeEqual(leftBuffer, rightBuffer);
}

/**
* Derives a stable operation key for a webhook event. The key is deterministic for a given
* (provider, eventId) pair so that retries and concurrent attempts always resolve to the
* same external operation identity.
*/
export function deriveWebhookOperationKey(provider: string, eventId: string): string {
return `${provider}:${eventId}`;
}

export class DefaultWebhookProcessor implements WebhookProcessor {
private readonly config: AnchorKitConfigSnapshot;
private readonly database: DatabaseAdapter;
Expand All @@ -48,30 +60,77 @@ export class DefaultWebhookProcessor implements WebhookProcessor {
payload: Record<string, unknown>;
rawBody: string | Buffer | Uint8Array;
signature?: string;
retry?: boolean;
}): Promise<{
duplicate: boolean;
eventId: string;
provider: string;
status?: WebhookEventRecord['status'];
operationKey?: string;
attemptId?: string;
attemptCount?: number;
}> {
this.verifySignatureIfEnabled(input);

const operationKey = deriveWebhookOperationKey(input.provider, input.eventId);

const insertion = await this.database.insertOrGetWebhookEvent({
id: randomUUID(),
eventId: input.eventId,
provider: input.provider,
payload: input.payload,
operationKey,
});

if (!insertion.inserted) {
if (!insertion.inserted && !input.retry) {
return {
duplicate: true,
eventId: insertion.record.eventId,
provider: insertion.record.provider,
status: insertion.record.status,
operationKey: insertion.record.operationKey,
attemptId: insertion.record.attemptId ?? undefined,
attemptCount: insertion.record.attemptCount,
};
}

const leaseOwner = randomUUID();
const leaseDurationMs = DEFAULT_LEASE_DURATION_MS;
const claimed = await this.database.claimWebhookAttempt({
id: insertion.record.id,
leaseOwner,
leaseDurationMs,
});

if (!claimed) {
return {
duplicate: true,
eventId: insertion.record.eventId,
provider: insertion.record.provider,
status: insertion.record.status,
operationKey: insertion.record.operationKey,
attemptId: insertion.record.attemptId ?? undefined,
attemptCount: insertion.record.attemptCount,
};
}

const attemptId = claimed.attemptId;
if (!attemptId) {
throw new Error('Webhook attempt claim did not return an attempt identity');
}

const context: WebhookCallbackContext = {
operationKey: claimed.operationKey,
attemptId,
attemptCount: claimed.attemptCount,
leaseOwner,
leaseExpiresAt:
claimed.leaseExpiresAt ?? new Date(Date.now() + leaseDurationMs).toISOString(),
receivedAt: claimed.createdAt,
signature: input.signature,
rawBody: input.rawBody,
};

try {
const callback = this.config.webhooks?.onEvent;
if (callback) {
Expand All @@ -81,16 +140,12 @@ export class DefaultWebhookProcessor implements WebhookProcessor {
Promise.resolve().then(() =>
callback(
{
id: insertion.record.id,
eventId: insertion.record.eventId,
provider: insertion.record.provider,
payload: insertion.record.payload,
},
{
receivedAt: insertion.record.createdAt,
signature: input.signature,
rawBody: input.rawBody,
id: claimed.id,
eventId: claimed.eventId,
provider: claimed.provider,
payload: claimed.payload,
},
context,
),
),
new Promise<never>((_, reject) => {
Expand All @@ -105,15 +160,22 @@ export class DefaultWebhookProcessor implements WebhookProcessor {
}
}

await this.database.updateWebhookEventStatus({
id: insertion.record.id,
const completed = await this.database.completeWebhookAttempt({
id: claimed.id,
attemptId,
leaseOwner,
status: 'processed',
});
if (!completed) {
throw new Error(CALLBACK_LOCK_LOST_MESSAGE);
}
} catch (error) {
const timedOut = error instanceof Error && error.message === CALLBACK_TIMEOUT_MESSAGE;
const message = timedOut ? CALLBACK_TIMEOUT_MESSAGE : CALLBACK_FAILURE_MESSAGE;
await this.database.updateWebhookEventStatus({
id: insertion.record.id,
await this.database.completeWebhookAttempt({
id: claimed.id,
attemptId,
leaseOwner,
status: timedOut ? 'unresolved' : 'failed',
errorMessage: message,
});
Expand All @@ -122,8 +184,11 @@ export class DefaultWebhookProcessor implements WebhookProcessor {

return {
duplicate: false,
eventId: insertion.record.eventId,
provider: insertion.record.provider,
eventId: claimed.eventId,
provider: claimed.provider,
operationKey: claimed.operationKey,
attemptId: attemptId,
attemptCount: claimed.attemptCount,
};
}

Expand Down Expand Up @@ -182,8 +247,7 @@ export class DefaultWebhookProcessor implements WebhookProcessor {
rawBody: string | Buffer | Uint8Array;
signature?: string;
}): void {
const verifyEnabled = this.config.security.verifyWebhookSignatures ?? true;

const verifyEnabled = this.config.security.verifyWebhookSignatures ?? false;
if (!verifyEnabled) {
return;
}
Expand Down
2 changes: 2 additions & 0 deletions tests/core/factory-pre-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ describe('AnchorInstance pre-init router access', () => {
createDepositWithIdempotency: unexpectedDatabaseCall,
deletePendingIdempotencyRecord: unexpectedDatabaseCall,
insertOrGetWebhookEvent: unexpectedDatabaseCall,
claimWebhookAttempt: unexpectedDatabaseCall,
completeWebhookAttempt: unexpectedDatabaseCall,
updateWebhookEventStatus: unexpectedDatabaseCall,
insertOrGetSettlementJob: unexpectedDatabaseCall,
getSettlementJobByOperationId: unexpectedDatabaseCall,
Expand Down
5 changes: 5 additions & 0 deletions tests/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ describe('package root exports', () => {
errorMessage: null,
processedAt: null,
createdAt: '2026-09-30T00:00:00.000Z',
operationKey: 'example-provider:event-1',
attemptCount: 0,
attemptId: null,
leaseOwner: null,
leaseExpiresAt: null,
};

const schema: AnchorKitConfig = {
Expand Down
6 changes: 6 additions & 0 deletions tests/runtime/auth-challenge-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ class ControllableFakeDatabaseAdapter implements DatabaseAdapter {
async insertOrGetWebhookEvent(): Promise<never> {
throw new Error('Not implemented');
}
async claimWebhookAttempt(): Promise<null> {
return null;
}
async completeWebhookAttempt(): Promise<boolean> {
return false;
}
async updateWebhookEventStatus(): Promise<void> {}
async insertWatcherTask(): Promise<void> {}
async listPendingWatcherTasks(): Promise<never[]> {
Expand Down
18 changes: 9 additions & 9 deletions tests/runtime/database/migrate-single-flight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,13 @@ function attachPostgresClient(adapter: SqlDatabaseAdapter, client: unknown): Sql
}

/**
* Nineteen statements define one migration sequence after transaction-field
* persistence and AK-046 provider-scoped deduplication were added:
* 6 CREATE TABLE + 1 ALTER idempotency_keys + 6 ALTER transaction fields
* + 1 DO block for webhook dedupe + 5 CREATE INDEX.
* Twenty-eight queries define migration sequence after transaction fields,
* webhook-attempt columns, and provider-scoped deduplication were added:
* 7 CREATE TABLE + 1 ALTER idempotency_keys + 6 ALTER transaction fields
* + 1 DO block for webhook dedupe + 1 migration lookup + 5 ALTER webhook_events
* + 1 migration insert + 6 CREATE INDEX.
*/
const POSTGRES_SEQUENCE_LENGTH = 19;
const POSTGRES_SEQUENCE_LENGTH = 28;

describe('Concurrent database migrations (#600)', () => {
it('runs a single migration sequence for concurrent callers', async () => {
Expand Down Expand Up @@ -125,10 +126,9 @@ describe('Concurrent database migrations (#600)', () => {

await Promise.all(Array.from({ length: 5 }, () => adapter.migrate()));

// 1 CREATE TABLE batch + 5 CREATE INDEX statements. The webhook dedupe
// upgrade is a no-op on a fresh database (the composite constraint is
// already present), so it does not add an extra exec call here.
expect(execCount).toBe(6);
// 1 CREATE TABLE batch + 5 webhook-attempt ALTERs + 6 CREATE INDEX
// statements. The provider-dedupe upgrade is a no-op on fresh databases.
expect(execCount).toBe(12);

// The schema is usable after the shared migration.
await adapter.insertAuthChallenge({
Expand Down
6 changes: 6 additions & 0 deletions tests/runtime/idempotency-key-size-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ function makeFakeDb(rejectOnIdempotency = false): {
async insertOrGetWebhookEvent() {
throw new Error('Not implemented in this stub');
},
async claimWebhookAttempt() {
return null;
},
async completeWebhookAttempt() {
return false;
},
async updateWebhookEventStatus() {},
async insertWatcherTask() {},
async listPendingWatcherTasks() {
Expand Down
Loading
Loading