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:
| Resource | Event types |
|---|---|
| Appointment | appointment.created, appointment.updated |
| Patient | patient.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:
| Event | Notification workflow |
|---|---|
patient.created | patient-welcome |
appointment.created | appointment-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.