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.
import { createSync, indexedDbStorage } from 'ferrysync';
import { schema } from './schema';
export const sync = createSync({ schema, storage: indexedDbStorage('my-app') });| Storage | Where | Notes |
|---|---|---|
memoryStorage() | tests, demos | The default. Lost on reload |
indexedDbStorage(name?) | web apps | The web default choice |
localStorageStorage(key?) | small web apps | About 5 MB per origin |
sqliteStorage(db) | Expo and React Native, with expo-sqlite | The React Native default choice |
opSqliteStorage(db) | React Native, with op-sqlite | The same tables as sqliteStorage |
mmkvStorage(mmkv) | React Native, with react-native-mmkv 3 or 4 | Fast; best for small to medium data |
keyValueStorage(kv) | anything with get, set, remove and keys | AsyncStorage, 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
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
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:
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';| Option | Default | What it does |
|---|---|---|
onLocked | 'fail' | 'fail' rejects load with StorageLockedError; 'wait' waits for the lock |
locks | browser | A 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. Passlocksto 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)
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)
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 use | Pick |
|---|---|
| expo-sqlite | sqliteStorage(db) |
| op-sqlite | opSqliteStorage(db) |
| react-native-mmkv, small data | mmkvStorage(mmkv) |
| AsyncStorage | keyValueStorage(...) |
MMKV
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
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.
| Option | Default | What it does |
|---|---|---|
prefix | 'ferrysync:' | Starts every key; clear removes every key under it, and only those |
onCorruptEntry | none | An 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.
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:
writeis atomic. Everything in aStorageWritelands, 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, thendeleteRecords, thenputRecords;deleteOutboxbeforeputOutbox;setMetamerges keys. - No shared objects. Nothing
loadreturns, and nothingwritewas 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:
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:
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.
resetclears 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.
Status and offline
Ferry is offline-first: the app reads and writes the device's data whether or not there is a network, and syncs when it can. sync.status says where syncing is, for the few places the UI cares.
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.