Skip to content

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 externalMutations work while offline and gracefully sync to the server. Actions and ctx.platform.call(...) only run on the server, so actions that require platform calls will not work offline. Effects from ctx.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-key singletonTable(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 (in app-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, NOT done: !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 or undefined
  • 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
  • aroundCursor is client-only (queries / subscribeToTable). Mutators and actions calling it on the server throw with a pointer to the workaround: compose two cursor + limit scans manually if you really need server-side context around an anchor
  • Current user inside a mutator/query: ctx.userId (NOT available on store.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.ts example 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 (see docs/solidjs-best-practices.md)
  • React hook with loading state: const { data, isLoading } = useLiveQuery(store, (ctx) => ctx.table("todos").entries().toArray()) from poe-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) — public

  • ctx.privateOfUser(userId).table(name) — per-user private (throws on client if userId !== ctx.userId)

  • ctx.serverOnly().table(name) — server-only (throws on client; guard writes with if (ctx.isServer))

  • ctx.isServer — branch for pending indicators (isPending: !ctx.isServer) or server-only writes. Avoid otherwise

  • ctx.enqueueAction("name", input) — call UNCONDITIONALLY; no-op on client, runs on server after commit. In tests, call store.action.<name>(...) directly after the mutator to deterministically wait for the action to run — don't rely on setTimeout/tick. → testing-actions.md

  • Skip optimistic entirely when outcome depends on unreadable data: if (!ctx.isServer) return

  • Sending 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 via postToChat.

    ts
    await 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.ts example 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 instance
  • onRemoveUser(ctx, { userId }) — user removed
  • onGrantPermission(ctx, { userId, permission }) — permission granted
  • onRevokePermission(ctx, { userId, permission }) — permission revoked
  • onAddAppInstanceToRoom(ctx, { storeTypeId, instanceId }) — an app instance was registered as a member of this room (fires on the room store after a new $room_member_instances row 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 with if (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 $users or $$system unless 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 hooks field is Partial<SystemHookMap>, so the ctx your handler receives types its tables as Record<string, JSONValue> rather than your schema's value types. If your hook needs typed reads/writes (anything beyond ctx.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 — InferSchemaTableTypes and singleton tables. For tables defined with singletonTable(item(key, schema), ...), InferSchemaTableTypes<Schema>["myTable"] returns the SingletonTableBrand & {state: T} bag, not the per-key value union. Reader-side ctx.table("myTable").get("key") correctly narrows to the inner item, so runtime calls work — but type 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>, defining themeSchema next to the singletonTable(...) 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 assigned
  • serverOnly(): 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. Use ctx.table("$users").entries().toArray(), filter !u.removedAt for 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. via apps.openChild). First-writer-wins; absent for root apps and instances first reached via cross-store dispatch. Read with await 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 to store.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 via onDisconnected
  • 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 .confirmed between clients — const r = await alice.mutate.X(...); await r.confirmed;
    • Fix option B (preferred): gate with waitForKeyExists / waitForKeyMatch / waitForValue / waitForAllClients from poe-tiles-sdk/v1/test-utils.js. The waitFor* 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 (call store.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.userId does NOT exist — read ctx.userId inside a subscribe/query/mutator callback, or in UI code call getCurrentUserId(store) (from poe-tiles-sdk/v1/client.js)
  • ctx.table(name) does NOT see your own private rows — even reading your own data needs ctx.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 throw inside if (ctx.isServer) { ... } does NOT reject the outer await store.mutate.X(...) or its .confirmed promise. The client's optimistic mutation just disappears and the user sees nothing. Validate with public/own-private data BEFORE the isServer gate so the throw runs on the optimistic pass and rejects synchronously. To observe server-rejected mutations from the client, subscribe with store.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: false rows 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 / waitForValue between cross-client steps, or at minimum await r.confirmed after each await client.mutate.X(...).
  • 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 $users or $$system unless 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.done can flip the wrong way
  • Read-before-write when merging fields — makes the mutator safe as both create and update, and safe to replay
  • ctx.enqueueAction needs no isServer guard — it's already a client no-op
  • Type-only schema import on the clientclient.ts, client-config.ts, and any other file that ends up in the iframe bundle must import type { appSchema }, never import { appSchema }. Same for mutators.ts: import only types from ./schema. Pulling the schema in as a value drags poe-tiles-sdk/v1/backend.js into the frontend, which requires node:async_hooks (via the recorder package) and the build fails with Module "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 separate synced-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