Events

Saying something happened, and letting other code react, now or on the queue.

An event says something happened: a note was shared, an order was paid. The code that emits it doesn't need to know who cares. Listeners react, in the same request or as queued jobs.

Defining an event

pnpm exec caldera make:event NoteShared
src/events/note-shared.ts
import { defineEvent } from '@caldera/events'
import { z } from 'zod'

export const NoteShared = defineEvent({
  name: 'note.shared',
  payload: z.object({ noteId: z.string(), email: z.email() }),
})

As with jobs, name is a stable id and payload a Zod schema, checked when the event is emitted.

Emitting

import { emit } from '@caldera/events'

await emit(NoteShared, { noteId: note.id, email })

emit runs the listeners in order and waits for them. If one throws, the rest don't run and emit throws too.

Inside a transaction, { afterCommit: true } waits for the commit, and drops the event if it rolls back, so listeners never react to something that didn't happen.

Listeners

pnpm exec caldera make:listener LogNoteShared --event NoteShared
src/listeners/log-note-shared.ts
import { listen } from '@caldera/events'
import { NoteShared } from '../events/note-shared'

export const logNoteShared = listen(NoteShared, async ({ noteId, email }) => {
  console.log(`Note ${noteId} shared with ${email}`)
})

caldera generate registers every listener in src/listeners. The payload is typed by the event, and the second argument has ctx.services, the app's services.

Queued listeners

A listener runs in the request that emitted the event, so the response waits for it. For anything slow, listen with a job instead:

src/listeners/publish-shared-note.ts
import { listen } from '@caldera/events'
import { NoteShared } from '../events/note-shared'
import { PublishNote } from '../jobs/publish-note'

export const publishSharedNote = listen(
  NoteShared,
  PublishNote.fromEvent((e) => ({ noteId: e.noteId })),
)

Each emit dispatches the job, with the payload fromEvent makes from the event. It's an ordinary job, with its tries, backoff and uniqueness.

Asking listeners

until asks the listeners in turn and returns the first answer that isn't undefined. It suits hooks where one listener decides, like a price:

import { until } from '@caldera/events'

const price = await until(PriceFor, { sku })

Queued listeners can't answer, so until skips them.

To the browser

An event with broadcast also reaches the browsers listening on a channel, so pages update live. That's Realtime.

Seeing every listener

AGENTS.md, which caldera generate keeps up to date, lists every event's listeners, and which are queued:

EventListenerRunsDefined in
note.sharedlogNoteSharedin processsrc/listeners/log-note-shared.ts
note.sharedpublishSharedNotequeued: job notes.publishsrc/listeners/publish-shared-note.ts

Testing

Fake events to check what was emitted without running the listeners:

const events = t.fake('events')

await client.post(`/notes/${note.id}/share`, { json: { email: 'bob@example.test' } })

events.assertEmitted(NoteShared, (e) => e.email === 'bob@example.test')

Without the fake, listeners run as usual, so a test can check what they did, such as the job a queued listener dispatched (with t.fake('queue')).

On this page