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.
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
| Call | What 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.
addMemberfor a member, andremoveMemberfor 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:
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:
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.
| Field | What it is |
|---|---|
space | The space with no members |
table | The table of the row being written; it narrows row's type |
row | The 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
projectIdtakes 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
writeand remove its members. - Keep space ids stable. Membership is stored by id: renaming a space's id scheme orphans its members.