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.
resources/users/
  handlers/
    sync-analytics.ts   # users.insert → track in analytics
    to-sockets.ts       # users.update → push to the browser

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:
import { eventBus } from '@/event-bus';
import logger from '@/logger';
import { analyticsService } from '@/services';

eventBus.on('users.insert', (data) => {
  try {
    for (const user of data.docs) {
      analyticsService.track('New user created', {
        fullName: user.fullName,
      });
    }
  } catch (err) {
    logger.error(`users.insert handler error: ${err}`);
  }
});
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:
import { eventBus } from '@/event-bus';
import ioEmitter from '@/io-emitter';
import logger from '@/logger';

eventBus.on('users.update', (data) => {
  try {
    for (const user of data.docs) {
      logger.debug(`Emitting user:updated to user ${user.id} (${user.email})`);
      ioEmitter.publishToUser(user.id, 'user:updated', user);
    }
  } catch (err) {
    logger.error(`users.update handler error: ${err}`);
  }
});
Need to act only when a specific field changed? Compare against prevDocs (same order as docs):
eventBus.on('users.update', ({ docs, prevDocs }) => {
  for (const [i, user] of docs.entries()) {
    const before = prevDocs?.[i];
    if (before && !before.isEmailVerified && user.isEmailVerified) {
      analyticsService.track('Email verified', { userId: user.id });
    }
  }
});

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:
users: new DbService<typeof users>(users, db, 'users', eventBus.hook('users')),
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:
// Auto-generated by codegen-router.ts — do not edit manually

import './resources/users/handlers/sync-analytics';
import './resources/users/handlers/to-sockets';
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