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.
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,Maporbigintis 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.
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
nullfrom 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
connectkeeps them, andresetwipes them with everything else (see Accounts).
Types
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 definedschema.$infer only carries types: it has no value at runtime.
Optional or nullable
Both work, and they mean different things.
| You write | In create | When not given | To clear it in update |
|---|---|---|---|
z.number().optional() | may be left out | the field is absent | not possible: undefined = unchanged |
z.string().nullable() | required (or null) | not possible | write null |
z.string().nullable().default(null) | may be left out | null | write null |
z.boolean().default(false) | may be left out | the default | write 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.
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
| Option | Type | Default | What it does |
|---|---|---|---|
version | positive integer | 1 | Bump it on a breaking change |
spaces | { [table]: (row) => string | null } | {} | The space of each row; private when unset |
localOnly | table 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
spacesorlocalOnly, a local-only table with a space, a reserved name or a value that is not a Standard Schema makedefineSchemathrow.