Ferry
Client

Schema

defineSchema describes your data once. The app and the server import the same schema: it types the client's tables, validates every row, and says who may see each row.

schema.tsShared
import { defineSchema } from 'ferrysync';
import { z } from 'zod';

export const schema = defineSchema(
  {
    projects: z.object({ id: z.string(), name: z.string() }),
    tasks: z.object({
      id: z.string(),
      projectId: z.string(),
      title: z.string(),
      done: z.boolean().default(false),
      notes: z.string().nullable().default(null),
    }),
    drafts: z.object({ id: z.string(), text: z.string() }),
  },
  {
    spaces: {
      projects: (project) => `project:${project.id}`,
      tasks: (task) => `project:${task.projectId}`,
    },
    localOnly: ['drafts'],
  },
);

export type Task = typeof schema.$infer.tasks;

The other pages import this schema as ./schema.

Tables

Each key is a table, and its value is the table's schema. Any Standard Schema works (Zod, Valibot, ArkType); these docs use Zod.

A table's schema must:

  • output an object with a string id;
  • output JSON values only. A field typed Date, Map or bigint is a type error, and a value JSON cannot carry (NaN, a class instance) is refused when it is written;
  • validate synchronously. An async refinement throws when the row is written.

The table names become properties of the client (sync.tasks) and are stored and sent over the wire, so treat them as stable. A few names are taken by the client itself and are refused: ready, isReady, status, tables, schema, engine, subscribe, onStatus, transaction, undo, redo, canUndo, canRedo, syncNow, flush, whenSynced, refetch, connect, reset, close, then and __proto__.

Spaces: private by default

A space is a group of rows that some users share: a project, a team, a document. Every row belongs to exactly one space, and a user receives exactly the spaces they are a member of.

Client
import { defineSchema } from 'ferrysync';
import { z } from 'zod';

export const schema = defineSchema(
  {
    teams: z.object({ id: z.string(), name: z.string() }),
    // In its team's space when it has a team; private to its creator when it does not.
    notes: z.object({ id: z.string(), teamId: z.string().nullable(), text: z.string() }),
    // No space function: every bookmark is private to the user who created it.
    bookmarks: z.object({ id: z.string(), url: z.string() }),
  },
  {
    spaces: {
      teams: (team) => `team:${team.id}`,
      notes: (note) => (note.teamId === null ? null : `team:${note.teamId}`),
    },
  },
);
  • A space function takes the row (the schema's output) and returns the space id: any string you choose.
  • A table without a space function is private. Each row belongs to the user who created it, and only their own devices receive it. Returning null from a space function means the same, for that row.
  • The literal 'user' is reserved: it is the private space on the wire.
  • Who is a member of which space is decided on the server.

Private by default means that forgetting a space function never leaks data: the worst case is a row nobody else sees.

Local-only tables

localOnly tables stay on the device. They are written, read, queried and undone like the others, and survive restarts, but are never sent to the server. Use them for drafts, UI state and per-device settings.

  • A local-only table cannot have a space.
  • Signing in with connect keeps them, and reset wipes them with everything else (see Accounts).

Types

Client
import { schema } from './schema';

type Task = typeof schema.$infer.tasks; // the output type of the tasks schema
const names = schema.tables; // ['projects', 'tasks', 'drafts'], in the order they were defined

schema.$infer only carries types: it has no value at runtime.

Optional or nullable

Both work, and they mean different things.

You writeIn createWhen not givenTo clear it in update
z.number().optional()may be left outthe field is absentnot possible: undefined = unchanged
z.string().nullable()required (or null)not possiblewrite null
z.string().nullable().default(null)may be left outnullwrite null
z.boolean().default(false)may be left outthe defaultwrite another value

undefined is never stored or sent, and in an update it means "unchanged". So a field users can clear should be .nullable(), usually with .default(null), rather than .optional().

Evolving a schema

Clients and the server may run different versions of the schema for a while (app store reviews, users who never update). Two kinds of change:

  • Compatible: no version bump. Add a field with .default(), .optional() or .nullable(). Rows written before the change are still valid, and older clients ignore the new field.
  • Breaking: bump version. Renaming a field or a table, or changing a field's type, would make older clients write rows the new schema refuses. Bump the version, and let the server accept 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 },
);

A client whose version the server no longer accepts keeps working offline, queues its writes, and reports it: its status phase becomes 'updateNeeded' and onUpdateNeeded is called (see Status and offline).

Options

OptionTypeDefaultWhat it does
versionpositive integer1Bump it on a breaking change
spaces{ [table]: (row) => string | null }{}The space of each row; private when unset
localOnlytable names[]Tables kept on the device, never synced

defineSchema returns { $infer, tables, version, models, definitions }. models and definitions are what the client and the server are built on; an app rarely reads them.

Gotchas

  • A schema must accept its own output. The client validates every row again when it reads it back, so a transform that changes a valid row again (.transform((n) => n * 2)) corrupts data. An idempotent one (.trim()) is fine.
  • Ids are strings. id: z.string() is enough: the client fills it in. Constrain it further (z.uuid()) only if every id you create fits.
  • Defaults run at create. A default added later does not rewrite stored rows: the schema fills it in each time it reads an old row.
  • Mistakes throw at startup. An unknown table in spaces or localOnly, a local-only table with a space, a reserved name or a value that is not a Standard Schema make defineSchema throw.

Next steps

On this page