Examples
Supabase
Supabase gives you a Postgres database and Auth. Ferry adds offline-first sync on top of them, from a backend you own: the app reads and writes its local database, and your backend syncs it into Supabase's Postgres, for the users Supabase Auth signed in.
| Piece | Here |
|---|---|
| Where the server keeps data | Supabase's Postgres, through postgresStore |
| Who the user is | Supabase Auth: its access token, verified |
| Where the server runs | Your backend: a Next.js route here |
| Where the device keeps data | IndexedDB |
It uses the schema of the Quick start.
The server
auth reads the access token the app sends, and asks Supabase whose it is. The store connects to
the same Postgres with its connection string; Ferry creates its own tables there, next to yours.
import { createClient } from '@supabase/supabase-js';
import { createSyncServer, nodePostgres, postgresStore } from 'ferrysync/server';
import { Pool } from 'pg';
import { schema } from './schema';
const supabase = createClient(process.env.SUPABASE_URL ?? '', process.env.SUPABASE_ANON_KEY ?? '');
/** The Supabase user the request's access token belongs to, or null. */
const userOf = async (request: Request) => {
const token = request.headers.get('authorization')?.replace(/^Bearer /, '');
if (!token) {
return null;
}
const { data } = await supabase.auth.getUser(token);
return data.user?.id ?? null;
};
export const syncServer = createSyncServer({
schema,
store: postgresStore(nodePostgres(new Pool({ connectionString: process.env.DATABASE_URL }))),
auth: userOf,
claimSpace: ({ table, space, row }) => table === 'projects' && space === `project:${row.id}`,
});Mount it as in Next.js, or on any other backend.
The client
The app starts on the device alone, signed in or not. At sign-in it connects to your route and uploads what the user did before; at sign-out it starts over. See Accounts.
import { createClient } from '@supabase/supabase-js';
import { createSync, http, indexedDbStorage, local } from 'ferrysync';
import { schema } from './schema';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL ?? '',
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '',
);
/** Your route, with the current access token on every request, so a refreshed one is used. */
const backend = http({
url: '/api/sync',
headers: async () => {
const { data } = await supabase.auth.getSession();
return { authorization: `Bearer ${data.session?.access_token ?? ''}` };
},
});
const { data } = await supabase.auth.getSession();
export const sync = createSync({
schema,
storage: indexedDbStorage('tasks'),
backend: data.session ? backend : local(),
});
export const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
throw error;
}
await sync.connect(backend, { upload: true });
};
export const signOut = async () => {
await supabase.auth.signOut();
await sync.reset(local());
};Gotchas
- The app does not read the synced tables through Supabase's client. It reads its local
database; your backend is the only one writing them. Row Level Security does not apply to that
connection, so
auth, the schema's spaces and the server'svalidatehook are where access is decided. - Connect from the sign-in, not from
onAuthStateChange. That event also fires when a session is restored or refreshed, andconnectwithupload: truemust run once per sign-in.