Ferry
Examples

Firebase

Keep Firebase for what it does well, signing users in, and keep the synced data in a database next to your own backend. Ferry runs in that backend: the app reads and writes its local database, and syncs through your route for the users Firebase Auth signed in.

PieceHere
Who the user isFirebase Auth: its ID token, verified
Where the server keeps dataYour Postgres or SQLite, through Ferry's stores
Where the server runsYour backend: any of the backends
Where the device keeps dataIndexedDB

It uses the schema of the Quick start.

The server

auth verifies the ID token the app sends, with the Admin SDK, and answers its user's uid:

sync-server.tsServer
import { initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
import { createSyncServer, nodeSqlite, sqliteStore } from 'ferrysync/server';
import { DatabaseSync } from 'node:sqlite';

import { schema } from './schema';

initializeApp();

/** The Firebase user the request's ID token belongs to, or null. */
const userOf = async (request: Request) => {
  const token = request.headers.get('authorization')?.replace(/^Bearer /, '');
  if (!token) {
    return null;
  }
  try {
    return (await getAuth().verifyIdToken(token)).uid;
  } catch {
    // Expired or forged: a 401. The next request calls `headers` again, with a fresh token.
    return null;
  }
};

export const syncServer = createSyncServer({
  schema,
  store: sqliteStore(nodeSqlite(new DatabaseSync('sync.db'))),
  auth: userOf,
  claimSpace: ({ table, space, row }) => table === 'projects' && space === `project:${row.id}`,
});

The client

The app starts on the device alone. Once Firebase has restored its session, the client picks your route or the device; at sign-in it connects and uploads what the user did before. See Accounts.

sync.tsClient
import { initializeApp } from 'firebase/app';
import { GoogleAuthProvider, getAuth, signInWithPopup, signOut as leave } from 'firebase/auth';
import { createSync, http, indexedDbStorage, local } from 'ferrysync';

import { schema } from './schema';

const auth = getAuth(initializeApp({ apiKey: '…', authDomain: '…', projectId: '…' }));

/** Your route, with a current ID token on every request: Firebase refreshes it as needed. */
const backend = http({
  url: '/api/sync',
  headers: async () => ({
    authorization: `Bearer ${(await auth.currentUser?.getIdToken()) ?? ''}`,
  }),
});

await auth.authStateReady();

export const sync = createSync({
  schema,
  storage: indexedDbStorage('tasks'),
  backend: auth.currentUser ? backend : local(),
});

export const signIn = async () => {
  await signInWithPopup(auth, new GoogleAuthProvider());
  await sync.connect(backend, { upload: true });
};

export const signOut = async () => {
  await leave(auth);
  await sync.reset(local());
};

Gotchas

  • Firestore is not the store here. Ferry's server keeps an ordered log next to the rows, in SQL. Keep Firestore for what you already have there, and the synced tables in Postgres or SQLite. A store over another database is possible: it is a ServerStore, proven by serverStoreConformanceCases from ferrysync/testing.
  • Wait for authStateReady() before choosing the backend. Until then currentUser is null even for a signed-in user, and the app would start on the device alone.
  • Connect from the sign-in, not from onAuthStateChanged. That fires on every restore, and connect with upload: true must run once per sign-in.

On this page