Versioning
Ferry has four version numbers. They change for different reasons, and most upgrades touch only one of them.
| Version | Where it lives | What it counts | Who changes it |
|---|---|---|---|
Package version (ferrysync, @ferrysync/*) | npm | A release of the code | The Ferry project |
version of defineSchema | Your app and your server | The shape of your data (the tables) | You |
schemaVersion in every sync request | On the wire, from the client | The app's version, checked against the server range | The app, at build |
Store version (schema_version in ferry_meta) | Your database, on the server | The layout of Ferry's own tables | The 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
kindorstatusis not safe for old clients, so a client declares the additions it understands in thefeaturesof its push, and the server sends a new answer only to clients that listed it.forgottenwas added this way: an older client getsappliedwhere a newer one getsforgotten. So wasforgottenClient: an older client getsconflictwhere a newer one getsforgottenClient. - 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.
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:
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
minis refused. Its status becomesupdateNeeded. - A client newer than
maxis 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:
- Deploy the server accepting both versions:
versions: { min: n, max: n + 1 }. - Release the app with
schemaVersionn + 1. - When few old clients remain, raise
minton + 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: falseleaves 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
versionsrange must include theschemaVersionof every app that still runs. - A store with
migrate: trueupgrades itself when the release has a new step. A store withmigrate: falseneeds 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.