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.
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
| Field | What it is |
|---|---|
table | The table, 'tasks' |
kind | 'create', 'update' or 'delete' |
before | The row before, typed by the table's schema; null for a create |
after | The row after; null for a delete |
actor | Who writes: see below |
name | The transaction's name: tasks.update.v1 from an app, or the name it gave a transaction |
actor is one of:
actor | When |
|---|---|
{ 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 returns | Then |
|---|---|
nothing, null | The change goes on |
| a string | Refused, 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:
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.
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
onErrorand 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:
- The table's schema validates the row.
- The user must belong to the row's space, or found it through
claimSpace. validate.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
WriteRejectedErroralso 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, neversyncServer.write, insidevalidateandonWrite.writestarts a transaction of its own, which waits for the one your hook is in. InafterCommit,writeis fine. - Hooks see changes, not requests. A transaction of three operations runs
validatethree times. Usenameto tell what the user did. - Keep
validatefast. It runs inside the database transaction, which holds its locks until it ends.