Ferry
Client

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.

Client
import { createSync, indexedDbStorage } from 'ferrysync';

import { schema } from './schema';

export const sync = createSync({ schema, storage: indexedDbStorage('my-app') });
StorageWhereNotes
memoryStorage()tests, demosThe default. Lost on reload
indexedDbStorage(name?)web appsThe web default choice
localStorageStorage(key?)small web appsAbout 5 MB per origin
sqliteStorage(db)Expo and React Native, with expo-sqliteThe React Native default choice
opSqliteStorage(db)React Native, with op-sqliteThe same tables as sqliteStorage
mmkvStorage(mmkv)React Native, with react-native-mmkv 3 or 4Fast; best for small to medium data
keyValueStorage(kv)anything with get, set, remove and keysAsyncStorage, and any store of strings

Every storage writes atomically: a write the app dies in the middle of leaves nothing behind. And none trusts what it reads back: an entry that does not parse is skipped, reported to onCorruptEntry if you pass one, and the rest loads.

Web

IndexedDB

Client
import { indexedDbStorage } from 'ferrysync';

const storage = indexedDbStorage('my-app', {
  onCorruptEntry: (entry) => console.warn('Skipped a stored entry', entry),
});

One IndexedDB database named after the first argument (default 'ferrysync'). Each write is one transaction with strict durability. The database opens on first use, so creating the storage never throws, even where IndexedDB is missing (the first call fails instead).

Only one tab at a time can use it: see Multiple tabs.

localStorage

Client
import { localStorageStorage } from 'ferrysync';

const storage = localStorageStorage('my-app'); // keys start with "my-app:"
const perTab = localStorageStorage('my-app', { storage: sessionStorage });

One key per row, each starting with <key>:. Browsers allow about 5 MB per origin, and a write needs room for a journal of what it replaces on top of its own size: a write that does not fit rejects and leaves nothing behind. Prefer IndexedDB beyond small data sets.

With a custom storage (sessionStorage is private to its tab), no lock is taken, unless you pass locks too.

Multiple tabs

Two tabs running a client over the same data would both number the outbox and move the cursor, and corrupt each other. So indexedDbStorage and localStorageStorage take an exclusive Web Lock on their first load, named after the database or key prefix, and hold it until the page unloads. The second tab either fails or waits:

Client
import { indexedDbStorage, StorageLockedError } from 'ferrysync';

// Default: load rejects with StorageLockedError, and so does `sync.ready`.
const strict = indexedDbStorage('my-app');

// Wait for the first tab to close, then go on. Nothing is lost: it persisted everything.
const patient = indexedDbStorage('my-app', { onLocked: 'wait' });

const isLocked = (error: unknown) => error instanceof StorageLockedError && error.code === 'locked';
OptionDefaultWhat it does
onLocked'fail''fail' rejects load with StorageLockedError; 'wait' waits for the lock
locksbrowserA lock manager (request(name, options, callback)) instead of navigator.locks
  • Without Web Locks (old browsers, React Native) nothing is locked, and the one-tab rule is on you. Node, Bun and Deno are left out on purpose, even though they define navigator.locks: a process is not a page. Pass locks to lock there.
  • reset() keeps the lock: the tab still owns the storage after it clears it.
  • One database per tab is not supported yet. Sharing one database between tabs (a leader tab syncs, the others follow it) is planned but not built, so today the ownership is exclusive.

React Native

SQLite (expo-sqlite)

Client
import { sqliteStorage } from 'ferrysync';
import { openDatabaseSync } from 'expo-sqlite';

const storage = sqliteStorage(openDatabaseSync('app.db'));

Three tables (sync_records, sync_outbox, sync_meta, plus a version table), created on first use. { prefix } changes the sync_ prefix, so the app can keep its own tables in the same database.

  • The database must be a file: the storage runs each write on a connection of its own, and a second connection to :memory: is another, empty database.

  • expo-sqlite has no exclusive transactions on the web. There, pass a driver that uses a plain transaction, and keep that database to the storage:

    import \{ sqliteStorage \} from 'ferrysync';
    import \{ openDatabaseSync \} from 'expo-sqlite';
    
    const db = openDatabaseSync('sync.db');
    const storage = sqliteStorage(\{
      withExclusiveTransactionAsync: (task) => db.withTransactionAsync(() => task(db)),
    \});

Any driver with the same three methods as expo-sqlite (execAsync, runAsync, getAllAsync) and withExclusiveTransactionAsync works.

SQLite (op-sqlite)

Client
import { opSqliteStorage } from 'ferrysync';
import { open } from '@op-engineering/op-sqlite';

const storage = opSqliteStorage(open({ name: 'app.db' }));

For apps that use op-sqlite instead of expo-sqlite. The same three tables, prefix option and guarantees as sqliteStorage; the package is not a dependency of Ferry, the storage only asks for op-sqlite's execute and transaction.

  • Each write is one db.transaction, which op-sqlite commits when it resolves and rolls back when it throws. op-sqlite queues transactions, so storage calls never overlap.
  • A transaction runs on the database's one connection, so it is not isolated from the app's other queries on that database: keep the app's writes out of the storage's tables, or give the storage a database of its own.
  • Both result shapes work: rows as an array (v9 and later) and wrapped in _array (older).

Which one for Expo and React Native

You usePick
expo-sqlitesqliteStorage(db)
op-sqliteopSqliteStorage(db)
react-native-mmkv, small datammkvStorage(mmkv)
AsyncStoragekeyValueStorage(...)

MMKV

Client
import { mmkvStorage } from 'ferrysync';
import { createMMKV } from 'react-native-mmkv';

const storage = mmkvStorage(createMMKV({ id: 'sync' }));

Takes an MMKV instance of react-native-mmkv 3 (new MMKV()) or 4 (createMMKV()); the package is not a dependency of Ferry. One key per row under a prefix (default 'ferrysync:'), and each write of several keys is journaled. Give it an instance of its own, or a prefix no other key starts with: reset removes every key under the prefix.

AsyncStorage, or any key-value store

Client
import AsyncStorage from '@react-native-async-storage/async-storage';
import { keyValueStorage } from 'ferrysync';

const storage = keyValueStorage({
  get: (key) => AsyncStorage.getItem(key),
  set: (key, value) => AsyncStorage.setItem(key, value),
  remove: (key) => AsyncStorage.removeItem(key),
  keys: () => AsyncStorage.getAllKeys(),
});

keyValueStorage turns any store of strings into a storage: localStorageStorage and mmkvStorage are built on it. Each method may return its result or a promise. It asks two things of the store: a set or remove that returned survives a restart, and each changes its key whole or not at all.

OptionDefaultWhat it does
prefix'ferrysync:'Starts every key; clear removes every key under it, and only those
onCorruptEntrynoneAn entry could not be read back and was left out

Writing your own

A storage is three methods. Write one when your data must live in something the list above does not cover, typically a database with transactions. For a key-value store, use keyValueStorage instead.

Client
import type { StorageSnapshot, StorageWrite } from 'ferrysync';

interface StorageAdapter {
  load(): Promise<StorageSnapshot>; // everything stored, in one consistent read
  write(change: StorageWrite): Promise<void>; // one atomic change
  clear(): Promise<void>; // remove everything (sign-out)
}

The contract:

  • write is atomic. Everything in a StorageWrite lands, or nothing does, and it resolves once the change would survive a restart. A rejection must mean nothing was applied.
  • Order inside a write: clearRecords, then deleteRecords, then putRecords; deleteOutbox before putOutbox; setMeta merges keys.
  • No shared objects. Nothing load returns, and nothing write was given, may be the same object as what is stored: copy on the way in and out.
  • The client never runs two calls at once.

A storage that keeps everything as one JSON document, for any place that can replace one string atomically:

Client
import type { StorageAdapter, StorageSnapshot, StorageWrite } from 'ferrysync';

/** Somewhere that replaces one string atomically: a file written then renamed, a row in a table. */
export interface Document {
  read(): Promise<string | null>;
  replace(text: string): Promise<void>;
  remove(): Promise<void>;
}

const applyWrite = (stored: StorageSnapshot, change: StorageWrite): StorageSnapshot => {
  const key = (model: string, id: string) => `${model}/${id}`;
  const records = new Map(
    (change.clearRecords ? [] : stored.records).map((record) => [
      key(record.model, record.id),
      record,
    ]),
  );
  for (const { model, id } of change.deleteRecords ?? []) {
    records.delete(key(model, id));
  }
  for (const record of change.putRecords ?? []) {
    records.set(key(record.model, record.id), record);
  }
  const outbox = new Map(stored.outbox.map((transaction) => [transaction.seq, transaction]));
  for (const seq of change.deleteOutbox ?? []) {
    outbox.delete(seq);
  }
  for (const transaction of change.putOutbox ?? []) {
    outbox.set(transaction.seq, transaction);
  }
  return {
    records: [...records.values()],
    outbox: [...outbox.values()].sort((a, b) => a.seq - b.seq),
    meta: { ...stored.meta, ...change.setMeta },
  };
};

export const documentStorage = (document: Document): StorageAdapter => {
  const load = async (): Promise<StorageSnapshot> => {
    const text = await document.read();
    // Parsing makes fresh objects on every load. Check the shape in production code.
    return text === null ? { records: [], outbox: [], meta: {} } : JSON.parse(text);
  };
  return {
    load,
    write: async (change) => {
      await document.replace(JSON.stringify(applyWrite(await load(), change)));
    },
    clear: () => document.remove(),
  };
};

Prove it with the conformance kit

storageConformanceCases checks a storage against the contract, through its three methods. Every built-in storage passes it. It needs no particular test framework; with Vitest:

Client
import { canRunStorageCase, storageConformanceCases } from 'ferrysync/testing';
import { describe, it } from 'vitest';

describe('documentStorage', () => {
  for (const testCase of storageConformanceCases) {
    let text: string | null = null;
    const document = {
      read: async () => text,
      replace: async (next: string) => {
        text = next;
      },
      remove: async () => {
        text = null;
      },
    };
    const target = {
      create: async () => documentStorage(document),
      // A new instance over the same data, as after a restart.
      reopen: async () => documentStorage(document),
    };
    it.skipIf(!canRunStorageCase(target, testCase))(testCase.name, () => testCase.run(target));
  }
});

A target may also offer failNextWrite(), to make its next write fail as a crash would; the cases that need it are skipped without it.

Gotchas

  • One client per storage. Two clients over the same data, at the same time, corrupt each other's bookkeeping. On the web, the tab lock enforces it.
  • reset clears the storage, everything under its prefix or in its database. Keep app data out of the storage's database tables and key prefix.
  • A storage that fails to load rejects sync.ready. sync.reset() recovers by clearing it.
  • Moving between storages is not automatic: a new storage starts empty and loads the data from the server.

On this page