Two mechanisms that ship together because neither is useful alone: how one event reaches many jobs, and how a single job is moved between engines without moving the rest.
Fan-out
sendEvent(name, data) writes one job_event row; a dispatcher enqueues one job_queue row per job whose trigger matches that name. work-item/transitioned therefore produces four runs, exactly as it does today.
Three properties that must hold, because their absence is silent:
- A subscriber that throws does not prevent its siblings from being enqueued. Fan-out is not a transaction over the consumers.
- Enqueue is idempotent per
(event, job) — a dispatcher that retries must not double-enqueue.
- The subscriber set is derived from the registry, not from a second hand-maintained list. Two lists drift.
The cutover switch
A per-job-id routing decision in sendEvent / defineJob: this job runs on the Postgres engine, that one still on Inngest. It is what turns a 24-job migration into 24 reversible one-line changes, and what lets the three cutover stories run in parallel.
- The switch is configuration, not a code branch at each call site.
- Its default is Inngest until a job is explicitly moved, so a job nobody has thought about cannot be silently migrated.
- It is deleted by the retirement story once no second lane exists — it is scaffolding with a known end, and this card says so rather than leaving it to become permanent.
Acceptance criteria
- One emitted event enqueues exactly one run per subscribing job — asserted against the real registry, so adding a subscriber cannot silently change the count without a test noticing.
- A dispatcher retry does not double-enqueue:
(event, job) is idempotent, tested by driving the retry.
- A subscriber whose enqueue fails does not prevent the others from being enqueued.
- A job routed to the Postgres engine runs there and does not also run on Inngest; a job not routed still runs on Inngest. Both directions tested — the second is the one that protects the 23 jobs this story does not move.
- The switch's default is Inngest, and a job absent from the configuration takes that default.
- Unit and real-Postgres integration tests ship with the change.
Context refs
lib/jobs/sendEvent.ts — the emit side
lib/jobs/registry.ts — the function list the subscriber set derives from
lib/jobs/defineJob.ts — where a job declares its trigger
lib/jobs/types.ts — JobEventDataMap, the event-name to payload mapping