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).
- WARNING: Synced-store mutations and
externalMutationswork while offline and gracefully sync to the server. Actions andctx.platform.call(...)only run on the server, so actions that require platform calls will not work offline. 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/app-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 APP_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 { APP_SCHEMA_VERSION } from "./app-schema-version";
export const appSchema = defineSchema({
schemaVersion: APP_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 AppSchema = typeof appSchema;- 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
APP_SCHEMA_VERSION(inapp-schema-version.ts) with a migration when existing data exists → schema-migrations.md.
mutators.ts — shared client + server handlers
ts
import type { InferMutatorHandlers, InferSchemaTableTypes } from "poe-tiles-sdk/v1/client.js";
import type { AppSchema } from "./schema";
export type AppTableTypes = InferSchemaTableTypes<AppSchema>;
export type Todo = AppTableTypes["todos"];
export const appMutatorHandlers: InferMutatorHandlers<AppSchema> = {
// `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".
setTodo: async (ctx, input) => {
const existing = await ctx.table("todos").get(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 });
},
removeTodo: async (ctx, input) => {
await ctx.table("todos").delete(input.id);
},
};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 { AppSchema } from "./schema";
export const appActions: InferActionHandlers<AppSchema> = {
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 — pass service names like"poe.botStream.open","blob.put","env.get"→ 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 { appSchema } from "./schema"; // type-only!
import { appMutatorHandlers } from "./mutators";
import { APP_SCHEMA_VERSION } from "./app-schema-version";
export const appClientConfig = defineClientConfig<typeof appSchema>({
mutators: appMutatorHandlers,
schemaVersion: APP_SCHEMA_VERSION,
});ts
// synced-store/backend-config.ts — server-only
import { defineBackendConfig } from "poe-tiles-sdk/v1/backend.js";
import { appSchema } from "./schema";
import { appMutatorHandlers } from "./mutators";
// import { appActions } from "./actions"; // add when you introduce server-only handlers
export const appBackendConfig = defineBackendConfig({
schema: appSchema,
mutators: appMutatorHandlers,
// actions: appActions, // uncomment + import once actions.ts exists
});ts
// app/src/entry.tsx — the only place that calls setupStore
import { createPoe, PostMessageEnvironment } from "poe-tiles-sdk/v1/client.js";
import { appClientConfig } from "../../synced-store/client-config";
const Poe = createPoe({ environment: new PostMessageEnvironment() });
const store = Poe.setupStore(appClientConfig);
// render UI with `store` as a prop- Type the client:
type AppStoreClient = InferSyncedStoreClient<AppSchema>. - 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, optimistic - Await server confirmation:
const { confirmed } = await store.mutate.setTodo({ ... }); await confirmed - Generate IDs/timestamps HERE (at the call site), pass as input:
id: await store.makeUniqueId(),updatedAt: Date.now() - See
mutators.tsexample above for handler-side rules (explicit values not toggles, read-before-write, etc.)
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; requires simpleUnread({ clearOn: "active" }) // and bumps each non-caller recipient's // app-owned unread count. Omit for a // preview/sortKey refresh only. // 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. Server-side only: appends one announcement row to the chat // room resolved from this store's pinned $$system/room. No caller-provided // chat id. Reusing messageId from the same source app is idempotent; // reusing one owned by another app/human message is rejected by chat. 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 startGame mutator that validates role coverage with public state before doing any server-only randomness:
typescript
startGame: 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 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.
Backend hooks — react to membership / permission changes
Declared in defineBackendConfig({ hooks: { ... } }). Run on the server within the same atomic transaction as the system mutator that fired them. Receive a MutationContext — treat them like mutators (read + write tables, follow rebase-safe rules). If a creator needs onAddUser-seeded rows before the first server pull, also pass the same client-safe handler to defineClientConfig({ hooks }); the client runs onAddUser optimistically only for brand-new creator launches (is-new="true"), then drops that overlay when server data arrives.
ts
export const appBackendConfig = defineBackendConfig({
schema: appSchema,
mutators: appMutatorHandlers,
actions: appActions,
hooks: {
onAddUser: async (ctx, { userId }) => {
await ctx.table("scores").set({ itemKey: userId, value: { userId, score: 0 } });
},
onRemoveUser: async (ctx, { userId }) => { /* cleanup */ },
onGrantPermission: async (ctx, { userId, permission }) => { /* e.g. log audit row */ },
onRevokePermission: async (ctx, { userId, permission }) => { /* e.g. tear down role-specific state */ },
},
});onAddUser(ctx, { userId })— user joined the instanceonRemoveUser(ctx, { userId })— user removedonGrantPermission(ctx, { userId, permission })— permission grantedonRevokePermission(ctx, { userId, permission })— permission revokedonAddAppInstanceToRoom(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)
Hook constraints
- Client hook mirrors must be browser-safe. Only mirror hooks into
defineClientConfig({ hooks })when they are deterministic and avoid server-only APIs unless guarded withif (ctx.isServer). Mirrored hooks are optimistic seed data, not a pushable client mutation. - 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$$systemunless the system mutator already wrote that table in the same operation. 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 Parameters<InferMutatorHandlers<AppSchema>["someMutator"]>[0]. 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. Two situations need a derived type:
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 = AppTableTypes["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.
Helpers that take ctx as a parameter. MutationContext<Schema> doesn't compile — the underlying type takes three generic params (actions, table types, system tables), not a single schema. Derive the typed context from one of your handlers:
ts
import type { InferMutatorHandlers } from "poe-tiles-sdk/v1/client.js";
import type { AppSchema } from "./schema";
// Pick any mutator name from your schema for the indexer.
type AppMutators = InferMutatorHandlers<AppSchema>;
export type AppMutationCtx = Parameters<AppMutators["setTodo"]>[0];
async function recomputeViews(ctx: AppMutationCtx, ...) {
// ctx.table("todos") is fully typed here.
}Casting a hook ctx. Hooks declare their context loosely (see "Hook ctx is loosely typed" above):
ts
hooks: {
onAddUser: async (ctx, { userId }) => {
await onUserJoin(ctx as unknown as AppMutationCtx, 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.$$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.
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.onFailedMutation(fn),store.onDisconnected(fn),store.onSchemaVersionMismatch(fn),store.onLibraryVersionMismatch(fn),store.onDisposed(fn)— kick / auth-failure codes arrive viaonDisconnected - Teardown:
store.dispose()— closes WebSocket, not reversible
Testing → testing.md
- Harness:
const harness = createPoeAppTestHarness<AppSchema>({ store: { backendConfig: appBackendConfig } }) - 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. - 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 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 from the client, subscribe withstore.onFailedMutation(...). See "Validation order" above 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 introduce writes to reserved system tables such as$usersor$$systemunless the system mutator already touched that table .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, and any other file that ends up in the iframe bundle mustimport type { appSchema }, neverimport { appSchema }. Same formutators.ts: import only types from./schema. 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