Subscribers — react to events
A subscriber listens for a domain event (emitted by a workflow viaemitEventStep) and runs an asynchronous side effect: send a notification, sync a search index, create a link. It lives in src/subscribers/ and exports a handler plus a config naming the event.
src/subscribers/brand-created.ts
Fetch full data from { id }
Mutate via workflows, never directly
If the subscriber needs to change data, it runs a workflow — same as a route would. The subscriber is the trigger; the workflow is the work.Log, don’t throw
Idempotency and loop guards
Events can be delivered more than once, and a subscriber that mutates data can re-trigger the very event it listens to. Two defences:- Idempotency — make the handler safe to run twice. Check current state before acting (e.g. “is this product already linked to a brand?” before creating the link), or clear the marker that triggered the work so a redelivered event finds nothing left to do.
- Loop guards — if handling event X causes a mutation that emits X again, gate on a condition that becomes false after the first run, or key off a marker you set. Never emit the same event unconditionally from its own subscriber.
Scheduled jobs — periodic work
A scheduled job runs on a cron interval to do time-based work: poll for records that became ready, reconcile drifted counters, emit a “settle now” event. It lives insrc/jobs/, exports a handler taking the container, and a config with a name and a cron schedule.
src/jobs/deactivate-stale-brands.ts
When to use a job vs a subscriber
| Trigger | Use |
|---|---|
| ”Something happened” (a workflow emitted an event) | Subscriber |
| ”It’s time” / “poll for anything that became ready” | Scheduled job |
brand.review_due), and a subscriber turns each event into a workflow run. Polling for “what’s ready” is the job; reacting to each item is the subscriber.
Job best practices
- Do the work in batches and bound result sets — a job that
SELECTs an unbounded table will eventually time out. Page through withLIMIT/OFFSETor a cursor. - Idempotent by design — a job re-runs on every tick; it must only act on records still needing action (filter on the not-yet-processed state).
- Mutations run workflows, reads run Query — same as everywhere.
- Log a summary each run (how many processed) so drift is visible.
Checklist
- Subscriber
config.eventnames a real emitted event; handler fetches full data from{ id }via Query. - Subscriber mutations run a workflow; errors are caught and logged, not thrown blindly.
- Handler is idempotent and can’t retrigger its own event without a guard.
- Job exports
{ name, schedule }; cron is correct (UTC). - Job filters to records still needing work, batches large sets, and logs a summary.
- Neither a subscriber nor a job writes to the database outside a workflow.