import type { Iso8601 } from './time.js'; /** * Outbound webhook delivery state machine. * * Transitions: * pending ─claim─▶ in_flight ─recordAttempt(success)─▶ succeeded * └─recordAttempt(failure, retryable)─▶ pending (re-scheduled) * └─recordAttempt(failure, !retryable)─▶ dead * * `failed` is reserved for a terminal non-retry decision the caller made * explicitly (e.g., the subscriber URL is malformed and we don't want to * keep trying). Auto-retried failures stay in `pending` until the budget * is exhausted, then transition to `dead`. */ export type WebhookDeliveryStatus = 'pending' | 'in_flight' | 'succeeded' | 'failed' | 'dead'; /** * Events Stipend emits to host webhooks. Schema lives with the API * package (see `@stipend/api`'s README) — the union of names is part of * the contract. */ export type WebhookEventType = 'payment.succeeded' | 'payment.failed' | 'payment.refunded' | 'entitlement.created' | 'entitlement.exhausted'; export interface WebhookEvent { /** Event type — see {@link WebhookEventType}. */ readonly type: WebhookEventType; /** * Structured payload. The shape is event-type specific and documented * in `@stipend/api`'s README. Stored as opaque JSON in the store. */ readonly data: unknown; } export interface WebhookDelivery { readonly id: string; /** * Caller-supplied dedup key. `enqueueDelivery` is idempotent on this: * a second enqueue with the same key returns the existing delivery * unchanged. */ readonly idempotencyKey: string; /** Destination URL the worker POSTs to. */ readonly url: string; readonly event: WebhookEvent; readonly status: WebhookDeliveryStatus; /** How many delivery attempts have been made (incremented on every recordAttempt). */ readonly attempts: number; /** * Earliest time the next attempt may run. Workers ignore deliveries * whose `scheduledAt` is in the future. */ readonly scheduledAt: Iso8601; /** Last time a worker tried this delivery; absent if never attempted. */ readonly lastAttemptAt?: Iso8601; /** Time the delivery reached `succeeded`. */ readonly succeededAt?: Iso8601; /** Time the delivery reached a terminal failed/dead state. */ readonly terminalAt?: Iso8601; /** Last error message recorded, if any. */ readonly lastError?: string; /** Identifier of the worker that currently holds the in_flight claim. */ readonly workerId?: string; /** Time the current claim was acquired. */ readonly claimedAt?: Iso8601; readonly createdAt: Iso8601; readonly metadata: Readonly>; } export interface EnqueueDeliveryInput { /** Caller-supplied dedup key — see {@link WebhookDelivery.idempotencyKey}. */ readonly idempotencyKey: string; readonly url: string; readonly event: WebhookEvent; /** * When the delivery first becomes eligible. Defaults to "now". Use * this to delay an initial attempt (e.g. a back-pressure window). */ readonly scheduledAt?: Iso8601; readonly metadata?: Readonly>; } export type EnqueueDeliveryResult = { readonly delivery: WebhookDelivery; /** True when the call inserted a new row; false when it matched an existing idempotency key. */ readonly created: boolean; }; export interface ClaimDueDeliveriesInput { /** Worker identity — recorded on each claimed delivery for debuggability. */ readonly workerId: string; /** Maximum number of deliveries to claim in one call. */ readonly limit: number; /** * "Now" for the eligibility comparison. Production callers omit it * and the store uses its own clock; tests inject for determinism. */ readonly now?: Iso8601; /** * How long an in_flight claim is honoured before another claimer may * steal it. Defaults to 60 seconds; tune to (worst-case HTTP timeout + * a safety margin). Tests inject smaller values. */ readonly claimTtlMs?: number; } export type RecordAttemptInput = { readonly deliveryId: string; readonly outcome: 'succeeded'; readonly attemptedAt?: Iso8601; } | { readonly deliveryId: string; readonly outcome: 'retry'; /** Next time the delivery becomes eligible. */ readonly nextScheduledAt: Iso8601; readonly errorMessage: string; readonly attemptedAt?: Iso8601; } | { readonly deliveryId: string; readonly outcome: 'dead'; readonly errorMessage: string; readonly attemptedAt?: Iso8601; } | { readonly deliveryId: string; readonly outcome: 'failed'; readonly errorMessage: string; readonly attemptedAt?: Iso8601; }; export interface ListDeliveriesFilter { readonly status?: readonly WebhookDeliveryStatus[]; readonly eventType?: WebhookEventType; /** Only deliveries created at or after this time. */ readonly since?: Iso8601; /** Maximum rows to return. Implementations should cap at a reasonable upper bound. */ readonly limit?: number; } export interface RecordInboundEventResult { /** True when the call inserted the marker; false when an existing record was found. */ readonly recorded: boolean; /** First-recorded timestamp (either the existing record's or "now" on insert). */ readonly firstSeenAt: Iso8601; } /** * Store for outbound webhook delivery state + inbound event-id * deduplication. Lives in `stipend` core so adapters depend on core * (never on `@stipend/api`). * * Atomicity contracts: * * - `enqueueDelivery` MUST be idempotent on `idempotencyKey` — concurrent * enqueues with the same key collapse to one row (collision detection * on the second writer is what defends this, mirroring the consumption * ledger's pattern). * - `claimDueDeliveries` MUST be atomic — two concurrent claimers * cannot return the same row. Adapters can implement via transactions, * `SELECT … FOR UPDATE SKIP LOCKED`, or in-process mutexes for the * memory implementation. * - `recordInboundEvent` MUST be atomic — concurrent calls with the same * `(source, eventId)` produce exactly one recorded=true result. */ export interface DeliveryStore { enqueueDelivery(input: EnqueueDeliveryInput): Promise; getDelivery(id: string): Promise; /** * Atomically transition up to `limit` pending+due deliveries to * `in_flight`, returning the claimed rows. Includes deliveries whose * existing claim has exceeded `claimTtlMs` (steal-back). */ claimDueDeliveries(input: ClaimDueDeliveriesInput): Promise; recordAttempt(input: RecordAttemptInput): Promise; listDeliveries(filter?: ListDeliveriesFilter): Promise; /** * Record that an inbound event from `source` (e.g. `'stripe'`) with * `eventId` has been observed. Returns `{recorded: true}` on the * first call; `{recorded: false}` on every subsequent call with the * same `(source, eventId)`. Use the result to skip duplicate side * effects on Stripe webhook retries. */ recordInboundEvent(source: string, eventId: string, eventType?: string): Promise; } //# sourceMappingURL=delivery.d.ts.map