Skip to main content
In Ship, every write to the database emits an event. Insert a row, update one, delete one — the typed DbService publishes a MutationEvent describing exactly what changed. Handlers subscribe to those events and run side effects: track analytics, push over a socket, denormalise a counter, fire a webhook. The endpoint stays a thin write; the reactions live elsewhere. This is the seam that keeps resources decoupled. The endpoint that updates a user doesn’t know — or care — that a socket handler broadcasts the change to the browser.

The event shape

Mutation events are emitted by @ship/db’s DbService and typed against the table they came from:
import type { MutationEvent } from '@ship/db';

interface MutationEvent<T> {
  type: 'insert' | 'update' | 'delete';
  docs: T[];        // the rows that were written
  prevDocs?: T[];   // the rows as they were before (update / delete only)
}
A single event carries the full set of affected rows — insertMany, updateMany and deleteMany emit one event with every row in docs, so a handler always iterates.

What each type carries

insertOne / insertMany emit type: 'insert'. docs holds the new rows; there is no prevDocs.
{ type: 'insert', docs: [user] }
Ship tables use soft deletes by default — deletedAt lives on baseColumns, so a “delete” in your domain is usually an updateOne(..., { deletedAt: new Date() }), which emits type: 'update'. A hard deleteOne / deleteMany is what emits type: 'delete'.

Comparing before and after

For updates, prevDocs is the previous version of each row, in the same order as docs. That makes “did this field actually change?” a one-liner — no diff library, no change object:
eventBus.on('users.update', ({ docs, prevDocs }) => {
  for (const [i, user] of docs.entries()) {
    const before = prevDocs?.[i];
    if (before && before.email !== user.email) {
      logger.info(`User ${user.id} email changed: ${before.email}${user.email}`);
    }
  }
});
prevDocs is only loaded when a table actually has handlers wired up — DbService reads the previous rows before an update or delete, then skips that read when nothing is listening. You don’t pay for events you don’t use.

Event keys

Each event is published under a key of `${table}.${type}`, so handlers subscribe to a specific table-and-operation pair:
KeyFires when
users.inserta row is inserted into users
users.updatea row in users is updated
users.deletea row in users is hard-deleted
The keys are typed — eventBus.on only accepts `${TableName}.${MutationType}`, so a typo or a non-existent operation won’t compile.

Why events

Events solve two problems cleanly:
  • Cross-resource side effects without coupling. Keep a change history, broadcast over a socket, or sync to an external system by subscribing to a resource’s events — not by importing its service into yours. The resource that owns the write stays unaware of who reacts.
  • One place that describes system behaviour. Every meaningful change flows through the same typed channel. Reading the handlers/ folders tells you everything that happens as a consequence of a write — much like Stripe exposing an event for every change in their system.

Next steps