Skip to content

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 privatectx.privateOfUser(userId).table(name) — synced only to that user. Write one copy per recipient.
  • Server-onlyctx.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

MethodClientServer
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)ThrowsRead/write
ctx.serverOnly()ThrowsRead/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(...).