Ferry

Versioning

Ferry has four version numbers. They change for different reasons, and most upgrades touch only one of them.

VersionWhere it livesWhat it countsWho changes it
Package version (ferrysync, @ferrysync/*)npmA release of the codeThe Ferry project
version of defineSchemaYour app and your serverThe shape of your data (the tables)You
schemaVersion in every sync requestOn the wire, from the clientThe app's version, checked against the server rangeThe app, at build
Store version (schema_version in ferry_meta)Your database, on the serverThe layout of Ferry's own tablesThe Ferry release

Ferry has no separate protocol version number: the wire format grows by additive changes only, and a client says which additions it understands (see below).

What a release may change

These rules come from the project's decisions. They hold for every release, and they are what you can rely on when you upgrade.

  • The public API is what the entry points export, and nothing else. Changes are additive: new fields and new options. A name is never changed and an export is never removed in place.
  • A breaking change to the public API is decided first, in an RFC under docs/rfc/, and is not made without that decision.
  • The wire format grows additively. A new optional field is safe in both directions: the validators drop keys they do not know. A new kind or status is not safe for old clients, so a client declares the additions it understands in the features of its push, and the server sends a new answer only to clients that listed it. forgotten was added this way: an older client gets applied where a newer one gets forgotten. So was forgottenClient: an older client gets conflict where a newer one gets forgottenClient.
  • Store tables only gain. A migration adds a table, a column or an index. It never edits a shipped one.

Your data: defineSchema and version

Clients and the server may run different versions of your schema for a while. Some changes need no version bump, and some do.

  • Compatible: no bump. Add a field with .default(), .optional() or .nullable(). Old rows stay valid, and older clients ignore the new field.
  • Breaking: bump version. Renaming a field or a table, or changing a field's type. Then the server accepts both versions while old clients are still around.
Client
import { defineSchema } from 'ferrysync';
import { z } from 'zod';

export const schema = defineSchema(
  { tasks: z.object({ id: z.string(), title: z.string(), dueAt: z.number().nullable() }) },
  { version: 2 },
);

Within a version, the rules in Evolving a schema hold: add fields with defaults, and never rename a field or a table in place. When a change cannot be additive, bump version.

The wire: schemaVersion and the server's range

Every request carries the client's schemaVersion. The server accepts a range, versions: { min, max }, and answers updateNeeded to any version outside it:

sync-server.tsServer
import { createSyncServer, memoryStore } from 'ferrysync/server';

import { schema } from './schema';

export const syncServer = createSyncServer({
  schema,
  store: memoryStore(),
  auth: async (request) => (await getSession(request))?.userId ?? null,
  versions: { min: 1, max: 2 }, // accept apps at version 1 and version 2
});

The range has two ends, and both matter:

  • A client older than min is refused. Its status becomes updateNeeded.
  • A client newer than max is refused too. The server must be deployed before the app that uses the new version.

A client that is refused keeps working offline: it queues its writes, reports the phase 'updateNeeded', and calls onUpdateNeeded. Once the user updates, syncNow() tries again and the queued writes go out (see Status and offline). The local authority accepts every version, since the app and its authority ship together.

To roll a change out without stopping anyone:

  1. Deploy the server accepting both versions: versions: { min: n, max: n + 1 }.
  2. Release the app with schemaVersion n + 1.
  3. When few old clients remain, raise min to n + 1. Old apps stop syncing and ask to update.

Raise min last. It is the only step that turns anyone away.

Your database: the store version

The server's SQL stores and the D1 store keep Ferry's tables in your database, and record the layout's version under schema_version in ferry_meta (ferry_ is the default prefix). The rules are the same for every store:

  • migrate: true (the default) creates the tables on first use and upgrades them when a release of Ferry adds a step. Nothing to run.
  • migrate: false leaves the tables to your migrations. The store checks at first use that the version matches this release, and refuses to start with a message that says what to run. See Migrations.
  • An older database than the release is upgraded by the steps the release has, and nothing else.
  • A newer database than the release is refused: the store says to upgrade ferrysync. Ferry does not write back to a layout it does not know.

The client's storages follow the same pattern where they keep a version: the SQLite storage and the IndexedDB storage record one and step up from older versions. The localStorage, MMKV and key-value storages do not record a version (see "Not decided yet").

Deprecations

Nothing is deprecated today. No option, export or endpoint carries a deprecation marker in the code, and the rule that names and options are never renamed or removed means nothing has been retired.

Upgrading

Ferry's client and server are separate packages, and each can move on its own. What must agree is the schema version and the store layout.

Server. Upgrade ferrysync on the server first, and check the range you accept:

  • The server's versions range must include the schemaVersion of every app that still runs.
  • A store with migrate: true upgrades itself when the release has a new step. A store with migrate: false needs the new statements in your migrations first. The release notes say what to add.
  • Rolling back is not supported once a newer release has upgraded the tables: the older release refuses to start on them.

Client. Upgrade ferrysync in the app with your release. The client does not need the server to change first, unless the new app uses a schema version the server does not accept yet. In that case the app is refused with updateNeeded until the server is deployed, and the queued writes wait on the device.

Rows and outboxes. Rows and queued transactions written under an older schema outlive the upgrade. A row that no longer fits the model is skipped and reported through onInvalidRow, and a transaction whose result fails is rejected invalid. Keep changes additive, so neither happens.

Not decided yet

These points are not settled in the project, so this page does not promise them:

  • Which release number a change calls for. No changelog or semver rule exists in the repository yet, and the packages are not released.
  • How a deprecation is announced, and how long a deprecated name stays.
  • Storage versions for localStorage, MMKV and the key-value storage. They record no version, so a change to their layout has no marker to check.
  • Whether a rolled-back release can read a newer store layout. Today it refuses to start.

On this page