Ferry
Server

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.

Server
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) or text column is ready as it is;
  • an integer serial key is not. Add a uuid column, 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

Server
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',
    },
  },
});
OptionDefaultWhat it does
tablerequiredYour table. Its only primary key is the row id's column
fieldsevery columnThe fields to write: a list of names, or the table's Zod schema
columnssnake_case of the field, or its own nameA column per field, where the default is not it
deletedAt'deleted_at'The column a delete sets; false deletes the row
toColumnsnoneYour own mapping, from a row to its columns
toRownoneYour 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 but deletedAt is taken for a field.
  • Values go as they are: strings, numbers, booleans, null. Objects and arrays are written as JSON (jsonb on Postgres). On Postgres each value is cast to its column's type, so a string field can fill a timestamptz column.
  • 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 sqliteStore and postgresStore, with every driver.

One feature at a time

Ferry does not need to own your whole database. Sync the data of one feature first:

  1. Put its tables in the schema, as they are in your database, with string ids.
  2. Mirror them into your existing tables, so nothing else in your backend changes.
  3. Bring the existing rows in with backfill, so devices download them: see below.
  4. Route the feature's writes through sync. Where your backend changes that data (an endpoint, a job), call syncServer.write instead, within your 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:

Server
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:

Server
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

CallWhat 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 tableSyncvalue
Gone, or its deletedAt column setDeletes the record'deleted'
Not known to sync, or deleted in syncCreates the record'created'
Known, with fields that changedUpdates those fields'updated'
Known, and the sameNothing'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 optionDefaultWhat it does
pageSize500Rows per page; each page is one read and one transaction
ownernoneFor 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 log throw a WriteRejectedError, and nothing of the transaction's sync write happens; backfill lists it in rejected instead.
  • log needs a driver that can join your transaction, like write with within: not the SQLite drivers over node:sqlite, bun:sqlite or better-sqlite3, nor durableObjectSqlite. d1Store keeps no mirrors, so it has neither log nor backfill.

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 NULL column 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.

On this page