Writes from your backend
syncServer.write changes synced data from your own code (an API endpoint, a job, a webhook) so
that every device hears about it, as it hears about other devices' changes.
// As the server itself: it may write any space.
await syncServer.write({ as: 'system' }, (tx) => tx.tasks.update(taskId, { done: true }));
// As a user: with that user's permissions, as if from their app.
const project = await syncServer.write({ as: { userId } }, (tx) =>
tx.projects.create({ name: 'Japan' }),
);The callback gets tx, with every table of the schema, and runs in one database transaction: all of
it is written, or nothing. What it returns is what write resolves with.
The tx
| Call | Resolves with | What it does |
|---|---|---|
tx.<table>.create(input, opts?) | the row, defaults applied | Adds a row; the id is made unless given |
tx.<table>.update(id, patch) | true, or false if no row | Changes some fields; the merged row is validated |
tx.<table>.delete(id) | true, or false if no row | Deletes the row |
tx.<table>.get(id) | the row, or null | Reads a row as the transaction sees it so far |
They take what the client's tables take, and return the same values, in promises:
await syncServer.write({ as: 'system' }, async (tx) => {
const project = await tx.projects.create({ name: 'Imported' });
for (const title of ['Flights', 'Hotel', 'Visa']) {
await tx.tasks.create({ projectId: project.id, title });
}
return tx.tasks.get(taskId); // reads see the transaction's own writes
});Writes apply in the order they are called. write waits for every one of them, awaited or not.
Who writes: as
as | May write | Hooks see |
|---|---|---|
'system' | Any space | actor: { type: 'system' } |
{ userId } | What that user may write from their apps | actor: { type: 'user', userId, clientId: null } |
Both go through the table's schema and your hooks, and neither checks auth: your
endpoint decides who may call it. { userId } can also found a space through
claimSpace, as an app can.
Refusals: WriteRejectedError
A write the schema, the permissions or a validate hook refuses throws a WriteRejectedError, and
nothing of it is written:
import { WriteRejectedError } from 'ferrysync/server';
try {
await syncServer.write({ as: { userId } }, (tx) => tx.projects.update(projectId, { name: 'x' }));
} catch (error) {
if (error instanceof WriteRejectedError) {
error.code; // 'forbidden', 'invalid', 'exists', or your validate hook's
error.message;
}
}A refused operation refuses the whole write, even if the callback catches its error and goes on.
| Code | When |
|---|---|
invalid | The table's schema refused the row, or a string in it holds U+0000 |
forbidden | The user may not write that space |
exists | create with an id that a row has |
local_only | The table is local-only: it never syncs |
| yours | A validate hook refused |
update and delete of a row that is not there (or was deleted) refuse nothing: they resolve with
false, as on the client.
Private rows
A table without a space function is private: each row belongs to one user. Writing as a user, the
row is theirs. Writing as system, say whose with { owner }:
import { defineSchema } from 'ferrysync';
import { createSyncServer, memoryStore } from 'ferrysync/server';
import { z } from 'zod';
// No space function: every note is private to one user.
const schema = defineSchema({ notes: z.object({ id: z.string(), text: z.string() }) });
const syncServer = createSyncServer({ schema, store: memoryStore(), auth: () => null });
// A welcome note in a new user's private space.
await syncServer.write({ as: 'system' }, (tx) =>
tx.notes.create({ text: 'Welcome to the app!' }, { owner: userId }),
);systemcreating a private row withoutowneris refused (invalid).systemupdating or deleting one needs nothing: the row knows its owner.- A user's own
owneris allowed; another user's isforbidden.
Within your own transaction
Pass within (your database transaction) to make the sync write part of it: your own tables and the
synced rows commit together, or roll back together.
import { createSyncServer, nodePostgres, postgresStore } from 'ferrysync/server';
import { Pool } from 'pg';
import { schema } from './schema';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const syncServer = createSyncServer({
schema,
store: postgresStore(nodePostgres(pool)),
auth: async (request) => (await getSession(request))?.userId ?? null,
});
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE invoices SET paid = true WHERE id = $1', ['inv_1']);
const written = await syncServer.write(
{ as: 'system' },
(tx) => tx.tasks.update(taskId, { done: true }),
{ within: client },
);
await client.query('COMMIT');
// Committed: now tell the devices, and run afterCommit hooks.
await written.committed();
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}With within, write resolves with { value, committed } instead of the value:
valueis what the callback returned.committed()tells devices about the change and runsafterCommithooks. Call it after your transaction commits. Never call it after a rollback: there is nothing to tell.
Devices are told only then because a device told before your commit would look for the change, not find it yet, and only find it at its next regular look, 15 seconds later.
What within takes depends on the store's driver:
| Driver | within |
|---|---|
nodePostgres | A pool client after BEGIN |
postgresJs | The sql of your sql.begin(async (sql) => …) |
pglite | The tx of your db.transaction(async (tx) => …) |
prismaDriver | The tx of your prisma.$transaction(async (tx) => …) |
drizzleDriver | The tx of your db.transaction(async (tx) => …) |
With Prisma:
import { createSyncServer, postgresStore, prismaDriver } from 'ferrysync/server';
import { PrismaClient } from '@prisma/client';
import { schema } from './schema';
const prisma = new PrismaClient();
const syncServer = createSyncServer({
schema,
store: postgresStore(prismaDriver(prisma, { dialect: 'postgres' })),
auth: async (request) => (await getSession(request))?.userId ?? null,
});
const written = await prisma.$transaction(async (tx) => {
await tx.invoice.create({ data: { id: 'inv_1', total: 120 } });
return syncServer.write(
{ as: 'system' },
(sync) => sync.tasks.create({ projectId, title: 'Send invoice inv_1' }),
{ within: tx },
);
});
await written.committed();The SQLite drivers over node:sqlite, bun:sqlite and better-sqlite3, durableObjectSqlite and
d1Store cannot join a transaction of yours: passing within with their stores is a type error.
Naming a write
Hooks see a transaction's name: server.write by default. Name the kind of change when your hooks
care:
await syncServer.write({ as: 'system' }, (tx) => tx.tasks.update(taskId, { done: true }), {
name: 'tasks.complete-from-billing.v1',
});Gotchas
- Every change to synced data goes through
write(or an app's push). A change made straight in the database is not in the sync log: devices never hear of it. - Reads see every space, whoever writes:
asdecides what may be written, not what may be read. - Not inside hooks. In
validateandonWrite, write through the hook's owntx: awritethere waits for the very transaction it is in. - The
txends with the callback. Using it afterwards rejects. - Rows the server creates get ids from
createId()(UUIDv7), like the apps' rows, unless the input has one.