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.
import { createSync, indexedDbStorage } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({
schema,
storage: indexedDbStorage('my-app'), // default: memoryStorage(), lost on reload
});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
readythey find nothing (getreturnsnull, queries[]), and update as soon as the data loads. - Writes before
readythrow, with a message that says so. Awaitsync.readyfirst, or render insideSyncProvider, which waits for you. sync.readyrejects when storage cannot be loaded. Awaiting it is optional: a failure is never an unhandled rejection.sync.isReadysays the same synchronously: true oncereadyhas resolved, false again while aresetruns.sync.subscribelisteners hear it change.
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:
| Call | Returns | What it does |
|---|---|---|
create(input, options?) | the row | Validates and adds a row; the id is optional |
update(id, patch, options?) | boolean | Changes some fields; false when the row does not exist |
delete(id, options?) | boolean | Removes the row; false when it does not exist |
get(id) | the row or null | Reads one row |
createtakes the schema's input (defaults may be left out) and returns its output, defaults applied.updatetakes some fields of the output. The merged row is validated.undefinedfields are left as they are,idis 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 }:
import { sync } from './sync';
sync.tasks.update('t1', { done: true }, { undoable: false }); // a background write, not the user'sTo 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.
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.
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:
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);
},
});| Field | What it is |
|---|---|
code | Machine-readable: 'forbidden', 'not_found', 'invalid', 'too_large', or your own |
message | For people, when the server gave one |
name | What was refused: tasks.update.v1 for a table write, or the name you gave a transaction |
changes | Each write of it, typed by table (below) |
transaction | The raw transaction, as sent |
Each change has table, kind and id; narrow on table and kind for the rest:
kind | Also 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.transactionstill 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:
- push: 1 big note, 2 small note
- 413 too large
- push: 1 big note, alone
- 413 too large
- onRejected({ code: 'too_large' }): the big note is rolled back
- push: 1 empty, holding its place in the order
- 200
- push: 2 small note
- 200
- It rejects the write:
onRejectedgets it withcode: 'too_large'and itschanges, the row goes back to what the server has, and it leaves the undo history. - 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:
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:
- Give the server a small limit,
createSyncServer({ …, maxBodyBytes: 2_048 }), and run it. - 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
| Option | Default | What it does |
|---|---|---|
schema | required | The schema |
storage | memoryStorage() | Where data lives on the device: see Storages |
backend | local() | Where data syncs: see Accounts |
onRejected | none | The server refused a write, which was rolled back |
onUpdateNeeded | none | The server needs a newer app: see Status |
onInvalidRow | none | A stored or received row failed its schema and was left out |
pushDelayMs | 0 | Wait after a write before sending, to batch bursts |
retry | 1 s to 60 s | Backoff after a failed sync: { initialMs, maxMs }, doubling |
undo | 100, stored | The undo history: { limit, persist }, see Undo |
createId | createId | Makes the ids of rows created without one. For tests |
now | Date.now | Epoch milliseconds. For tests |
scheduler | global timers | { setTimeout, clearTimeout }, for the client and its backend. For tests: see Testing |
The rest of the client
| Member | Page |
|---|---|
sync.<table>.where, orderBy, all… | Queries |
sync.transaction, undo, redo | Transactions and undo |
sync.status, onStatus, syncNow, refetch | Status and offline |
sync.whenSynced, sync.flush | Waiting |
sync.connect, sync.reset | Accounts |
sync.subscribe | Called after any change: rows, status or undo history |
sync.tables | The table names, in the schema's order |
sync.schema | The schema the client was made with |
sync.isReady | Whether sync.ready has resolved: see above |
sync.close() | Stops for good: reads keep the last data, writes throw |
sync.engine | The low-level engine, for bindings. reset replaces it |
Gotchas
- Writes are synchronous, sending is not.
createreturns once the row is visible on the device; whether the server has it is insync.status. - A row from
createand the row fromgetare 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.