Bridge Training

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".

StatusMeaning
pendingPersisted, waiting for a worker.
processingClaimed by a worker, attempt counted.
doneHandler succeeded.
failedHandler threw and attempts remain; the queue retries it.
exhaustedHandler threw on the last allowed attempt; waits for a replay.
skippedNo handler registered for its (provider, eventType); marked at ingestion.
ExportDefinition
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

ErrorCodeisExpectedFields
InvalidReplayStateErrorINGESTED_EVENT_INVALID_REPLAY_STATEtrueeventId, currentStatus. Constructor (id: string, currentStatus: IngestedEventStatus).
WebhookVerificationErrorWEBHOOK_INVALID_SIGNATUREtruecause 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.

events.ts
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,
};
MethodSignatureDescription
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.

events.ts
// 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 });
}
events.ts
// 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,
    }
  );
}
events.ts
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

MemberSignatureDescription
IngestedEventHandler.providerTProvider
IngestedEventHandler.eventTypestringOne handler per (provider, eventType).
IngestedEventHandler.handle(payload: unknown, ctx: IngestedEventContext) => Promise<void>Idempotent business handling.
IngestedEventHandlerRegistry.get(provider: TProvider | (string & {}), eventType: string) => IngestedEventHandler<TProvider> | undefinedAn 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() => booleanTrue 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.
events.ts
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(); // => true

IngestedEventsRepository: 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>.

MethodSignatureDescription
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.

events.ts
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

ErrorCodeThrown by
WebhookVerificationErrorWEBHOOK_INVALID_SIGNATUREingest when the verifier rejects the request; the verifier's error is kept as cause.
NotFoundErrorINGESTED_EVENT_NOT_FOUNDgetEvent, replayOne.
InvalidReplayStateErrorINGESTED_EVENT_INVALID_REPLAY_STATEreplayOne for a status other than failed or exhausted, or a lost concurrent reset.

See Errors for the base classes.