Ferry
Client

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.

Client
// 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, over fetch. Your backend mounts it on one route.

At startup, choose from what the app knows:

Client
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

OptionDefaultWhat it does
urlrequiredWhere the server is mounted: …/sync for …/sync/push
headersnoneAn object, or a function called before every request, so tokens can refresh
realtimetrueHear other devices' changes as they happen: see Realtime
pollIntervalMsnoneWith realtime: false, pull this often too: see Realtime
onUnauthorizednoneCalled on a 401, before the request fails: refresh the session or sign out
timeoutMs30000How long a request may take; Infinity turns it off
fetchglobal fetchYour 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.

Client
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 });
uploadThe device's rowsPending writesUse it when
trueKept, and sent to the server as new rowsKept, sent after themThe user signs in on a device they used anonymously
falseReplaced by what the account has on the serverDroppedThe 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.

Client
await sync.reset(local());

Pending writes are lost with the rest, so check first:

Client
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:

Client
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.
  • SyncProvider shows 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 bindLegendState follow the client's tables, so they keep working too.
  • sync.engine is replaced. Anything bound to the engine itself (bindEngine) must bind again.

Gotchas

  • connect and reset run one at a time, in the order they were called. A reset called during a connect runs 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.
  • onUnauthorized fires on every 401. Refresh the token there, or sign out with reset(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.

On this page