Events
Inbound webhook ingestion: verify, persist, enqueue, the worker lifecycle, admin replay, and the in-memory repository for tests.
The events module ingests webhooks from third-party providers. An inbound delivery is verified, persisted once per (provider, externalId) and enqueued; a worker claims it, runs the registered handler and marks it done or failed; the admin app lists, inspects and replays events. The API hosts the HTTP side, the jobs runtime hosts the worker, the admin app the replay surface.
Import from @workspace/training/domain/events and @workspace/training/application/events. The cross-cutting WebhookVerifier and Queue ports it depends on are documented on the Ports page.
Domain
Status
IngestedEventStatus is "pending" | "processing" | "done" | "failed" | "exhausted" | "skipped".
| Status | Meaning |
|---|---|
pending | Persisted, waiting for a worker. |
processing | Claimed by a worker, attempt counted. |
done | Handler succeeded. |
failed | Handler threw and attempts remain; the queue retries it. |
exhausted | Handler threw on the last allowed attempt; waits for a replay. |
skipped | No handler registered for its (provider, eventType); marked at ingestion. |
| Export | Definition |
|---|---|
CLAIMABLE_INGESTED_EVENT_STATUSES | ["pending", "failed"]: what a worker may take, a new event or a failed one its retry comes back for. exhausted waits for a replay. |
Errors
| Error | Code | isExpected | Fields |
|---|---|---|---|
InvalidReplayStateError | INGESTED_EVENT_INVALID_REPLAY_STATE | true | eventId, currentStatus. Constructor (id: string, currentStatus: IngestedEventStatus). |
WebhookVerificationError | WEBHOOK_INVALID_SIGNATURE | true | cause holds the raw provider error for server-side logs. Constructor (reason: string, cause?: unknown); reason is a short non-sensitive category safe to surface. |
Application
EventIngestionService
createEventIngestionService(deps: EventIngestionServiceDeps): EventIngestionService
Orchestrates the verify → persist → enqueue lifecycle for inbound webhooks, the worker's claim → handle → done/failed lifecycle, and admin replay. Verification lives in ingest rather than in the HTTP adapter so the backfill CLI shares the flow.
Prop
Type
Prop
Type
DEFAULT_INGESTION_TOPICS is { process: "webhook.process", sweepPending: "webhook.sweep-pending" }. Queue names allow only alphanumerics, _, -, . and /, no colons.
const config: EventIngestionConfig = {
topics: DEFAULT_INGESTION_TOPICS, // { process: "webhook.process", sweepPending: "webhook.sweep-pending" }
retryLimit: 5,
retryDelayMs: 30_000,
retryBackoff: "exponential",
sweepStaleMinutes: 10,
sweepCronExpression: "*/5 * * * *",
gracefulShutdownTimeoutMs: 10_000,
replayBatchSize: 100,
};| Method | Signature | Description |
|---|---|---|
ingest | (req: EventIngestionRequest) => Promise<{ eventId: string; duplicate: boolean }> | Verifies the request, creates the row, then enqueues it when it is new and a handler is known, else marks it skipped. A repeat delivery returns the existing id with duplicate: true and dispatches nothing. Throws WebhookVerificationError when the verifier rejects the request, and a plain Error when the service was built without a verifier. |
process | (eventId: string) => Promise<void> | Worker entrypoint: claims the row, runs the handler, marks it done. A lost claim returns silently. When the handler throws, marks the row failed (or exhausted) and re-throws so the queue retries. |
getEvent | (id: string) => Promise<PersistedIngestedEvent> | Throws NotFoundError (INGESTED_EVENT_NOT_FOUND). |
listEvents | (input: { cursor?: IngestedEventsCursor; limit: number; filters?: ListIngestedEventsFilters }) => Promise<IngestedEventsPage> | Paginated, filtered listing delegated to the repository. |
stats | () => Promise<IngestedEventsStats> | Counts by status. |
distinctEventTypes | (params?: { provider?: string }) => Promise<string[]> | |
distinctProviders | () => Promise<string[]> | Every provider that ever sent an event. |
replayOne | (id: string) => Promise<void> | Resets a failed or exhausted event to pending and re-enqueues it. Throws NotFoundError for an unknown id and InvalidReplayStateError for any other status, or when a concurrent replay won the atomic reset. |
replayFailed | (filter?: ListIngestedEventsFilters) => Promise<{ enqueued: number; skipped: number }> | Bulk replay over a filtered page walk of replayBatchSize rows, never a full-table read. skipped counts rows that were not replayable or changed between the read and the reset. |
EventIngestionRequest<TProvider extends string = string> is { provider: TProvider; request: Request }. TProvider narrows the tag to the caller's own literal: a route handler knows which provider it serves.
// HTTP side: the verifier is required, the request is the raw webhook delivery.
export async function handleWebhook(
verifier: WebhookVerifier<Provider>,
repository: IngestedEventsRepository,
queue: Queue,
request: Request
) {
const ingestion = createEventIngestionService({
verifier,
repository,
queue,
registry,
config,
});
const result = await ingestion.ingest({ provider: "billing", request });
// => { eventId, duplicate: false } on first delivery
// => { eventId, duplicate: true } on a repeat delivery, nothing re-enqueued
return new Response(JSON.stringify(result), { status: 202 });
}// Worker side: no verifier; `process` claims, handles and marks the row.
export async function startWorker(repository: IngestedEventsRepository, queue: Queue) {
const ingestion = createEventIngestionService({ repository, queue, registry, config });
await queue.work<{ id: string }>(
config.topics.process,
(msg) => ingestion.process(msg.data.id), // re-throws after markFailed so the queue retries
{
maxAttempts: config.retryLimit,
initialDelayMs: config.retryDelayMs,
backoff: config.retryBackoff,
}
);
}export async function replay(ingestion: EventIngestionService, eventId: string) {
try {
await ingestion.replayOne(eventId);
} catch (error) {
if (error instanceof InvalidReplayStateError) {
error.currentStatus; // => "pending" | "processing" | "done" | "skipped"
error.code; // => "INGESTED_EVENT_INVALID_REPLAY_STATE"
} else if (isAppError(error)) {
error.code; // => "INGESTED_EVENT_NOT_FOUND"
}
throw error;
}
// Bulk variant, filtered and paged by `config.replayBatchSize`:
return ingestion.replayFailed({ provider: "billing", status: ["failed", "exhausted"] });
// => { enqueued, skipped }
}Ports
IngestedEventHandler and its registry. A handler serves one (provider, eventType) pair and must be idempotent: replaying the same payload N times converges. TProvider lets an adapter narrow the tag to its own literal so its factory returns a homogeneous handler list.
Prop
Type
| Member | Signature | Description |
|---|---|---|
IngestedEventHandler.provider | TProvider | |
IngestedEventHandler.eventType | string | One handler per (provider, eventType). |
IngestedEventHandler.handle | (payload: unknown, ctx: IngestedEventContext) => Promise<void> | Idempotent business handling. |
IngestedEventHandlerRegistry.get | (provider: TProvider | (string & {}), eventType: string) => IngestedEventHandler<TProvider> | undefined | An unknown pair resolves to undefined and the ingestion service marks the row skipped. The string & {} merge keeps a persisted provider (a text column without CHECK) assignable. |
IngestedEventHandlerRegistry.hasHandlers | () => boolean | True when at least one handler is registered; the composition root uses it to decide whether the worker is worth starting. |
createIngestedEventHandlerRegistry | <TProvider extends string>(handlers: IngestedEventHandler<TProvider>[]) => IngestedEventHandlerRegistry<TProvider> | Keys handlers by provider:eventType. |
type Provider = "billing";
// Idempotent: replaying the same payload converges on the same state.
declare function applyInvoicePaid(payload: unknown, eventId: string): Promise<void>;
const invoicePaid: IngestedEventHandler<Provider> = {
provider: "billing",
eventType: "invoice.paid",
handle: (payload, ctx) => applyInvoicePaid(payload, ctx.eventId),
};
const registry = createIngestedEventHandlerRegistry<Provider>([invoicePaid]);
registry.get("billing", "invoice.paid"); // => invoicePaid
registry.get("billing", "invoice.voided"); // => undefined: the event will be stored as `skipped`
registry.hasHandlers(); // => trueIngestedEventsRepository: the persisted events and their atomic transitions.
Prop
Type
Prop
Type
Prop
Type
IngestedEventsCursor is { createdAt: Date; id: string }, a composite cursor over (created_at DESC, id DESC); both fields are required to break ties. IngestedEventsPage is { rows: PersistedIngestedEvent[]; nextCursor: IngestedEventsCursor | null }. IngestedEventsStats is Record<IngestedEventStatus, number>.
| Method | Signature | Description |
|---|---|---|
create | (dto: CreateIngestedEventDTO) => Promise<PersistedIngestedEvent | null> | Null when (provider, externalId) already exists: dedup, not an exception. |
findByProviderExternalId | (input: { provider: string; externalId: string }) => Promise<PersistedIngestedEvent | null> | |
findById | (id: string) => Promise<PersistedIngestedEvent | null> | |
findPendingOlderThan | (cutoff: Date) => Promise<PersistedIngestedEvent[]> | pending rows created before cutoff: orphans the sweep cron re-enqueues. |
claim | (id: string) => Promise<PersistedIngestedEvent | null> | Atomic pending/failed → processing, counting the attempt. Null on double-claim. |
resetForReplay | (id: string) => Promise<PersistedIngestedEvent | null> | Atomic failed/exhausted → pending so a replay is claimable. Null unless replayable. |
markDone | (id: string, status?: "done" | "skipped") => Promise<void> | Defaults to done. |
markFailed | (id: string, error: unknown, opts: { retryLimit: number }) => Promise<void> | Sets failed or exhausted depending on attempts versus retryLimit. |
listPaginated | (params: { cursor?; limit: number; filters? }) => Promise<IngestedEventsPage> | Stable (created_at DESC, id DESC) order; the caller owns clamping limit. |
statsByStatus | () => Promise<IngestedEventsStats> | Zero-filled for statuses without rows. |
distinctEventTypes | (params?: { provider?: string }) => Promise<string[]> | |
distinctProviders | () => Promise<string[]> | Sorted. |
Cross-cutting ports used by the service: WebhookVerifier<TProvider> turns a Request into an IngestedEvent or throws, and Queue carries the { id } job between ingest and process. Both are on the Ports page.
Testing
@workspace/training/testing exports createFakeIngestedEventsRepository(): IngestedEventsRepository, an in-memory repository mirroring the Drizzle implementation's atomic transitions: dedup on create, claim from pending/failed only, replay from failed/exhausted only, markFailed switching to exhausted once attempts reaches retryLimit. Its listPaginated returns every row in one page. Paired with createFakeQueue from the same entry, it runs the worker lifecycle without Postgres.
export async function workerLifecycleInTests() {
const repository = createFakeIngestedEventsRepository();
const queue = createFakeQueue({ mode: "manual" }); // `send` records, `drain` runs the handlers
const ingestion = createEventIngestionService({ repository, queue, registry, config });
const created = await repository.create({
provider: "billing",
externalId: "evt_1",
eventType: "invoice.paid",
occurredAt: new Date(),
payload: {},
});
// created => status "pending", attempts 0
await ingestion.process(created?.id ?? "");
// claim: pending → processing (attempts 1), then markDone: → done
await ingestion.process(created?.id ?? ""); // claim lost: returns without running the handler
return repository.statsByStatus(); // => { pending: 0, processing: 0, done: 1, failed: 0, exhausted: 0, skipped: 0 }
}The factories makeIngestedEvent (verifier output) and makePersistedIngestedEvent (persisted row) live in @workspace/training/factories with neutral test-provider / test.event defaults. See Factories.
Errors
| Error | Code | Thrown by |
|---|---|---|
WebhookVerificationError | WEBHOOK_INVALID_SIGNATURE | ingest when the verifier rejects the request; the verifier's error is kept as cause. |
NotFoundError | INGESTED_EVENT_NOT_FOUND | getEvent, replayOne. |
InvalidReplayStateError | INGESTED_EVENT_INVALID_REPLAY_STATE | replayOne for a status other than failed or exhausted, or a lost concurrent reset. |
See Errors for the base classes.