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.
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,
});// 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:
| Endpoint | When the client sends it | What the server does |
|---|---|---|
push | After local writes | Validates them, checks permissions, applies them |
pull | After a push, and when something changed | Answers the changes this user may see since last time |
bootstrap | The first time, and after joining a space | Answers every row the user may see |
wait | All the time, with realtime on | Holds the request until there is something new |
- App (createSync) → Your routePOST /sync/push
- Your route → syncServersyncServer.fetch(request)
- syncServer: auth(request), who is this?
- syncServer → Your databaseone transaction: schema, permissions, hooks, the log
- syncServer → App200: applied (or rejected, with a code)
- syncServer → Appwakes the waits of everyone who can see it
On every push, in one database transaction, the server:
- validates each row with the table's schema;
- checks the user may write the row's space;
- runs your hooks: rules that refuse, and writes that follow;
- 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
| Option | Default | What it does |
|---|---|---|
schema | required | The app's schema |
store | required | Where sync data lives: see Stores |
auth | required | The user a request comes from: see Auth |
tables | none | Rules and side effects per table: see Hooks |
claimSpace | none | Who 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 |
broadcast | memoryBroadcast() | Realtime across instances: see Scaling |
waitUntil | none | Run work after the response on serverless (ctx.waitUntil) |
onError | none | Failures no request reports: a 500's cause, a failed hook. Log them |
maxBodyBytes | 5 MiB | Larger requests get a 413; a write too large alone is dropped by the device: see Gotchas |
maxHeldWaitsPerUser | 10 | Most wait requests held at once per user and instance: see Open requests |
now | Date.now | For tests |
scheduler | global timers | For tests: the timers of held waits |
createId | createId | For tests: ids of rows the server creates |
The server object
| Member | What 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 |
spaces | Who 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 |
protocol | The protocol server underneath: see docs/internals/server.md in the repository |
Mount it
| Your stack | Page |
|---|---|
| Hono, on any runtime | Hono |
| Next.js | Next.js |
| Express | Express |
| Fastify, Nest, plain Node | Node.js: toNodeHandler(syncServer) |
| Cloudflare Workers | Cloudflare Workers |
| Bun | Bun |
Answers
fetch never throws for a bad request: every failure is an answer, with a JSON body
{ error: { code, message } }.
| Status | Code | When |
|---|---|---|
| 200 | Answered; a refused write is a 200 too, with its rejection inside | |
| 400 | invalid_request | The body is not JSON, or not a valid request |
| 401 | unauthenticated | auth found no user |
| 403 | forbidden | The device's id belongs to another user |
| 404 | unknown_endpoint | Not a POST to push, pull, bootstrap or wait |
| 413 | too_large | The body is over maxBodyBytes: the device drops a lone write |
| 500 | internal | Something failed; the cause goes to onError, never to the client |
| 503 | unavailable | The 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.
fetchroutes by the last path segment, so mount it under any prefix (/sync/*,/api/v1/sync/*) and give the client that prefix as itsurl. - 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
authorizationandcontent-typeheaders. - 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_sizedefaults to 1 MiB, a fifth ofmaxBodyBytes. A device whose single write is over the proxy's limit drops it astoo_large, though the server would have taken it. Set the proxy's limit at or abovemaxBodyBytes(for exampleclient_max_body_size 5m;), or lowermaxBodyBytesto 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.