Skip to main content
A handler subscribes to a resource’s mutation events and runs a side effect when the data changes. Handlers live next to the resource that owns the data, in apps/api/src/resources/<name>/handlers/*.ts. Each file calls eventBus.on(...) at module scope — importing the file registers the subscription.

Anatomy of a handler

A handler file imports eventBus, subscribes to one `${table}.${type}` key, iterates docs, and wraps the work in a try/catch so a side-effect failure never takes down the request that triggered it:
data is the typed MutationEvent for the users table, so user is a fully-typed User row — user.fullName, user.email and user.id are all known to the compiler.

Reacting to updates

users.update events carry both the new rows (docs) and the rows as they were (prevDocs). Here Ship pushes every updated user to their own browser over Socket.IO:
Need to act only when a specific field changed? Compare against prevDocs (same order as docs):

Opt a table into events

A table only emits events when its DbService is constructed with the event-bus hook. This is wired in the generated src/db.ts:
eventBus.hook('users') returns the onMutation callback DbService calls after every write — it re-emits the mutation as users.insert / users.update / users.delete. A table created without the fourth argument (e.g. sessions, verifications) writes silently and fires no events. Add the hook when you want a table to be observable.
src/db.ts is generated by codegen-db.ts. Re-run pnpm --filter api codegen after adding a schema; then add eventBus.hook('<table>') to that table’s DbService if it should emit events.

Registration is automatic

You never wire handlers up by hand. codegen-router.ts scans every resources/<name>/handlers/ folder and emits a side-effect import at the top of the generated src/router.ts:
Importing the module runs its top-level eventBus.on(...), registering the subscription. So the workflow is:
1

Add the file

Create resources/<name>/handlers/<what-it-does>.ts and call eventBus.on('<table>.<type>', ...).
2

Run codegen

pnpm --filter api codegen regenerates router.ts with the new side-effect import.
3

Done

The handler is live — no registry, no manual import.
Name handlers for the effect, not the event: sync-analytics.ts, to-sockets.ts, denormalise-counts.ts. One file per side effect keeps each reaction independent and easy for an agent to find.

Keep handlers safe

  • Always try/catch. A handler runs in the same process as the write; an unguarded throw shouldn’t surface as a request error. Log and move on.
  • Iterate docs. Bulk mutations (insertMany, updateMany, deleteMany) deliver one event with many rows.
  • Stay idempotent where you can. Treat handlers as best-effort reactions, not part of the transaction.

Next steps