Appearance
Synced-Store Reference
Real-time sync engine (like Replicache). Each app instance = one server-side SQLite DB + per-client IndexedDB copies. Mutators run optimistically on the client and authoritatively on the server. Actions run server-only (AI calls, HTTP, randomness).
- Mutators and actions are portable store code, not normal browser/Node code: mutators run on the client and then on the backend, and actions run on the backend. Backend execution happens in a special store-function runtime where ambient timer APIs such as
setTimeout/clearTimeoutare not available. - Synced-store mutations and
externalMutationswork while offline and gracefully sync to the server. Actions andctx.platform.call(...)only run on the server; client-sidectx.platform.call(...)in a mutator always throws. Mutator platform calls are discouraged, must be guarded withctx.isServer, must be awaited, must be idempotent/read-only because optimistic-lock conflicts can retry the server mutator attempt, and should be used only for short one-off work because mutators are processed one at a time. Effects fromctx.enqueueAction(...)in a mutator will not happen until the mutation syncs to the server. Avoid actions when a mutator can express the behavior.
This file is a snippet index. Load the linked reference when you need detail.
synced-store/tile-schema-version.ts — single source of truth
ts
// Plain constant file — no Zod, no schema imports. Both client-config.ts
// and schema.ts read from here so the version can never drift between
// client and server. When you add migrations later, `schema.ts` keeps
// importing this same constant alongside the migrations registry.
export const TILE_SCHEMA_VERSION = 1;See → schema-migrations.md for bumping the version with a migration.
⚠️ Once an activity is published, a backwards-incompatible schema change REQUIRES a migration — never ship one without it. Real instances hold real data at the old shape. Bumping
TILE_SCHEMA_VERSIONwithout a matchingmigrationsentry does not "reset" those instances gracefully — it makes every existing client clear its local data and dispose (seeonSchemaVersionMismatchbelow), which reads to players as their game/room being wiped. If you are renaming/removing/retyping a field, changing an itemKey/sortKey convention, or otherwise altering the shape of already-persisted data or in-flight mutations, you are making a backwards-incompatible change: bump the version AND write the migration in the same change. If you genuinely only added a new table, a new mutator, or a new field with a default, that is backwards-compatible — do NOT bump the version. When unsure, assume it's incompatible and write the migration. Before republishing a schema change to a live activity, stop and confirm: does existing data still parse under the new schema? If not → migration required. → schema-migrations.md
⚠️ Before bumping the schema version, land a parameterized snapshot test with a committed fixture for the current version. The test is a
test.eachover every version from a checked-in baseline toTILE_SCHEMA_VERSION, built onharness.getOrCreateSnapshot(...): each case loads (or, only at the current version, records) a real client+server snapshot, restores it under today's code, and pushes a mutation to drive the migration chain. Do this as its own step FIRST — a fixture for version N can only be recorded by code running at version N, so once you bump, the old shape is unrecoverable and your migration has no real old data to run against. → schema-migrations.md
schema.ts — the contract
ts
import { z } from "zod";
import { defineSchema, table, singletonTable, item } from "poe-tiles-sdk/v1/backend.js";
import { TILE_SCHEMA_VERSION } from "./tile-schema-version";
export const tileSchema = defineSchema({
schemaVersion: TILE_SCHEMA_VERSION,
tables: {
todos: {
schema: table(z.object({
id: z.string(),
text: z.string(),
done: z.boolean(),
updatedAt: z.number(),
})),
searchable: { textField: "text", timestampField: "updatedAt" },
},
settings: {
schema: singletonTable(
item("theme", z.enum(["light", "dark"])),
item("pageSize", z.number()),
),
},
},
mutators: {
setTodo: {
description: "Create or update a todo",
input: z.object({ id: z.string(), text: z.string(), done: z.boolean().optional(), updatedAt: z.number() }),
},
removeTodo: { input: z.object({ id: z.string() }) },
},
actions: {
suggestTodo: {
description: "Ask an LLM for a todo suggestion",
input: z.object({ prompt: z.string() }),
output: z.object({ text: z.string() }),
},
},
});
export type TileSchema = typeof tileSchema;- Homogeneous collection:
table(valueSchema)— itemKeys are dynamic. - Single-row record:
table(schema)with a fixed itemKey like"game"(no no-keysingletonTable(schema)form). - Typed settings bag:
singletonTable(item(key, schema), ...)— per-key value types → singleton-tables.md. - Lighter bundles (no Zod): Valibot or JSON Schema → schema-libraries.md.
- Searchable in platform search / MCP tools → searchable-tables.md.
- Bump
TILE_SCHEMA_VERSIONonly for backwards-incompatible changes, with the migration and the pre-bump snapshot fixture in the same change — see the two callouts undertile-schema-version.tsabove and → schema-migrations.md.
Mutators + shared data helpers
Keep each mutator handler in its own file under synced-store/mutators/. Compose the exported mutator map in synced-store/mutators/index.ts. Put read-only data loading helpers in synced-store/data/ so query, mutation, and action code can share them without importing write-only helpers.
synced-store/data/todos.ts:
ts
import type { InferReadContext, InferSchemaTableTypes } from "poe-tiles-sdk/v1/client.js";
import type { TileSchema } from "../schema";
export type TileTableTypes = InferSchemaTableTypes<TileSchema>;
export type Todo = TileTableTypes["todos"];
export type TileReadCtx = InferReadContext<TileSchema>;
export async function readTodo(ctx: TileReadCtx, id: string): Promise<Todo | undefined> {
return (await ctx.table("todos").get(id)) as Todo | undefined;
}synced-store/mutators/types.ts:
ts
import type { InferMutatorHandlers } from "poe-tiles-sdk/v1/client.js";
import type { TileSchema } from "../schema";
export type TileMutators = InferMutatorHandlers<TileSchema>;
export type TileMutator<Name extends keyof TileMutators> = TileMutators[Name];synced-store/mutators/set-todo.ts:
ts
import { readTodo, type Todo } from "../data/todos";
import type { TileMutator } from "./types";
// `updatedAt` is a REQUIRED input — the caller passes Date.now() at the call site.
// Reading the clock inside a mutator is not rebase-safe: the mutator re-runs on
// the server and during rebase, each seeing a different "now".
export const setTodo: TileMutator<"setTodo"> = async (ctx, input) => {
const existing = await readTodo(ctx, input.id);
const todo: Todo = {
id: input.id,
text: input.text,
done: input.done ?? existing?.done ?? false,
updatedAt: input.updatedAt,
};
await ctx.table("todos").set({ itemKey: input.id, value: todo });
};synced-store/mutators/remove-todo.ts:
ts
import type { TileMutator } from "./types";
export const removeTodo: TileMutator<"removeTodo"> = async (ctx, input) => {
await ctx.table("todos").delete(input.id);
};synced-store/mutators/index.ts:
ts
import { removeTodo } from "./remove-todo";
import { setTodo } from "./set-todo";
import type { TileMutators } from "./types";
export const tileMutators: TileMutators = {
setTodo,
removeTodo,
};
export type { TileMutator, TileMutators } from "./types";Read → mutator-rules.md before writing mutators. Key rules:
- Generate IDs + timestamps at the call site (not inside the mutator) — mutators run multiple times (optimistic + server + rebase).
- Explicit values, never toggles:
done: true, NOTdone: !existing.done. - Read-before-write when merging — makes the mutator safe as both create and update.
.set()takes{ itemKey, value }, not positional args.- Private/server-only writes:
ctx.privateOfUser(userId).table(...),ctx.serverOnly().table(...)— see data-visibility.md.
actions.ts — server-only handlers
ts
import { collectModelText, createModelStream, type InferActionHandlers } from "poe-tiles-sdk/v1/backend.js";
import type { TileSchema } from "./schema";
export const tileActions: InferActionHandlers<TileSchema> = {
suggestTodo: async (ctx, input) => {
const existing = await ctx.table("todos").scan().values().toArray(); // reads are fine
const openModelStream = createModelStream(ctx.platform);
const text = await collectModelText(
openModelStream({
model: "claude-opus-5", // a model-proxy catalog id — discover ids with `Poe.listModels()`
prompts: [{ role: "user", text: `Suggest a todo after ${existing.length} existing todos: ${input.prompt}` }],
}),
);
await ctx.mutate("setTodo", { id: crypto.randomUUID(), text, done: false, updatedAt: Date.now() });
return { text };
},
};- Actions have read-only table access:
ctx.table(...),ctx.privateOfUser(...).table(...),ctx.serverOnly().table(...). - To WRITE, call
ctx.mutate("mutatorName", input)— there's no.set()/.delete()on action table handles. - Platform API is a single
ctx.platform.call(name, input)dispatch — prefer actions; the mutator-side caveats are at the top of this file and in → platform.md. - Call from UI:
await store.action.suggestTodo({ prompt })— flushes pending mutations first, waits for server. - Dispatch to another store instance → external-stores.md.
- File uploads (
store.files.upload()+ the reserved_requestUploadUrlgrant handler that gates them) → file-uploads.md.
client-config.ts, backend-config.ts, wiring
ts
// synced-store/client-config.ts — client-safe, no Zod
import { defineClientConfig } from "poe-tiles-sdk/v1/client.js";
import type { tileSchema } from "./schema"; // type-only!
import { tileMutators } from "./mutators/index";
import { tileHooks } from "./hooks";
import { TILE_SCHEMA_VERSION } from "./tile-schema-version";
export const tileClientConfig = defineClientConfig<typeof tileSchema>({
mutators: tileMutators,
hooks: tileHooks,
schemaVersion: TILE_SCHEMA_VERSION,
});ts
// synced-store/backend-config.ts — server-only
import { defineBackendConfig } from "poe-tiles-sdk/v1/backend.js";
import { tileSchema } from "./schema";
import { tileMutators } from "./mutators/index";
import { tileHooks } from "./hooks";
// import { tileActions } from "./actions"; // add when you introduce server-only handlers
export const tileBackendConfig = defineBackendConfig({
schema: tileSchema,
mutators: tileMutators,
hooks: tileHooks,
// actions: tileActions, // uncomment + import once actions.ts exists
});ts
// tile/src/entry.tsx — the only place that calls setupStore
import {
applyNativeAppGestureOverrides,
createPoe,
PostMessageEnvironment,
} from "poe-tiles-sdk/v1/client.js";
import { tileClientConfig } from "../../synced-store/client-config";
applyNativeAppGestureOverrides();
const Poe = createPoe({ environment: new PostMessageEnvironment() });
const store = Poe.setupStore(tileClientConfig);
// render UI with `store` as a prop- Keep
applyNativeAppGestureOverrides()in every player-facing activity; remove it only when free text selection is a primary feature. This global override does not stop the iOS magnifier loupe. Follow the tile-creator gesture audit and applysuppressLongPressMagnifier(element)to every non-scrolling gameplay surface where a finger may pause or drag. - Type the client:
type TileStoreClient = InferSyncedStoreClient<TileSchema>. - Import paths:
poe-tiles-sdk/v1/client.js(UI),poe-tiles-sdk/v1/backend.js(server),poe-tiles-sdk/v1/test-utils.js(tests).
Reading — inside query / subscribe / mutator ctx → client-api-reference.md
- One-shot:
const todo = await store.query((ctx) => ctx.table("todos").get("todo-1")) - Get by key:
await ctx.table("todos").get("id")— returns value orundefined - Existence check:
const exists = await ctx.table("todos").has("id") - All entries:
const rows = await ctx.table("todos").entries().toArray()—[EntryKey, value][] - Just values:
const vals = await ctx.table("todos").scan().values().toArray() - Just keys:
const keys = await ctx.table("todos").keys().toArray() - Prefix scan:
table.scan({ prefix: { sortKey: "2026-" }, limit: 50 }) - Pagination:
table.scan({ limit: 50, cursor: lastEntryKey }) - Reverse (latest N):
table.scan({ limit: 5, reverse: true })— returns last 5 in descending order. Same entries via sentinel:table.scan({ cursor: "$last", aroundCursor: { before: 5, after: 0 } })— same set in ascending order (no manual reverse needed) - Window around an anchor (UI rendering, e.g. show context around a search result):
table.scan({ cursor: { sortKey, itemKey }, aroundCursor: { before: 5, after: 45 } })— returns up to 5 entries before the anchor, the anchor itself if present, then up to 45 after, ascending aroundCursoris client-only (queries /subscribeToTable). Mutators and actions calling it on the server throw with a pointer to the workaround: compose twocursor + limitscans manually if you really need server-side context around an anchor- Current user inside a mutator/query:
ctx.userId(NOT available onstore.userId). From UI code, use the helper:import { getCurrentUserId } from "poe-tiles-sdk/v1/client.js"; const myId = await getCurrentUserId(store); - Read your own private table:
await ctx.privateOfUser(ctx.userId).table("name").get("key").ctx.table("name")reads ONLY public data — even your own private rows aren't visible through it. Same goes for subscribes (tx.privateOfUser(tx.userId).table(...)) and tests (store.query(tx => tx.privateOfUser(tx.userId).table(...)))
Writing — firing mutators from UI → mutator-rules.md
Fire:
store.mutate.setTodo({ id, text, done: false, updatedAt: Date.now() })— returns immediately, optimisticAwait server confirmation:
const { confirmed } = await store.mutate.setTodo({ ... }); await confirmedTerminal/manual inspection:
poe-tiles stores access <typeId> <instanceId>checks the currentPOE_API_KEYuser against$users;poe-tiles stores schema <typeId> <instanceId>lists mutators and generated CLI commands;poe-tiles stores mutate <typeId> <instanceId> <mutationName> --input '<json>'dispatches a mutator;poe-tiles store files upload <typeId> <instanceId> <file>uploads managed bytes and returns afileIdto claim. See tile-creator CLI reference.Generate IDs/timestamps HERE (at the call site), pass as input:
id: await store.makeUniqueId(),updatedAt: Date.now()Client-side performance optimization: pass
{ pendingMutationDedupeKey }as the secondstore.mutate.*argument only when repeated pending mutations can be coalesced so the latest value replaces earlier same-key values. Good fits: setter-style drafts, selected tool/type, cursor/hover/highlight, and passive position updates. Do NOT use it for append/send/event/counter/toggle mutations, or when a later queued mutation depends on every intermediate same-key write reaching the server.tsconst POSITION_MUTATION_OPTIONS = { pendingMutationDedupeKey: "player-position", } as const; void store.mutate.updatePosition( { x, y, updatedAt: Date.now() }, POSITION_MUTATION_OPTIONS, );See the mutator examples above for handler-side rules (explicit values not toggles, read-before-write, etc.)
Sort keys — ordering unbounded, accumulating tables → sort-keys.md
Sort keys are an opt-in ordering field that lets synced-store data be lazy-loaded. Developers assign the row order, then pull windows control which ordered data loads first.
When to use
Add a nonempty sortKey when a table can accumulate unbounded data that is mostly presented as one ordered list, such as chat, activity, or social or photo feeds.
When not to use
Keep small or bounded tables sort-key-free. Safety quotas do not make naturally accumulating history bounded.
Default
Data has the empty sort key (sortKey: "") and is always loaded. Nonempty sort keys make unbounded rows eligible for lazy loading; both kinds can coexist in one store.
- In practice, use one pull window per schema. The API accepts an array, but its windows run sequentially against one shared byte budget and cannot be loaded independently.
- Use one namespace for one ordered stream. For a feed, give each post a new
feed/...sort key and reuse that exact key for bounded associated rows such as a capped comment preview, so they occupy the same atomic lazy-loaded position. An unbounded comment history must not share the post's key; paginate it separately.
See sort-keys.md for generation, pagination, pull windows, and examples.
Subscribing — reactive UI → ui-patterns.md
- React/Preact:
const unsub = store.subscribe((ctx) => ctx.table("todos").entries().toArray(), (entries) => setTodos(entries.map(([, v]) => v))) - SolidJS (change diffs):
store.subscribeToTable("todos", (entries, changes) => { /* changes.added|modified|removed */ }) - Solid +
reconcile()to preserve DOM nodes across updates (seedocs/solidjs-best-practices.md) - React hook with loading state:
const { data, isLoading } = useLiveQuery(store, (ctx) => ctx.table("todos").entries().toArray())frompoe-tiles-sdk/v1/react - Subscribe to a key prefix:
store.subscribe((ctx) => ctx.table("users").scan({ prefix: { itemKey: "alice" } }).entries().toArray(), (entries) => {}) - Subscription queries re-run only when an exact key or scan prefix read through
ctxchanges. Values captured outsidectxare not tracked; after changing one that affects the query, callawait subscription.refresh()to re-run the query and replace its dependencies. store.subscribe()andstore.subscribeToTable()return a callableSubscriptionControl: call it to unsubscribe, or call itsrefresh(): Promise<void>method to refresh the result and tracked dependencies.- Treat subscriptions for local actions as firing twice: optimistic local mutation, then authoritative server confirmation/rebase. Any subscription-driven animation, sound, toast, or derived side effect must de-dupe by event id, version, timestamp, or previous-state comparison.
- Animations via subscribeToTable change diffs: see
docs/synced-store-animation-guide.md
Mutator context → mutator-rules.md + server-forking.md
ctx.table(name)— publicctx.privateOfUser(userId).table(name)— per-user private (throws on client ifuserId !== ctx.userId)ctx.serverOnly().table(name)— server-only (throws on client; guard writes withif (ctx.isServer))ctx.isServer— branch for pending indicators (isPending: !ctx.isServer) or server-only writes. Avoid otherwisectx.enqueueAction("name", input)— call UNCONDITIONALLY; no-op on client, runs on server after commit. In tests, callstore.action.<name>(...)directly after the mutator to deterministically wait for the action to run — don't rely onsetTimeout/tick. → testing-actions.mdSkip optimistic entirely when outcome depends on unreadable data:
if (!ctx.isServer) returnSending notifications from a mutator:
await notifyActivity(ctx, input)— updates the manager sidebar (preview / unread bump), optionally enqueues an OS push, and can optionally append one app-owned announcement to the containing chat viapostToChat.Release gate: every meaningful accepted user action that changes activity state or advances play bumps the activity for the actor. Put the activity in the same mutator as the state change; when gameplay state is otherwise local, invoke a small activity mutator once at the semantic boundary rather than on every pointer frame. Make the caller an activity recipient and resolve their activity to
recency: "bump". Reuse a fan-out call when it already includes the actor; otherwise add an actor-onlynotifyActivitywithtargetUserIds: [ctx.userId],recency: "bump", and nounreadorpush.setTurnaimed at the next player does not bump the actor. Skip rejected/no-op actions and transient pointer/hover/draft updates. Also skip per-user preferences visible only to the acting user — mute, volume, haptics, theme, and reduced motion — even though they are accepted actions that write state: they change how the activity presents itself to one person rather than what happened inside it, so persist the preference and return without notifying. Shared settings that affect other players or play — house rules, difficulty, round length, board size, and similar room-wide configuration — still bump. Verify the actor's confirmed Recents bump and unchanged unread count withcreatePoeTileInManagerTestHarness.tsawait notifyActivity(ctx, { preview: string, // sidebar preview text (e.g. last message) previewTimestamp: number, // bumps the space in the recents list unread: "increment", // optional; uses the default simple unread // policy and bumps each non-caller recipient's // app-owned unread count. Omit for a // preview/sortKey refresh only. unreadToCaller: true, // optional; system-attributed receipt that // should also increment the caller. Requires // unread: "increment" and caller targeting. // Optional. Omit → fan out to every active member. // Client pass is a no-op unless ctx.userId is in this list (or omitted); // the server's authoritative pass does the real fan-out. targetUserIds?: string[], // Optional. If present, every activity recipient EXCEPT the caller // gets an OS push (default sender-suppression). Use `pushToCaller: true` // to include the caller (e.g. system-attributed pushes); throws if the // caller isn't in the activity recipient set. push?: { title: string, // notification title (sender / app name) body: string, // notification body (preview / message text) pushToCaller?: boolean, // default false — don't push your own action }, // Optional. Appends one announcement row to the chat room resolved from // this store's pinned $$system/room. No caller-provided chat id. Chat // ignores messageId for row identity, uses its next msg/... sortKey, and // derives a separate chat-owned itemKey from that sortKey. postToChat?: { messageId: string, text: string, timestamp: number, }, });
Validation order: throw BEFORE the isServer gate
When a mutator validates its input (turn checks, slot conflicts, phase gates), put the throw above any if (!ctx.isServer) return; so it runs on the client's optimistic pass. Otherwise the optimistic mutation succeeds locally and the server-side rejection is silent (see "Server throws don't reject confirmed" below). Validate using public/own-private state up top; only gate writes that touch serverOnly() or other users' privateOfUser tables.
typescript
makeMove: async (ctx, input) => {
// CHEAP CHECKS FIRST — they run on both client (optimistic) and server.
// A bad call rejects the outer `await store.mutate.makeMove(...)` synchronously.
const game = await ctx.table("game").get("state");
if (game?.status !== "playing") throw new Error("Game is not in play");
if (game.currentPlayer !== ctx.userId) throw new Error("Not your turn");
// SERVER-ONLY work below this line. The board lives in serverOnly(), so we
// can't validate the move further on the client — accept the optimistic
// pass as a no-op and let the server do the real work.
if (!ctx.isServer) return;
const board = await ctx.serverOnly().table("board").get("state");
// ... apply move, advance turn, etc.
},Worked example — a startRound mutator that validates role coverage with public state before doing any server-only randomness:
typescript
startRound: async (ctx) => {
const phase = await ctx.table("game").get("phase");
if (phase !== "setup") throw new Error(`Cannot start in phase ${phase}`);
// Public state — readable on client + server, so this throw rejects the
// optimistic call synchronously when roles aren't filled.
const players = (await ctx.table("players").entries().toArray()).map(([, v]) => v);
const hasRedSpy = players.some((p) => p.team === "red" && p.role === "spymaster");
const hasBlueSpy = players.some((p) => p.team === "blue" && p.role === "spymaster");
if (!hasRedSpy || !hasBlueSpy) throw new Error("Both teams need a spymaster");
// Server-only work below — randomness, hidden board placement, etc.
if (!ctx.isServer) return;
const seed = crypto.getRandomValues(new Uint32Array(1))[0]!;
await ctx.serverOnly().table("board").set({ itemKey: "state", value: layoutFor(seed) });
await ctx.table("game").set({ itemKey: "phase", value: "playing" });
},Actions — more detail → actions.md + platform.md
- Handler shape and basic wiring shown in
actions.tsexample above. - Stream bot responses, MCP tool exposure, multi-step actions — see
actions.md. - Testing actions and server-side platform calls with mocked
ctx.platform.call(...)→ testing-actions.md.
Inter-app communication → external-stores.md
Read this reference when you want different apps to communicate with each other or trigger mutations on each other.
System hooks — react to membership / parenting / permission changes
Declare hooks in a client-safe module and wire them into both defineClientConfig({ hooks }) and defineBackendConfig({ hooks }). Backend hooks run on the server within the same atomic transaction as the system mutator that fired them. Client hooks run optimistically for fresh creator launches before server data arrives, then the server-authoritative data replaces that overlay. Receive a MutationContext — treat hooks like mutators (read + write tables, follow rebase-safe rules). If a hook has server-only side effects, split the deterministic writes into a shared helper and keep the server-only tail backend-local, or guard that tail with if (ctx.isServer). onInit is supplied by poe-tiles-kernel for new child-room launches and root-group genesis. Read the current room topology from await ctx.table("$$system").get("room"); ctx.userId identifies the initiating user for user/external-store bootstraps, while ctx.isSystem indicates the hook is running in trusted system scope. The input carries a read-only snapshot of the parent room's users for child rooms.
ts
import type { SystemHookMap } from "poe-tiles-sdk/v1/client.js";
export const tileHooks = {
onInit: async (ctx, { parentRoomUsers }) => {
const room = await ctx.table("$$system").get("room");
if (room?.type === "rootGroup") {
// Seed app-owned rows for a newly started group here.
// A fresh rootGroup genesis always has an empty parentRoomUsers.
}
},
onAddUsers: async (ctx, { userId }) => {
await ctx.table("scores").set({ itemKey: userId, value: { userId, score: 0 } });
},
onRemoveUser: async (ctx, { userId }) => { /* cleanup */ },
onAnonymizeUser: async (ctx, { userId }) => { /* scrub app references */ },
onSetTitle: async (ctx, { userId, title }) => { /* title is string | null — null when the custom title is cleared/reset to the default; handle both */ },
onGrantPermission: async (ctx, { userId, permission }) => { /* e.g. log audit row */ },
onRevokePermission: async (ctx, { userId, permission }) => { /* e.g. tear down role-specific state */ },
onChildInstancesAdded: async (ctx, { instances }) => { /* e.g. index child rows */ },
onRoomMemberInstanceMovedOut: async (ctx, { storeTypeId, instanceId, toRoom }) => { /* e.g. record departure */ },
} satisfies Partial<SystemHookMap>;onInit(ctx, { parentRoomUsers })— new root-group or child-room activity initialized. Readawait ctx.table("$$system").get("room")for the current room topology, and usectx.userIdfor the initiating user when the bootstrap came from a user/external store; usectx.isSystem, notctx.source.type === "system", to detect trusted system scope.parentRoomUsersis a read-only snapshot of the active parent room roster (PoeUserInfo[]) for child rooms and empty for root groups — useful to detect a fresh rootGroup genesis or to react to the parent roster, never a way to admit members. Apps may write their own tables on both passes. Do not write$usersdirectly, and do not try to pull parent-room users into the activity: membership is platform-owned. The host seats the launcher at genesis (and both users when the activity is launched from a 2-person room, i.e. a DM); anyone else joins through the host picker/invite flows.- Use
onInitfor deterministic one-time bootstrap, not membership. It is a good place to seed app-owned rows such as an initial activity or transcript message ("{name} started the group"). onAddUsers(ctx, { userId })— user joined the instance. Use it to auto-seat newly added users into roles, seats, teams, or turn order, and to append membership activity. The$usersrow has already been written when the hook runs, so readawait ctx.table("$users").get(userId)foraddedByand optionaladdedBatchUserIds, then read$userInfoto write messages like"{name} added {usernames...}"or coalesce a multi-user add into one announcement.onRemoveUser(ctx, { userId })— user removedonAnonymizeUser(ctx, { userId })— user hard-deleted/anonymizedonSetTitle(ctx, { userId, title })— title changed;titleisstring | null— it isnullwhen the custom title is cleared/reset to the default, so handle the null (reset) case rather than interpolatingnullonGrantPermission(ctx, { userId, permission })— permission grantedonRevokePermission(ctx, { userId, permission })— permission revokedonAddTileInstanceToRoom(ctx, { storeTypeId, instanceId })— an app instance was registered as a member of this room (fires on the room store after a new$room_member_instancesrow is written; suppressed on idempotent re-registers)onChangeTileParent(ctx, { previousParent, parent })— this activity instance gained, lost, or changed its parent rootGrouponAddChildTile(ctx, { typeId, instanceId, room })— a rootGroup gained one net-new child activity rowonChildInstancesAdded(ctx, { instances })— a rootGroup gained one or more net-new child activity rowsonRoomMemberInstanceMovedOut(ctx, { storeTypeId, instanceId, toRoom, ... })— a member app instance is removed from this room
Hook constraints
- Client hook mirrors are the default. Every backend hook should have a client-config entry so fresh prepared stores can run the same hook optimistically before server results arrive. Hooks must be browser-safe: deterministic, no backend imports, no platform calls on the client, and no server-only table reads/writes unless guarded with
if (ctx.isServer). A hook whose only useful work is server-only should still be represented by a client-safe no-op or shared partial helper, so future optimistic hook inputs do not silently do nothing. - Hooks can write app tables across visibility tiers. Hooks run inside system mutators, but app public tables, server-only tables, and app private tables (including
ctx.privateOfUser(otherUserId)) are app data, not reserved system tables. They still must not write reserved system tables such as$usersor$$systemdirectly — membership is platform-owned (the host seats users at launch; pickers/invites admit the rest), so hooks never add members. Use cross-user private writes sparingly for durable per-user projections tied directly to the hook event; regular mutators or actions are still clearer for user-initiated fan-out. - Hook ctx is loosely typed. The
hooksfield isPartial<SystemHookMap>, so thectxyour handler receives types its tables asRecord<string, JSONValue>rather than your schema's value types. If your hook needs typed reads/writes (anything beyondctx.userId), cast:ctx as unknown as InferMutationContext<TileSchema>. See "Typing helpers extracted from a schema" below.
Typing helpers extracted from a schema
The exported types InferMutatorHandlers<Schema>, InferSchemaTableTypes<Schema>, etc. cover the common cases. For helpers, prefer the schema-level context aliases:
ts
import type {
InferActionContext,
InferMutationContext,
InferReadContext,
} from "poe-tiles-sdk/v1/client.js";
import type { TileSchema } from "./schema";
export type TileReadCtx = InferReadContext<TileSchema>;
export type TileMutationCtx = InferMutationContext<TileSchema>;
export type TileActionCtx = InferActionContext<TileSchema>;InferReadContext<Schema>is the best default for shared data-loading helpers. It is a read-only context and accepts query, mutation, and action ctx values.InferMutationContext<Schema>is for helpers that write or enqueue actions.InferActionContext<Schema>is for helpers that need action-only fields such asctx.mutate.
One situation still needs special care:
Caveat —
InferSchemaTableTypesand singleton tables. For tables defined withsingletonTable(item(key, schema), ...),InferSchemaTableTypes<Schema>["myTable"]returns theSingletonTableBrand & {state: T}bag, not the per-key value union. Reader-sidectx.table("myTable").get("key")correctly narrows to the inner item, so runtime calls work — buttype T = TileTableTypes["myTable"]looks like it works (no error at the extraction step) and then blows up downstream because none of your fields are on the bag type. Derive the singleton item type from the Zod schema instead —type Theme = z.infer<typeof themeSchema>, definingthemeSchemanext to thesingletonTable(...)call.
Casting a hook ctx. Hooks declare their context loosely (see "Hook ctx is loosely typed" above):
ts
hooks: {
onAddUsers: async (ctx, { userId }) => {
await onUserJoin(ctx as unknown as TileMutationCtx, userId);
},
}The cast is safe at runtime — the platform passes the same MutationContext shape; only the static types are loose.
Data visibility — pick a tier before writing schema → data-visibility.md
- Public (default): everyone in the instance sees it
privateOfUser(userId): only that user — write one copy per recipient when roles are assignedserverOnly(): never syncs to clients — expose derived results via actions- Red flag: if you're designing client-side filtering or action-gating to hide data, you picked the wrong tier
System tables — read-only, platform-populated → getting-user-info-of-members.md
Apps can READ but NOT WRITE these $-prefixed tables. The platform populates them.
$users— membership roster. ItemKey = userId. Usectx.table("$users").entries().toArray(), filter!u.removedAtfor current members.$userInfo— profile data (displayName,username,profilePicture). ItemKey = userId.Optimistic first-mount system rows (
$$system.room,$users,$userInfo) carry_pendingSync: trueuntil the first server pull replaces them. Use it when UI needs to distinguish a locally seeded member from one fully admitted by the platform.$$system:createdBy—{storeTypeId, instanceId}of the app instance that originally spawned this one (e.g. viaapps.openChild). First-writer-wins; absent for root apps and instances first reached via cross-store dispatch. Read withawait ctx.table("$$system").get("createdBy").Encouraged: surface the current user's avatar + display name somewhere in the UI (header, sidebar, "playing as ..." chip). It anchors the user inside the app instance — without it, multi-user apps feel ambiguous about identity, especially across device switches.
For multi-player activities, also render the avatars of co-players and opponents where the action is — at the table, on the board, beside each move, score row, and turn indicator — not just the current user. Seeing real faces is what makes a session feel like playing with people rather than against software, and it's a core part of what makes social games rewarding. Enumerate members from
$users, look each up in$userInfo, and design for a missingprofilePicture(initials fallback). Full recipe → getting-user-info-of-members.md.ts// Current user (avatar + name in the header) — recommended for every app const me = await ctx.table("$userInfo").get(ctx.userId); // me?.profilePicture, me?.displayName, me?.username // Another user (rendering an avatar next to their move/message) const other = await ctx.table("$userInfo").get(otherUserId); // Anywhere with ctx — same data, ergonomic helper: import { getUserInfo } from "poe-tiles-sdk/v1/client.js"; const info = await getUserInfo(ctx, userId);
Client lifecycle → client-api-reference.md
- Wait for authoritative data:
await store.waitForBootstrap()(none of these are required — queries/mutations work immediately) - Sortable unique id:
const id = await store.makeUniqueId()— for use as itemKey or input tostore.mutate.* - Pending mutations:
store.getPendingCount(),store.onPendingMutationsChanged((m) => showSaving(m.length > 0)) - Connection status:
store.connectionStatus,store.onConnectionStatusChange(fn),store.isOnline - Error hooks:
store.onBackgroundError(fn)for async sync errors,store.onFailedMutation(fn)for mutation-only failures,store.onDisconnected(fn),store.onSchemaVersionMismatch(fn),store.onLibraryVersionMismatch(fn),store.onDisposed(fn)— kick / auth-failure codes arrive viaonDisconnected - Background-error toast: the SDK auto-shows a generic
BackgroundError:toast and fires areportErrorevent for every background error — exceptno_access_to_this_store, which gets a blocking overlay and neither toast nor report — all with zero app code. There is no per-error opt-out — you cannot mark a specific expected error as suppressible. So keep expected errors out of the throw/background-error path entirely (see the "Don't throw for expected error cases" gotcha). - Teardown:
store.dispose()— closes WebSocket, not reversible
Testing → testing.md
- Harness:
const harness = createPoeTileTestHarness<TileSchema>({ store: { backendConfig: tileBackendConfig } }) - Client:
const { store } = await harness.createClient({ userId: "alice" }) - Multi-client:
- A single mutate-then-peer-query works — the harness propagates synchronously enough.
- For ANY sequence of cross-client mutations where the next step depends on a prior client's writes being server-confirmed, bare
await store.mutate.X(...)is NOT sufficient. This includes final-submitter mutators that aggregate everyone's state (e.g. "all players have submitted → reveal"). - Fix option A: await
.confirmedbetween clients —const r = await alice.mutate.X(...); await r.confirmed; - Fix option B (preferred): gate with
waitForKeyExists/waitForKeyMatch/waitForValue/waitForAllClientsfrompoe-tiles-sdk/v1/test-utils.js. ThewaitFor*helpers also produce descriptive timeout errors. store.query,store.subscribe,waitFor, andwaitForAllClientstake a(tx) => ...reader callback. Do not pass a helper that expects the store/client object; type reusable readers againstInferReadContext<TileSchema>instead. Key/value wait helpers use{ table, key, ... }options.- Full family + example → testing.md
- Observing optimistic state before server sync → testing-network-control.md
- Comparing optimistic vs server-verified values for the same mutation → testing-optimistic-values-and-server-verified-values.md
- Message reordering / concurrent mutations → testing-race-conditions.md
- Disconnect/reconnect, offline retry → testing-network-failures.md
- Deterministic model calls from actions (
createModelStream/createImageGenerator) → testing-bot-streaming.md - Mock
ctx.platform.call(...)in action / guarded server-mutator tests → testing-actions.md - Awaiting mutators that
enqueueAction(callstore.action.X(...)directly) → testing-actions.md - File upload retention and claim inspection (
harness.files.status()/claims()) → file-uploads.md - E2E with TestServer + Playwright blob-frame → testing.md
Gotchas that bite everyone
- Subscriptions fire twice per mutation — once when the optimistic write lands locally, again when the server-confirmed result rebases. Callbacks must be idempotent: don't
mutate/ trigger sounds or animations / push to an array / increment a counter from inside a subscribe callback without dedup. store.userIddoes NOT exist — readctx.userIdinside a subscribe/query/mutator callback, or in UI code callgetCurrentUserId(store)(frompoe-tiles-sdk/v1/client.js)ctx.table(name)does NOT see your own private rows — even reading your own data needsctx.privateOfUser(ctx.userId).table(name). The same applies to subscribes (tx.privateOfUser(tx.userId).table(...)) and tests- Server-only throws are silently rolled back — a
throwinsideif (ctx.isServer) { ... }does NOT reject the outerawait store.mutate.X(...)or its.confirmedpromise. The client's optimistic mutation just disappears and the user sees nothing. Validate with public/own-private data BEFORE theisServergate so the throw runs on the optimistic pass and rejects synchronously. The platform already toasts and reports every background error (see "Background-error toast" above), so usestore.onFailedMutation(...)to repair local state after a rollback, and reach forstore.onBackgroundError(...)only when you replace the platform toast with app-specific handling. See "Validation order" above - Don't throw for expected error cases — early-return, or write the error to the store. A throw in a mutator that survives to the server becomes a
failed_mutationbackground error, which the SDK surfaces as a genericBackgroundError:toast AND fires areportErrorevent. There is no per-error opt-out — you cannot selectively suppress the toast for a single expected error. So route expected failures away fromthrowentirely:- Benign / idempotent / redundant-replay ("already started", "already joined", "already claimed", or an outcome that depends on unreadable server-only data) → early
return. Pending mutations replay on every rebase and can race the same action from another client/device, so these replays are normal, not errors; a throw here also logs"Synced State | Error rebasing mutation"to the user's console on every rebase. - An expected failure the UI must react to (seat taken, hand folded, quota hit, validation the user should see) → don't throw; write an error/status row to the store and let clients subscribe, infer, and render it. This is the multi-user-correct channel: every client sees the state, not just the one caller who awaited
store.mutate.X(...). Throwing would only reach that caller (and only via.confirmed/onBackgroundError) and would spuriously toast. - Throw only for genuinely invalid actions that indicate a bug, never for a state the app is expected to hit in normal use. → mutator-rules.md rule 5
- Benign / idempotent / redundant-replay ("already started", "already joined", "already claimed", or an outcome that depends on unreadable server-only data) → early
await store.mutate.X(...)does NOT wait for server confirmation in cross-client tests — it resolves once the optimistic mutation is in pendingMutations.- When it bites: sequential mutations from different clients where the next step reads server-aggregate state (e.g. an "all-players-submitted → reveal" mutator that scans the public players table). The next client's mutator may run before the prior client's writes are committed, so its scan sees stale
hasSubmitted: falserows and the reveal never fires. - Symptom: tests pass with 2 clients (timing happens to win), then fail at 3+ — exactly the "scaled past two players" regression.
- Also bites when verifying a remote client sees a public-flag flip in their own DOM/query — bare
await mutate()won't have flushed by the time you assert. - Fix: prefer
waitForKeyMatch/waitForValuebetween cross-client steps, or at minimumawait r.confirmedafter eachawait client.mutate.X(...).
- When it bites: sequential mutations from different clients where the next step reads server-aggregate state (e.g. an "all-players-submitted → reveal" mutator that scans the public players table). The next client's mutator may run before the prior client's writes are committed, so its scan sees stale
- Hooks can write app private tables for any user - system hooks may update app-owned public, server-only, and private rows, including
ctx.privateOfUser(otherUserId).table(...). They still cannot write reserved system tables such as$usersor$$systemdirectly.onInitcan seed app rows for new root groups by reading$$system.room; it cannot add members —parentRoomUsersis a read-only snapshot and membership is platform-owned. .set()takes{ itemKey, value }, not positional args- Generate IDs +
Date.now()outside mutators, pass as input — mutators run on client + server + rebase - Use explicit values, not toggles — rebase sees current state, so
!current.donecan flip the wrong way - Read-before-write when merging fields — makes the mutator safe as both create and update, and safe to replay
ctx.enqueueActionneeds noisServerguard — it's already a client no-op- Type-only schema import on the client —
client.ts,client-config.ts, data helpers, mutator files, and any other file that ends up in the iframe bundle mustimport type { tileSchema }, neverimport { tileSchema }. Pulling the schema in as a value dragspoe-tiles-sdk/v1/backend.jsinto the frontend, which requiresnode:async_hooks(via the recorder package) and the build fails withModule "node:async_hooks" has been externalized for browser compatibility. The frontend module count typically jumps 10× when this happens. If you need a runtime constant in both schema and UI, put it in a separatesynced-store/constants.ts(no zod, no SDK imports) and import from there. - Share pure logic — extract anything used by both mutators and UI into a shared module
Constraints & limits → limitations.md
- Size limits, JSON-only data types, kick codes, last-writer-wins, optimistic-lock retries
- Read it before designing a table that accumulates: nothing caps total store size, rows per table, or always-loaded bytes. Capping data growth is the app's job.
- Per-write limits are enforced on the client AND the server: keys up to 2,048 code units of
tableName:itemKey, values up to 1 MiB ofJSON.stringifyoutput. A whole push frame can contain multiple writes and is capped at 5 MiB, so split large payloads across keys and pushes rather than storing one big value.