Appearance
Data Visibility Tiers
Synced-store enforces visibility at the server API boundary via key prefixes. Every table belongs to exactly one tier — pick it before you design the schema, not after.
The Three Tiers
- Public (default) —
ctx.table(name)— synced to every user in the app instance. - Per-user private —
ctx.privateOfUser(userId).table(name)— synced only to that user. Write one copy per recipient. - Server-only —
ctx.serverOnly().table(name)— never synced to any client; only server-side mutators/actions read it. Expose derived results via actions.
When to Reach for Non-Public Tiers
Role-restricted state — spymaster's color key in Codenames, dealer's deck in a card game, judge's secret prompt. Use privateOfUser(roleHolderId) and write a copy per role-holder when roles are assigned.
Server-evaluated secrets — answer keys, RNG seeds, scoring weights, unrevealed puzzle solutions. Use serverOnly() and expose results through actions.
Per-user private state — draft notes, private preferences, unread markers, unshared progress. Use privateOfUser(userId).
Red Flag
If you're proposing client-side filtering, client-side encryption, or gating every read through an action just to hide data — you want privateOfUser or serverOnly instead. The platform already solves this at the API boundary.
Method Access Matrix
| Method | Client | Server |
|---|---|---|
ctx.table(name) | Read/write (public rows only) | Read/write (public rows only) |
ctx.privateOfUser(ctx.userId) | Read/write (own only) | Read/write |
ctx.privateOfUser(otherUserId) | Throws | Read/write |
ctx.serverOnly() | Throws | Read/write |
ctx.table("name") and ctx.privateOfUser(userId).table("name") are separate namespaces — even though they share a table name in the schema, the rows live under different storage prefixes ({table} vs $$pu/{userId}/{table}). Reading or writing one never touches the other.
Read your own private rows
Writing private data via ctx.privateOfUser(ctx.userId).table(...).set(...) is the part most people remember. The mirror — that READING your own private rows ALSO needs privateOfUser — bites every time.
typescript
// In a mutator handler — server fan-out to teammates' private stores
sendTeamChat: async (ctx, input) => {
if (!ctx.isServer) return;
const message = { id: input.id, userId: ctx.userId, text: input.text };
for (const allyId of [ctx.userId, allyUserId]) {
await ctx.privateOfUser(allyId).table("teamChat").set({
itemKey: input.id,
value: message,
});
}
},
// In the UI — to see your own teamChat row, you MUST go through privateOfUser
const { data: teamChatRaw } = createLiveQuery(
() => store,
() => (tx) => tx.privateOfUser(tx.userId).table("teamChat").entries().toArray(),
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// NOT tx.table("teamChat") — that namespace is empty
);
// Same in a one-shot query (e.g. tests)
const view = await store.query((tx) =>
tx.privateOfUser(tx.userId).table("teamView").get("state"),
);Reading another user's private rows is rejected on the client with a thrown error; only the server can do that. So in practice, every UI / test read of a private table goes through tx.privateOfUser(tx.userId).
Hooks can't fan out to other users' private rows
System hooks (onAddUser, onRemoveUser, onGrantPermission, onRevokePermission) run inside a system mutator. The platform throws SystemTableTamperError if a hook writes to ctx.privateOfUser(otherUserId).table(...) — $$pu/<userId>/... keys are reserved for non-system mutations. A hook CAN write to public tables, server-only tables, and the hook-target user's own private tables, but NOT to other users' private tables. If you need cross-user fan-out triggered by a membership change (e.g. seed each existing player's private view when a new user joins), do it from a regular mutator that the new user calls on connect, or from an action enqueued via ctx.enqueueAction(...).