Ferry
Server

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.

Server
// 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

CallResolves withWhat it does
tx.<table>.create(input, opts?)the row, defaults appliedAdds a row; the id is made unless given
tx.<table>.update(id, patch)true, or false if no rowChanges some fields; the merged row is validated
tx.<table>.delete(id)true, or false if no rowDeletes the row
tx.<table>.get(id)the row, or nullReads a row as the transaction sees it so far

They take what the client's tables take, and return the same values, in promises:

Server
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

asMay writeHooks see
'system'Any spaceactor: { type: 'system' }
{ userId }What that user may write from their appsactor: { 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:

Server
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.

CodeWhen
invalidThe table's schema refused the row, or a string in it holds U+0000
forbiddenThe user may not write that space
existscreate with an id that a row has
local_onlyThe table is local-only: it never syncs
yoursA 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 }:

Server
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 }),
);
  • system creating a private row without owner is refused (invalid).
  • system updating or deleting one needs nothing: the row knows its owner.
  • A user's own owner is allowed; another user's is forbidden.

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.

Server
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:

  • value is what the callback returned.
  • committed() tells devices about the change and runs afterCommit hooks. 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:

Driverwithin
nodePostgresA pool client after BEGIN
postgresJsThe sql of your sql.begin(async (sql) => …)
pgliteThe tx of your db.transaction(async (tx) => …)
prismaDriverThe tx of your prisma.$transaction(async (tx) => …)
drizzleDriverThe tx of your db.transaction(async (tx) => …)

With Prisma:

Server
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:

Server
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: as decides what may be written, not what may be read.
  • Not inside hooks. In validate and onWrite, write through the hook's own tx: a write there waits for the very transaction it is in.
  • The tx ends 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.

On this page