Ferry
Server

The server

Ferry's server is a library you mount in your backend, not a service you deploy. It runs on one route of your existing app, on your database, behind your auth.

sync-server.tsServer
import { createSyncServer, nodeSqlite, sqliteStore } from 'ferrysync/server';
import { DatabaseSync } from 'node:sqlite';

import { schema } from './schema';

export const syncServer = createSyncServer({
  schema, // the same schema the app imports
  store: sqliteStore(nodeSqlite(new DatabaseSync('sync.db'))),
  auth: async (request) => (await getSession(request))?.userId ?? null,
});
Server
// Hono, Bun, Deno, Workers: any framework with web Request and Response
import { Hono } from 'hono';

import { syncServer } from './sync-server';

const app = new Hono();
app.all('/sync/*', (c) => syncServer.fetch(c.req.raw));

The app points its client at that route, http({ url: 'https://api.example.com/sync' }), and the rest is automatic. See the client's backends.

What it does

Each device keeps its data on the device and talks to the server in four requests, all POSTs to your route:

EndpointWhen the client sends itWhat the server does
pushAfter local writesValidates them, checks permissions, applies them
pullAfter a push, and when something changedAnswers the changes this user may see since last time
bootstrapThe first time, and after joining a spaceAnswers every row the user may see
waitAll the time, with realtime onHolds the request until there is something new
  1. App (createSync) → Your routePOST /sync/push
  2. Your route → syncServersyncServer.fetch(request)
  3. syncServer: auth(request), who is this?
  4. syncServer → Your databaseone transaction: schema, permissions, hooks, the log
  5. syncServer → App200: applied (or rejected, with a code)
  6. syncServer → Appwakes the waits of everyone who can see it
Every push is checked and written in one transaction, and only then are the waits that can see it woken.

On every push, in one database transaction, the server:

  1. validates each row with the table's schema;
  2. checks the user may write the row's space;
  3. runs your hooks: rules that refuse, and writes that follow;
  4. appends every change to a log, in one global order.

Pulls read that log from where each device left off, so devices never miss a change and never apply one twice. A rejected write comes back with a code, and the device rolls it back.

Options

OptionDefaultWhat it does
schemarequiredThe app's schema
storerequiredWhere sync data lives: see Stores
authrequiredThe user a request comes from: see Auth
tablesnoneRules and side effects per table: see Hooks
claimSpacenoneWho may found a new space: see Spaces
versions{ min: 1, max: schema.version }Schema versions accepted; older clients are told to update
retention{ days: 90 }How long the log keeps changes: see Retention
broadcastmemoryBroadcast()Realtime across instances: see Scaling
waitUntilnoneRun work after the response on serverless (ctx.waitUntil)
onErrornoneFailures no request reports: a 500's cause, a failed hook. Log them
maxBodyBytes5 MiBLarger requests get a 413; a write too large alone is dropped by the device: see Gotchas
maxHeldWaitsPerUser10Most wait requests held at once per user and instance: see Open requests
nowDate.nowFor tests
schedulerglobal timersFor tests: the timers of held waits
createIdcreateIdFor tests: ids of rows the server creates

The server object

MemberWhat it does
fetch(request)Answers every endpoint: mount it on one route
handle({ endpoint, body, request })The same, for a framework that routes and parses bodies itself (no size limit: its parser has one)
write({ as }, (tx) => …)Writes from your backend, so apps hear of it: see Writes
log(within, { table, id })Tells sync about a row your code wrote to a mirror table: see Adoption
backfill(table, options?)Brings the rows a mirror table already has into sync: see Adoption
spacesWho belongs to which space: see Spaces
afterRestore({ highestSyncId })Run once after restoring the database: see Backups
close()Ends held requests, refuses later ones and writes, and waits for background work
protocolThe protocol server underneath: see docs/internals/server.md in the repository

Mount it

Your stackPage
Hono, on any runtimeHono
Next.jsNext.js
ExpressExpress
Fastify, Nest, plain NodeNode.js: toNodeHandler(syncServer)
Cloudflare WorkersCloudflare Workers
BunBun

Answers

fetch never throws for a bad request: every failure is an answer, with a JSON body { error: { code, message } }.

StatusCodeWhen
200Answered; a refused write is a 200 too, with its rejection inside
400invalid_requestThe body is not JSON, or not a valid request
401unauthenticatedauth found no user
403forbiddenThe device's id belongs to another user
404unknown_endpointNot a POST to push, pull, bootstrap or wait
413too_largeThe body is over maxBodyBytes: the device drops a lone write
500internalSomething failed; the cause goes to onError, never to the client
503unavailableThe server was closed

toNodeHandler adds one: a request whose Host header is not a valid host gets a 400 invalid_request before it reaches the server.

The client treats every error as temporary and retries with backoff; a 401 also calls its onUnauthorized. One exception: a 413 (too_large, from the server or a proxy) makes it send fewer writes at once, and a single write that is still too large is rejected on the device with the code too_large (see When the server says no), so the writes behind it are not held up.

Gotchas

  • The route is the whole API. fetch routes by the last path segment, so mount it under any prefix (/sync/*, /api/v1/sync/*) and give the client that prefix as its url.
  • One server per process. Create it at module level and share it, like a database client: held requests and realtime live in it.
  • It does not do CORS. If the app runs on another origin, answer preflight requests in your framework, and allow the authorization and content-type headers.
  • Body limits must agree. A proxy in front of the server (nginx, a load balancer, a platform) has a body limit of its own, and the lowest one wins: nginx's client_max_body_size defaults to 1 MiB, a fifth of maxBodyBytes. A device whose single write is over the proxy's limit drops it as too_large, though the server would have taken it. Set the proxy's limit at or above maxBodyBytes (for example client_max_body_size 5m;), or lower maxBodyBytes to match it.
  • Reading is yours. Ferry keeps rows as JSON in tables of its own (ferry_*). To query synced data from the rest of your backend, mirror the tables you need into your own, and read those with your ORM.

Next steps

On this page