Accounts: sign-in and sign-out
An app can start with no account and no server: the device holds the data. At sign-in, the same data moves to the server; at sign-out, the device starts over. Three calls cover it.
// Before sign-in: createSync({ schema }) runs on local(), and the device is the authority.
// Sign-in: keep what the user did without an account, and upload it.
await sync.connect(
http({
url: 'https://api.example.com/sync',
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
}),
{ upload: true },
);
// Sign-out: wipe the device, and go back to having no account.
await sync.reset(local());Backends
A backend says where the client syncs. Pass one to createSync, connect or reset.
local(): the device is the authority. Every write is confirmed on the device and nothing leaves it. For apps without accounts, and for the time before sign-in. It is the default.http(options): a Ferry server, overfetch. Your backend mounts it on one route.
At startup, choose from what the app knows:
import { createSync, http, local } from 'ferrysync';
import { schema } from './schema';
const url = 'https://api.example.com/sync';
export const sync = createSync({
schema,
backend: session
? http({ url, headers: async () => ({ authorization: `Bearer ${await getToken()}` }) })
: local(),
});http options
| Option | Default | What it does |
|---|---|---|
url | required | Where the server is mounted: …/sync for …/sync/push |
headers | none | An object, or a function called before every request, so tokens can refresh |
realtime | true | Hear other devices' changes as they happen: see Realtime |
pollIntervalMs | none | With realtime: false, pull this often too: see Realtime |
onUnauthorized | none | Called on a 401, before the request fails: refresh the session or sign out |
timeoutMs | 30000 | How long a request may take; Infinity turns it off |
fetch | global fetch | Your own fetch, for tests or a network layer; the platform's fetch fits as is |
Sign-in: connect
connect(backend, { upload }) moves the client to another backend. It runs once storage has loaded,
so it can be called right away.
const backend = http({
url: 'https://api.example.com/sync',
headers: { authorization: 'Bearer …' },
});
// Either keep this device's data, and upload it…
await sync.connect(backend, { upload: true });
// …or replace it with the account's data.
await sync.connect(backend, { upload: false });upload | The device's rows | Pending writes | Use it when |
|---|---|---|---|
true | Kept, and sent to the server as new rows | Kept, sent after them | The user signs in on a device they used anonymously |
false | Replaced by what the account has on the server | Dropped | The account already has its data elsewhere |
Either way, local-only rows stay, and the undo history is cleared.
With upload: true, each row goes up as its own transaction (named sync.upload.v1 on the server),
so a row the server refuses loses only that row, through
onRejected. Rows the device created are private to the
signing-in user unless your schema puts them in a space.
When you cannot tell (the user has an account, and also used the app without one), ask: "Keep the data on this device?".
Sign-out: reset
reset(backend?) wipes the device and starts over: every row, every pending write, local-only
tables, the undo history. Then it starts again on backend, the current one by default.
await sync.reset(local());Pending writes are lost with the rest, so check first:
const signOut = async () => {
await sync.whenSynced({ signal: AbortSignal.timeout(5_000) });
const { pending } = sync.status;
if (pending > 0 && !(await confirmDiscard(pending))) {
return; // keep the account until those writes reach the server
}
await sync.reset(local());
};To sign out but keep what belongs to the device (drafts, settings, anything in a
local-only table), pass keepLocalOnly:
await sync.reset(local(), { keepLocalOnly: true });The rows of local-only tables stay, in storage and in queries. Everything else goes: synced rows, pending writes (they never reach any server), the undo history and the sync cursor. The device starts over as a new client, so nothing kept is uploaded to the next account. Wipe and keep happen in one storage write: a crash leaves either the old device or the wiped one. Other keys you added to the storage's meta are wiped too.
While reset runs, reads find nothing and writes throw; sync.ready is the promise of the new
start, and reset resolves with it.
What survives a reset
The client object is the same: keep using it.
- Tables and queries keep working. A query you hold (or a component's
useQuery) empties, then shows the new data. SyncProvidershows its fallback until the new start is ready, so the screens of the previous account unmount.useSync()returns the same client after. See React.- The Zustand store, the Jotai atoms and
bindLegendStatefollow the client's tables, so they keep working too. sync.engineis replaced. Anything bound to the engine itself (bindEngine) must bind again.
Gotchas
connectandresetrun one at a time, in the order they were called. Aresetcalled during aconnectruns after it.- Use the server's identity, not the device's. The server knows the user from your auth headers; nothing the client sends picks the user.
onUnauthorizedfires on every 401. Refresh the token there, or sign out withreset(local()); the request fails and is retried with backoff either way.close()is not a sign-out. It stops the client for good and keeps the data on the device.
Storages
A storage is where the client keeps its data on the device: the rows, the writes waiting for the server, and its own bookkeeping. Pick one per platform and pass it to createSync.
Testing
The client needs no server and no device to test: give it memory storage and the local() backend, which are the defaults, and it runs anywhere your tests do.