Ferry
Server

Spaces

A space is a set of rows some users share: a project, a team, a household. The schema says which space each row is in; the server says who belongs to each space. A user's devices get exactly the rows of their spaces, plus their private rows.

Server
await syncServer.spaces.addMember(userId, `project:${projectId}`); // their devices download it
await syncServer.spaces.removeMember(userId, `project:${projectId}`); // their devices drop it

await syncServer.spaces.members(`project:${projectId}`); // ['alice', 'bob']
await syncServer.spaces.of(userId); // ['project:p1', 'team:t1']

Space ids are the strings the schema's space functions return, like project:${task.projectId}. Ferry never parses them.

Members

CallWhat it does
addMember(userId, space)Gives the user the space; their devices download its rows
removeMember(userId, space)Takes it away; their devices delete its rows and their pending changes in it
members(space)The space's members, sorted
of(userId)The user's spaces, sorted, their private space left out
  • Both changes reach the user's devices in realtime, like any change.
  • addMember for a member, and removeMember for a non-member, do nothing.
  • Membership is not a row: no hook runs, and the app cannot change it. Your backend decides.

Invites

An invite is an endpoint of your API, which checks whatever invites mean in your app and then adds the member:

Server
import { Hono } from 'hono';

const app = new Hono();

app.post('/invites/:token/accept', async (c) => {
  const session = await getSession(c.req.raw);
  const invite = await findInvite(c.req.param('token'));
  if (session === null || invite === null) {
    return c.json({ error: 'Not found' }, 404);
  }
  await syncServer.spaces.addMember(session.userId, `project:${invite.projectId}`);
  return c.json({ ok: true });
});

The app needs nothing else: the invited user's devices download the project as soon as the call returns. Billing checks, seat limits and unique names belong in such endpoints too.

claimSpace

A user creates a project on their phone, offline. When the phone syncs, the project's space has no members yet, so the user is not one, and the server refuses the write as forbidden. claimSpace lets that first writer become the space's first member:

Server
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,
  // A project's own row founds its space; nothing else does.
  claimSpace: ({ table, space, row }) => table === 'projects' && space === `project:${row.id}`,
});

The server asks only when a write would put a row in a space that has no members at all, and no rows. Returning true makes the writer its member, in the same transaction as the write, and their other devices download it. Returning false refuses the write as forbidden.

FieldWhat it is
spaceThe space with no members
tableThe table of the row being written; it narrows row's type
rowThe row as it would be written
actor{ type: 'user', userId, clientId }: the user who would found it (clientId is null for a write({ as: { userId } }))

Decide from the row, so that only the row that creates a space can found it. A space whose members all left but whose rows stayed (a project deleted without its tasks) is never claimed again: a write there is forbidden, whatever claimSpace would say, so its rows never reach a new member. Give it members with spaces.addMember, or delete its rows.

Private spaces

Rows of tables without a space function (and rows whose space function returns null) belong to the user who created them: their private space, user:<userId> on the server. It needs no membership, and of leaves it out.

Never add anyone to a user: space. addMember(userId, 'user:' + userId) throws, but adding someone to another user's private space is not refused, and would share that user's private rows with them. Keep your space ids apart from it: project:…, team:….

The server can write a user's private rows with write.

Gotchas

  • Changing a row's space moves it. An update that changes projectId takes the task out of one project's space and into another's: members of the first see it deleted, members of the second see it created. The writer must belong to both.
  • Removing a member drops their pending changes in that space, on their devices.
  • A space ends with its rows. To delete a project, delete its rows with write and remove its members.
  • Keep space ids stable. Membership is stored by id: renaming a space's id scheme orphans its members.

On this page