Ferry
Client

The client

createSync makes the client: one object with a property per table, that reads and writes a local database and syncs it in the background.

sync.tsClient
import { createSync, indexedDbStorage } from 'ferrysync';

import { schema } from './schema';

export const sync = createSync({
  schema,
  storage: indexedDbStorage('my-app'), // default: memoryStorage(), lost on reload
});
Client
import { sync } from './sync';

await sync.ready;

const task = sync.tasks.create({ projectId: 'p1', title: 'Book flights' });
sync.tasks.update(task.id, { done: true });
sync.tasks.get(task.id); // { id: '0192…', projectId: 'p1', title: 'Book flights', done: true, notes: null }
sync.tasks.delete(task.id);

Writes apply at once, on the device, with no loading state. They are stored, queued, and sent to the server when there is one and the network allows.

Starting: sync.ready

createSync starts right away: it loads storage, then syncs in the background. sync.ready resolves as soon as local data is readable, before any network.

  • Reads work at any time. Before ready they find nothing (get returns null, queries []), and update as soon as the data loads.
  • Writes before ready throw, with a message that says so. Await sync.ready first, or render inside SyncProvider, which waits for you.
  • sync.ready rejects when storage cannot be loaded. Awaiting it is optional: a failure is never an unhandled rejection.
  • sync.isReady says the same synchronously: true once ready has resolved, false again while a reset runs. sync.subscribe listeners hear it change.
Client
import { sync } from './sync';

if (sync.isReady) {
  sync.tasks.create({ projectId: 'p1', title: 'Book flights' });
}

Make one client per app, at module level, and share it.

Writing

Every table has the same four calls:

CallReturnsWhat it does
create(input, options?)the rowValidates and adds a row; the id is optional
update(id, patch, options?)booleanChanges some fields; false when the row does not exist
delete(id, options?)booleanRemoves the row; false when it does not exist
get(id)the row or nullReads one row
  • create takes the schema's input (defaults may be left out) and returns its output, defaults applied.
  • update takes some fields of the output. The merged row is validated. undefined fields are left as they are, id is ignored, and an update that changes nothing sends nothing.
  • Every subscriber (queries, hooks, stores) sees a write before the call returns.
  • Every write is one step of undo, unless you pass { undoable: false }:
Client
import { sync } from './sync';

sync.tasks.update('t1', { done: true }, { undoable: false }); // a background write, not the user's

To write several rows at once, all or nothing, use a transaction. To read many rows, use a query.

Ids

create makes an id when the input has none: a UUIDv7, which sorts as text in creation order. That is why rows come back in creation order by default.

Client
import { createId } from 'ferrysync';

import { sync } from './sync';

const projectId = createId(); // know the id before the row exists
sync.projects.create({ id: projectId, name: 'Japan' });

Ids are made on the device, so a row has its final id at once, offline included. Without Web Crypto (React Native's Hermes without a polyfill), the random part comes from Math.random: enough for ids, which must be unique, not secret. Creating a row with an id that exists throws.

Validation errors

A write the schema refuses throws a ValidationError and changes nothing.

Client
import { ValidationError } from 'ferrysync';

import { sync } from './sync';

try {
  sync.projects.create({ name: '' });
} catch (error) {
  if (error instanceof ValidationError) {
    error.table; // 'projects'
    for (const issue of error.issues) {
      console.log(issue.path, issue.message); // show it next to the form field it names
    }
  }
}

issues are the schema's own (Standard Schema issues, with paths), plus any value JSON cannot carry (a Date, NaN).

When the server says no

Writes are optimistic: the device shows them before the server has seen them. When the server refuses one (permissions, a server rule), the client rolls it back, so the row goes back to what the server has, and calls onRejected with what was refused, typed by table:

Client
import { createSync } from 'ferrysync';

import { schema } from './schema';

export const sync = createSync({
  schema,
  onRejected: ({ code, message, name, changes }) => {
    for (const change of changes) {
      if (change.table === 'tasks' && change.kind === 'create') {
        showToast(`"${change.row.title}" could not be saved: ${message ?? code}`);
      }
    }
    console.warn('Rejected', name, code);
  },
});
FieldWhat it is
codeMachine-readable: 'forbidden', 'not_found', 'invalid', 'too_large', or your own
messageFor people, when the server gave one
nameWhat was refused: tasks.update.v1 for a table write, or the name you gave a transaction
changesEach write of it, typed by table (below)
transactionThe raw transaction, as sent

Each change has table, kind and id; narrow on table and kind for the rest:

kindAlso has
'create'row: the row it created
'update'patch: the fields it set; previous: what they were before, if they were set
'delete'previous: the row it deleted
  • A rejected write leaves the undo history.
  • A change the schema cannot type now is left out of changes: an update of a row that is gone from the device, or a table this version of the schema no longer has. transaction still has every operation.
  • code: 'forgotten' comes from the device itself: a write sent again after the server's retention forgot its answer, whose effects the server's rows do not show (a delete never does). The server may have refused it, or applied it before someone changed those rows; either way the device now shows what the server has. A device the server forgot gets this code only for a write it knows the server had handled; the rest it checks and sends again, with nothing to report.
  • code: 'too_large' comes from the device too: the write was too large to send. See below.

Writing to a space the user is not a member of is allowed on the device and refused by the server.

A write too large to send

Every server limits how large a request may be: Ferry's server takes 5 MiB by default (maxBodyBytes), and a proxy in front of it (nginx, a load balancer, your platform) may take less. The answer to a request over the limit is a 413 ("payload too large").

The device sends its waiting writes in groups. When one is refused as too large, it sends half as many at once, and so on down to one. A single write that is still too large can never be sent, and the server takes a device's writes in order, so waiting would hold every later write back forever. So the device gives that write up:

DeviceServer, or a proxy in front
  1. push: 1 big note, 2 small note
  2. 413 too large
  3. push: 1 big note, alone
  4. 413 too large
  5. onRejected({ code: 'too_large' }): the big note is rolled back
  6. push: 1 empty, holding its place in the order
  7. 200
  8. push: 2 small note
  9. 200
Nothing is retried forever: the big write is given up once, and every write after it syncs.
  1. It rejects the write: onRejected gets it with code: 'too_large' and its changes, the row goes back to what the server has, and it leaves the undo history.
  2. It sends an empty write in its place, so the server's count of this device's writes has no gap, and the writes behind it go through as usual.

To tell the user, check the code in onRejected:

Client
import { createSync } from 'ferrysync';

import { schema } from './schema';

export const sync = createSync({
  schema,
  onRejected: ({ code }) => {
    if (code === 'too_large') {
      showToast('That change is too large to save. Try splitting it up.');
    }
  },
});

To see it happen on your machine:

  1. Give the server a small limit, createSyncServer({ …, maxBodyBytes: 2_048 }), and run it.
  2. In the app, create a row with a field longer than 2,048 characters.

You should see

the row appear, then disappear, and onRejected called with code: 'too_large'. Rows you create after it sync as usual.

Gotcha

The lowest limit on the way wins. nginx refuses bodies over 1 MiB by default (client_max_body_size), a fifth of the server's 5 MiB, and the device drops a write over the proxy's limit even though your server would take it. Set the proxy's limit at or above maxBodyBytes (for example client_max_body_size 5m;), or lower maxBodyBytes to match it.

Note

If the server had already saved the write (its answer was lost on the way back, and the limit was lowered since), the device still calls onRejected, but then receives the write from the server like any other change: it ends up showing what the server has.

Keep single writes small: a large file belongs in file storage, with only its URL in a row.

Options

OptionDefaultWhat it does
schemarequiredThe schema
storagememoryStorage()Where data lives on the device: see Storages
backendlocal()Where data syncs: see Accounts
onRejectednoneThe server refused a write, which was rolled back
onUpdateNeedednoneThe server needs a newer app: see Status
onInvalidRownoneA stored or received row failed its schema and was left out
pushDelayMs0Wait after a write before sending, to batch bursts
retry1 s to 60 sBackoff after a failed sync: { initialMs, maxMs }, doubling
undo100, storedThe undo history: { limit, persist }, see Undo
createIdcreateIdMakes the ids of rows created without one. For tests
nowDate.nowEpoch milliseconds. For tests
schedulerglobal timers{ setTimeout, clearTimeout }, for the client and its backend. For tests: see Testing

The rest of the client

MemberPage
sync.<table>.where, orderBy, all…Queries
sync.transaction, undo, redoTransactions and undo
sync.status, onStatus, syncNow, refetchStatus and offline
sync.whenSynced, sync.flushWaiting
sync.connect, sync.resetAccounts
sync.subscribeCalled after any change: rows, status or undo history
sync.tablesThe table names, in the schema's order
sync.schemaThe schema the client was made with
sync.isReadyWhether sync.ready has resolved: see above
sync.close()Stops for good: reads keep the last data, writes throw
sync.engineThe low-level engine, for bindings. reset replaces it

Gotchas

  • Writes are synchronous, sending is not. create returns once the row is visible on the device; whether the server has it is in sync.status.
  • A row from create and the row from get are equal, not the same object. Compare rows by id.
  • Never mutate a row you read. The client shares row objects between reads, so a change would show everywhere without being stored or synced. Write through update.
  • One client per storage. Two clients over the same storage (two tabs on one IndexedDB database) are not coordinated.

Next steps

On this page