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).
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-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
schemaVersionwith 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, 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-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 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 })— equivalent totable.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 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-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.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-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)— 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) and optionally enqueues an OS push.tsawait 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.ts — startGame 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.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).
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 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 throwsSystemTableTamperErrorbecause 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 viactx.enqueueAction(...). - 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-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 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.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 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-apps-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
store.userIddoes NOT exist — readctx.userIdinside a subscribe/query/mutator callback, or in UI code callgetCurrentUserId(store)(frompoe-apps-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't write to other users' private tables — system hooks (
onAddUser, etc.) throwSystemTableTamperErroronctx.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.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-apps-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. poe-apps-sdkmust beworkspace:*for in-repo apps; package name must be@poe-app/[name].bunx app-platform apps init --committedsets this correctly; without--committedyou get the published-tarball URL.- Preact/SolidJS apps need exclusion from root
tsconfig.json(root assumes React JSX — seepoe-apps/chess). The new app's owntsconfig.jsonshouldextends: "../../tsconfig.json"plus its ownexclude: ["node_modules", "dist"]so the app'sbun run type-checkstill picks up its files when the root excludes them. - Required scripts in
package.json:lintandformat:check(enforced byscripts/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