Adopting Ferry in an existing backend
An existing app has its own tables, ids, ORM and migrations. Ferry fits in beside them: it keeps its own tables in your database, mirrors synced tables into yours, and takes over one feature at a time.
import { defineSchema } from 'ferrysync';
import { createSyncServer, nodePostgres, postgresStore } from 'ferrysync/server';
import { Pool } from 'pg';
import { z } from 'zod';
export const Task = z.object({
id: z.string(),
projectId: z.string(),
title: z.string(),
done: z.boolean().default(false),
});
export const schema = defineSchema(
{ tasks: Task },
{ spaces: { tasks: (task) => `project:${task.projectId}` } },
);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const syncServer = createSyncServer({
schema,
store: postgresStore(nodePostgres(pool), {
// Every synced task is also a row of your own `tasks` table, in the same transaction.
mirror: { tasks: { table: 'tasks', fields: Task } },
}),
auth: async (request) => (await getSession(request))?.userId ?? null,
});The rest of your backend keeps reading tasks with its ORM, its reports and its admin screens.
What your tables need
String ids that devices can make. Devices create rows offline, so they choose the ids: UUIDv7
strings from createId(). A synced table's primary key must hold them:
- a
uuid(Postgres) ortextcolumn is ready as it is; - an integer
serialkey is not. Add auuidcolumn, make it the key for synced rows, and keep the integer as a plain column if other tables point at it.
A column for deletes. A delete through sync sets deleted_at (a timestamp column, nullable; a
boolean column is set to true) on the mirrored row instead of deleting it, so foreign keys and
history keep working; writing the row again clears it. Your queries then filter
deleted_at IS NULL. Name another column with deletedAt: 'archived_at', or pass
deletedAt: false to delete the row.
Columns for the fields. Each field of the schema goes to its column: projectId to
project_id, or to projectId when the table has that one (as Prisma's tables do). Other columns
(created_at, updated_at, an internal score) are left alone, as long as they have defaults.
The sync tables in your migrations
Ferry's own tables (ferry_*) can come from your migration tool, like every other table: generate
their statements with syncTablesSql, and open the store with migrate: false. See
Stores.
Mirrors
export const store = postgresStore(nodePostgres(new Pool()), {
mirror: {
tasks: { table: 'tasks', fields: Task },
projects: {
table: 'app.projects', // another Postgres schema
fields: ['name'],
columns: { name: 'title' }, // the field `name` in the column `title`
deletedAt: 'archived_at',
},
},
});| Option | Default | What it does |
|---|---|---|
table | required | Your table. Its only primary key is the row id's column |
fields | every column | The fields to write: a list of names, or the table's Zod schema |
columns | snake_case of the field, or its own name | A column per field, where the default is not it |
deletedAt | 'deleted_at' | The column a delete sets; false deletes the row |
toColumns | none | Your own mapping, from a row to its columns |
toRow | none | Your own mapping back, from columns to a row |
- Every write through sync updates the mirror in the same transaction: an app's push, a
syncServer.write, a hook's write. A refused transaction leaves no trace in either. - Pass
fields. With them, the store checks at startup that each field has its column, and leaves your other columns alone. Without them, every column of the table butdeletedAtis taken for a field. - Values go as they are: strings, numbers, booleans, null. Objects and arrays are written as
JSON (
jsonbon Postgres). On Postgres each value is cast to its column's type, so a string field can fill atimestamptzcolumn. store.ready()checks every mirror (the table exists, its key, its columns) and fails with a message that says what to change. Call it at startup.- Mirrors work on
sqliteStoreandpostgresStore, with every driver.
One feature at a time
Ferry does not need to own your whole database. Sync the data of one feature first:
- Put its tables in the schema, as they are in your database, with string ids.
- Mirror them into your existing tables, so nothing else in your backend changes.
- Bring the existing rows in with
backfill, so devices download them: see below. - Route the feature's writes through sync. Where your backend changes that data (an endpoint, a
job), call
syncServer.writeinstead,withinyour ORM's transaction when it changes other tables too.
Bringing existing rows in
Rows that exist only in your tables are unknown to sync, so devices do not get them. backfill
brings a mirrored table's rows in once, from a script or at deploy:
import { syncServer } from './sync-server';
const result = await syncServer.backfill('tasks');
console.log(`${result.created} tasks synced, ${result.existing} already were`);
for (const { id, code, message } of result.rejected) {
console.warn(`task ${id} was not synced: ${code}: ${message}`);
}- It reads the mirror table a page at a time and writes each page as
system, in one transaction. Devices download the rows like any other change. - Rows sync already has stay as they are, so running it again is safe.
- A row the schema, a hook or a permission refuses is listed in
rejected, and written nowhere. The others still go in.
Writes outside sync
A change made straight in a mirrored table (by an old endpoint, a migration, an admin tool) does not
reach devices, and the next change through sync overwrites it. Route those writes through
syncServer.write where you can. Where you cannot change the write, tell sync about it
with log, in the same transaction, right after it:
import { createSyncServer, postgresStore, prismaDriver } from 'ferrysync/server';
import { PrismaClient } from '@prisma/client';
import { schema } from './schema';
const prisma = new PrismaClient();
const syncServer = createSyncServer({
schema,
store: postgresStore(prismaDriver(prisma, { dialect: 'postgres' }), {
mirror: { tasks: { table: 'tasks' } },
}),
auth: async (request) => (await getSession(request))?.userId ?? null,
});
// An endpoint you keep as it is: it writes the table itself.
const logged = await prisma.$transaction(async (tx) => {
await tx.task.update({ where: { id: taskId }, data: { done: true } });
return syncServer.log(tx, { table: 'tasks', id: taskId });
});
await logged.committed();
logged.value; // 'created', 'updated', 'deleted' or 'unchanged'log reads the row back from your table and writes what changed into sync, as system, inside your
transaction. Devices hear about it once you call committed(), as with
write and within.
log and backfill
| Call | What it does |
|---|---|
syncServer.log(within, { table, id }) | Syncs one row your code just wrote to a mirrored table, in your transaction |
syncServer.backfill(table, options?) | Syncs every row of a mirrored table that sync does not have yet |
What log writes, from the row it reads back:
| The row in your table | Sync | value |
|---|---|---|
Gone, or its deletedAt column set | Deletes the record | 'deleted' |
| Not known to sync, or deleted in sync | Creates the record | 'created' |
| Known, with fields that changed | Updates those fields | 'updated' |
| Known, and the same | Nothing | 'unchanged' |
A value that changed only in how its column keeps it (an instant written in another format, a number at the column's scale) is not a change.
backfill option | Default | What it does |
|---|---|---|
pageSize | 500 | Rows per page; each page is one read and one transaction |
owner | none | For a private table: (row) => userId, whose space a row joins |
log takes owner too, for a private table: { table, id, owner }.
- Both need a mirror of the table in the store.
- A row that does not fit the schema makes
logthrow aWriteRejectedError, and nothing of the transaction's sync write happens;backfilllists it inrejectedinstead. logneeds a driver that can join your transaction, likewritewithwithin: not the SQLite drivers overnode:sqlite,bun:sqliteorbetter-sqlite3, nordurableObjectSqlite.d1Storekeeps no mirrors, so it has neitherlognorbackfill.
Gotchas
- Ids are strings, made on devices. Never let a database default (
gen_random_uuid(), a sequence) choose a synced row's id: the device already did. - Columns the schema does not know need defaults. The mirror writes only the schema's fields; a
NOT NULLcolumn without a default fails every insert. - Triggers on mirrored tables run inside sync's transactions. They must not write synced data themselves: devices would not hear of it.