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).

This file is a snippet index. Load the linked reference when you need detail.

schema.ts — the contract

ts
import { z } from "zod";
import { defineSchema, table, singletonTable, item } from "poe-apps-sdk/v1/backend.js";

// schemaVersion lives here on day zero. Once you start migrating data,
// extract it (and `migrations`) into `app-schema-version.ts` + `migrations.ts`
// so both client + server can read the constant without importing the schema.
export const appSchema = defineSchema({
  schemaVersion: 1,
  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 schemaVersion with a migration when existing data exists → schema-migrations.md.

mutators.ts — shared client + server handlers

ts
import type { InferMutatorHandlers, InferSchemaTableTypes } from "poe-apps-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-apps-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 { apiKey } = await ctx.platform.call("getPoeApiKey", {});
    // ... LLM call using apiKey ...
    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 "getPoeApiKey", "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-apps-sdk/v1/client.js";
import type { appSchema } from "./schema"; // type-only!
import { appMutatorHandlers } from "./mutators";

export const appClientConfig = defineClientConfig<typeof appSchema>({
  mutators: appMutatorHandlers,
  schemaVersion: 1,
});
ts
// synced-store/backend-config.ts — server-only
import { defineBackendConfig } from "poe-apps-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-apps-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-apps-sdk/v1/client.js (UI), poe-apps-sdk/v1/backend.js (server), poe-apps-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 }) — equivalent to table.scan({ cursor: "$last", aroundCursor: { before: 5, after: 0 } }) and arrives in ascending order without manual reversal
  • 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-apps-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-apps-sdk/v1/react
  • Subscribe to a key prefix: store.subscribe((ctx) => ctx.table("users").scan({ prefix: { itemKey: "alice" } }).entries().toArray(), (entries) => {})
  • 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) and optionally enqueues an OS push.

    ts
    await notifyActivity(ctx, {
      preview: string,           // sidebar preview text (e.g. last message)
      previewTimestamp: number,  // bumps the space in the recents list
      markUnread: boolean,       // typically false for the sender's own actions
    
      // 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
      },
    });

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.
},

Working example: poe-apps/codenames/synced-store/mutators.tsstartGame throws on missing roles before its if (!ctx.isServer) return; randomness block.

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).

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

Hook constraints

  • Hooks can't write to other users' private tables. A hook firing for user A cannot do ctx.privateOfUser(userB).table("x").set(...) — the platform throws SystemTableTamperError because hooks run inside a system mutator and $$pu/<userId>/... keys are reserved. Public writes and the hook-target user's own private writes (ctx.privateOfUser(userId).table(...)) are fine. If you need cross-user fan-out (e.g. seed a state-dependent view for a late joiner), do it from a regular mutator or trigger an action via ctx.enqueueAction(...).
  • 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-apps-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.

    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-apps-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-apps-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

  • store.userId does NOT exist — read ctx.userId inside a subscribe/query/mutator callback, or in UI code call getCurrentUserId(store) (from poe-apps-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't write to other users' private tables — system hooks (onAddUser, etc.) throw SystemTableTamperError on ctx.privateOfUser(otherUserId).table(...) writes. Public writes and the hook-target user's own private writes are fine; cross-user fan-out needs to come from a regular mutator
  • .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-apps-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.
  • poe-apps-sdk must be workspace:* for in-repo apps; package name must be @poe-app/[name]. bunx app-platform apps init --committed sets this correctly; without --committed you get the published-tarball URL.
  • Preact/SolidJS apps need exclusion from root tsconfig.json (root assumes React JSX — see poe-apps/chess). The new app's own tsconfig.json should extends: "../../tsconfig.json" plus its own exclude: ["node_modules", "dist"] so the app's bun run type-check still picks up its files when the root excludes them.
  • Required scripts in package.json: lint and format:check (enforced by scripts/check-package-scripts.ts).
  • Share pure logic — extract anything used by both mutators and UI into a shared module
  • Import paths differ by location — apps use poe-apps-sdk/*; platform/tests use @synced-store/*

Constraints & limits → limitations.md

  • Size limits, JSON-only data types, kick codes, last-writer-wins, optimistic-lock retries