Skip to Content

Events

Every domain event uses the same versioned envelope:

type DomainEvent = { id: EventId; type: DomainEventType; version: 1; workspaceId: WorkspaceId; memberId: MemberId; occurredAt: string; resourceType: "appointment" | "patient"; resourceId: string; };

The envelope contains identifiers only. It does not copy names, contact details, appointment reasons, or other health data into Workflow storage. Listeners for non-deletion events can load the current resource when they need more context.

Persistence

events is the append-only history. Every row stores the event ID, workspace, type, version, member, occurrence time, and the primary polymorphic resource_type / resource_id pair. recorded_at is the database receipt time; it is kept separate from the domain-owned occurred_at.

event_resources links an event to every timeline where it is relevant. The primary resource is always linked. Appointment events also link to their patient and practitioner, so those timelines do not depend on mutable joins back to the current appointment row. Polymorphic resource IDs deliberately have no resource foreign key, allowing history to survive resource deletion.

Event catalog

The catalog matches implemented mutations:

ResourceEvent types
Appointmentappointment.created, appointment.updated
Patientpatient.created, patient.updated, patient.deleted

Add a contract only when its mutation path exists.

Delivery

Repositories write the resource mutation, canonical event, timeline links, and event-outbox row in one PostgreSQL transaction. The outbox references the canonical event by ID rather than storing a second payload. The scheduled /v1/system/events dispatcher claims pending rows with SKIP LOCKED, starts dispatchDomainEvent through Vercel Workflow, and marks accepted rows dispatched. A failed start releases the claim for retry. Delivery is at least once.

All listener lists are explicit in apps/api/src/workflows/events/listeners.ts. The mapped type requires a key for every event in the public catalog, so adding a new event fails typechecking until its listener seam exists.

The current creation listeners start transactional notification workflows:

EventNotification workflow
patient.createdpatient-welcome
appointment.createdappointment-confirmation

The notification step reloads the resource through its repository. Domain event payloads remain identifier-only.

Adding a listener

Create a workflow with the exact event type, then add it to the matching list:

import type { DomainEventOf } from "@dawn/domain"; export async function syncPatient(event: DomainEventOf<"patient.updated">) { "use workflow"; // Call retryable step functions here. }

Change the existing registry entry:

export const domainEventWorkflows = { - "patient.updated": [], + "patient.updated": [syncPatient], } satisfies DomainEventWorkflowMap;

Listener workflows for one event run concurrently and are flattened into the dispatcher. External side effects must use event.id or the Workflow step ID as an idempotency key because steps can retry.

Last updated on