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. - WARNING: 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.
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 Poe search / MCP tools → searchable-tables.md.
- Bump
TILE_SCHEMA_VERSIONonly for backwards-incompatible changes that require a migration. DO NOT bump it merely when adding a new mutator or a new field on a table → 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 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 stream = await ctx.platform.call("poe.botStream.open", {
botName: "GPT-4o-mini",
queryRequest: {
version: "1.0",
type: "query",
query: [{ role: "user", content: `Suggest a todo after ${existing.length} existing todos` }],
user_id: "",
conversation_id: crypto.randomUUID(),
message_id: crypto.randomUUID(),
},
});
await stream.cancel(); // parse the stream in real AI-driven actions
const text = "Water the plants";
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; guarded server-side mutator calls are discouraged, must be idempotent/read-only because optimistic-lock conflicts can retry the server attempt, and should only be used for short one-off work → 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.
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 { createPoe, PostMessageEnvironment } from "poe-tiles-sdk/v1/client.js";
import { tileClientConfig } from "../../synced-store/client-config";
const Poe = createPoe({ environment: new PostMessageEnvironment() });
const store = Poe.setupStore(tileClientConfig);
// render UI with `store` as a prop- 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. All use the normal user-router WebSocket; see tile-creator CLI referenceGenerate 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 large, accumulating tables (advanced)
Most stores don't need this. Reach for a sortKey only when a table accumulates a large, ever-growing amount of linearly-ordered data (e.g. chat messages) and you need cheap range scans or "load more" pagination without ever loading the whole table into memory. A todo list, a settings singleton, or any table whose row count stays small and bounded doesn't need one — a plain scan() over the whole table is simpler and fine.
sortKeyis an ordering-only field, separate fromitemKey(identity). Pass it in.set():ctx.table("events").set({ sortKey, itemKey, value }). Omit it and it defaults to""— scans order by(sortKey, itemKey), so rows without a sortKey sort together, before any explicit one.- Data with no
sortKeyis always eagerly downloaded — it's exempt from pull-window budgeting and syncs to every client regardless ofpullWindows/firstRenderBytes. Data with asortKeyis lazily downloaded — subject to windowing/budget, loaded progressively aspullWindowsandfirstRenderBytesallow. This is why the feature only pays off for large, unbounded tables: giving rows asortKeyis what makes them lazy in the first place. - Generate sort keys with
ctx.getNextSortKey({ namespace })inside a mutator — it returns a monotonically increasing{namespace}/{number}key, safe under concurrent and offline writes. Don't hand-roll aDate.now()-based sort key: clock skew across clients/devices can produce collisions or out-of-order keys. - Existing
itemKey-prefix scans are unaffected.sortKeyanditemKeyprefix filters are independent axes ofscan({ prefix })— adding asortKeyto some or all rows in a table does not change the results oftable.scan({ prefix: { itemKey: "..." } }). - Pairs with two schema-level options that make ordering pay off for pull performance once row counts get large, both declared on
defineSchema({ ... }):pullWindows: [{ namespace, direction, cursor? }]— stream a namespace"ascending","descending", or"outward"from an anchor, instead of pulling the whole table on every sync.firstRenderBytes— caps the initial synchronous payload size so first render doesn't block on the full history.
- Only configure
pullWindows/firstRenderBytesonce a table's row count can grow unbounded — they add real complexity and aren't needed for small/bounded tables.
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) => {}) - 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.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 tile 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 tile: membership is platform-owned. The host seats the launcher at genesis (and both users when the tile 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 tile instance gained, lost, or changed its parent rootGrouponAddChildTile(ctx, { typeId, instanceId, room })— a rootGroup gained one net-new child tile rowonChildInstancesAdded(ctx, { instances })— a rootGroup gained one or more net-new child tile 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. Cope: derive the singleton item type from the Zod schema instead —type Theme = z.infer<typeof themeSchema>, definingthemeSchemanext to thesingletonTable(...)call. Tracked as a real bug in the helper; this note is interim guidance.
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 tiles, 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 for every background error (exceptno_access_to_this_store, which gets a blocking overlay) with zero app code, and always fires areportErrorevent. 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 bot streams (
Poe.stream()/Poe.call()) → 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 - 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. To observe server-rejected mutations and other async sync errors from the client, subscribe withstore.onBackgroundError(...); usestore.onFailedMutation(...)only when mutation-specific handling is enough. 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