Ferry
Server

Hooks

Hooks put your backend's rules into sync, per table: validate refuses writes, onWrite writes more in the same transaction, and afterCommit reacts once a change is saved.

Server
export const syncServer = createSyncServer({
  schema,
  store: memoryStore(),
  auth: async (request) => (await getSession(request))?.userId ?? null,
  tables: {
    tasks: {
      // A rule: return a reason to refuse the whole transaction.
      validate: ({ after }) => {
        if (after !== null && after.title.trim() === '') {
          return 'A task needs a title';
        }
      },
      // After the commit: tell the outside world.
      afterCommit: async ({ before, after }) => {
        if (after?.done === true && before?.done === false) {
          await notifyProject(after.projectId, `Done: ${after.title}`);
        }
      },
    },
    projects: {
      // More writes in the same transaction: they commit with the change, or not at all.
      onWrite: async ({ kind, after }, tx) => {
        if (kind === 'create' && after !== null) {
          await tx.tasks.create({ projectId: after.id, title: 'Invite your team' });
        }
      },
    },
  },
});

Hooks run for every change, wherever it comes from: an app's push, a write from your backend, and the writes of other hooks.

What a hook sees

FieldWhat it is
tableThe table, 'tasks'
kind'create', 'update' or 'delete'
beforeThe row before, typed by the table's schema; null for a create
afterThe row after; null for a delete
actorWho writes: see below
nameThe transaction's name: tasks.update.v1 from an app, or the name it gave a transaction

actor is one of:

actorWhen
{ type: 'user', userId, clientId }An app's write, from the device clientId
{ type: 'user', userId, clientId: null }syncServer.write({ as: { userId } }, …)
{ type: 'system' }syncServer.write({ as: 'system' }, …), and the writes of onWrite hooks

A hook runs once per change to a row. An update that changes nothing, or a delete of a row already gone, runs none.

validate

Runs for each change, in the transaction, before anything is saved. Return a reason to refuse:

It returnsThen
nothing, nullThe change goes on
a stringRefused, with code rejected and the string as the message
{ code, message? }Refused, with your code

A refusal refuses the whole transaction: the app rolls it back and calls its onRejected with the code and message; a write from your backend throws a WriteRejectedError.

get(table, id) reads any row as the transaction sees it so far, this change included:

Server
export const syncServer = createSyncServer({
  schema,
  store: memoryStore(),
  auth: async (request) => (await getSession(request))?.userId ?? null,
  tables: {
    tasks: {
      validate: async ({ after, get }) => {
        if (after !== null && (await get('projects', after.projectId)) === null) {
          return { code: 'no_project', message: 'This project was deleted' };
        }
      },
    },
  },
});

The schema and the user's spaces were checked before: validate is for rules beyond them.

onWrite

Runs after validate, in the same database transaction, with a tx to write through. What it writes:

  • commits with the change, or not at all, and reaches every device that can see it;
  • comes after the change in the log;
  • is written as system, so it may write any space (a private row needs { owner });
  • runs the hooks of its own tables, up to 8 levels deep. Deeper is a loop, and fails the request.

tx.<table> has create, update, delete and get, all async, as in write. Writes apply in the order they are called, awaited or not.

Server
export const syncServer = createSyncServer({
  schema,
  store: memoryStore(),
  auth: async (request) => (await getSession(request))?.userId ?? null,
  tables: {
    projects: {
      // Deleting a project deletes its tasks, in the same transaction.
      onWrite: async ({ kind, before }, tx) => {
        if (kind === 'delete' && before !== null) {
          for (const taskId of await taskIdsOf(before.id)) {
            await tx.tasks.delete(taskId);
          }
        }
      },
    },
  },
});

taskIdsOf is your own query: tx reads one row by id, and finding rows is your database's job (through a mirror, for one). The devices that see the project get the deleted tasks on their next pull, like any change from elsewhere.

afterCommit

Runs once per applied change, after the transaction commits, in the order of the changes. Never for a refused transaction.

  • The request waits for it before answering, unless the server has waitUntil: then it runs after the response.
  • What it throws goes to onError and fails nothing.
  • It is the place for what is not sync data: emails, push notifications, webhooks, search indexes.

Order

For each change in a transaction:

  1. The table's schema validates the row.
  2. The user must belong to the row's space, or found it through claimSpace.
  3. validate.
  4. onWrite, whose writes go through the same steps.

Then the transaction commits, devices are told, and afterCommit runs for each change.

Gotchas

  • Throwing is not refusing. A hook that throws fails the request as a server error, and the device retries the same transaction until it goes through. Return a refusal for "no" (a thrown WriteRejectedError also refuses, with its code); throw anything else only for "try again later" (a service down). A bug that always throws blocks that device's changes.
  • Write through tx, never syncServer.write, inside validate and onWrite. write starts a transaction of its own, which waits for the one your hook is in. In afterCommit, write is fine.
  • Hooks see changes, not requests. A transaction of three operations runs validate three times. Use name to tell what the user did.
  • Keep validate fast. It runs inside the database transaction, which holds its locks until it ends.

On this page