Appearance
Synced-Store API Patterns
Table of Contents
- Schema Definition
- Mutator Context API
- Client Config & Backend Config
- Entry Point Wiring
- Framework Bindings
- Package Conventions
Schema Definition
typescript
import { z } from "zod";
import { defineSchema, singletonTable, table } from "poe-apps-sdk/v1/backend.js";
export const appSchema = defineSchema({
schemaVersion: 1,
tables: {
game: {
schema: table(z.object({
id: z.string(),
status: z.string(),
createdAt: z.number(),
})),
},
moves: {
schema: table(z.object({
id: z.string(),
moveIndex: z.number(),
playedBy: z.string(),
})),
},
},
mutators: {
makeMove: {
description: "Record a player move",
input: z.object({ from: z.string(), to: z.string() }),
},
resetGame: {
description: "Reset the game",
input: z.object({}),
},
},
});
export type AppSchema = typeof appSchema;Single-row tables: Use table(schema) with a fixed itemKey like "game" for records that always have exactly one row (e.g., game state). To expose a typed settings bag where keys carry distinct value types, use singletonTable(item(key, schema), ...) — see singleton-tables.md. There is no no-key singletonTable(schema) form.
Mutator Context API
typescript
import type { InferMutatorHandlers, InferSchemaTableTypes } from "poe-apps-sdk/v1/client.js";
export type AppTableTypes = InferSchemaTableTypes<AppSchema>;
export type GameRecord = AppTableTypes["game"];
export const appMutatorHandlers: InferMutatorHandlers<AppSchema> = {
makeMove: async (ctx, input) => {
// READ a single record
const game = await ctx.table("game").get("game"); // returns value | undefined
// READ all entries
const entries = await ctx.table("moves").entries().toArray(); // [key, value][]
// WRITE a record — itemKey + value required
await ctx.table("moves").set({
itemKey: "move-1",
value: { id: "move-1", moveIndex: 0, playedBy: ctx.userId },
});
// DELETE a record
await ctx.table("moves").delete("move-1");
// Current user
const userId: string = ctx.userId;
// Server check
if (ctx.isServer) { /* server-only logic */ }
},
};Client Config & Backend Config
Client (synced-store/client-config.ts):
typescript
import { defineClientConfig } from "poe-apps-sdk/v1/client.js";
import type { appSchema } from "./schema"; // type-only import!
import { appMutatorHandlers } from "./mutators";
export const appClientConfig = defineClientConfig<typeof appSchema>({
mutators: appMutatorHandlers,
schemaVersion: 1,
});Backend (synced-store/backend-config.ts):
typescript
import { defineBackendConfig } from "poe-apps-sdk/v1/backend.js";
import { appSchema } from "./schema"; // runtime import
import { appMutatorHandlers } from "./mutators";
export const appBackendConfig = defineBackendConfig({
schema: appSchema,
mutators: appMutatorHandlers,
actions: {},
});Backend entry (app/src/backend.ts):
typescript
import { appBackendConfig } from "../../synced-store/backend-config";
export default appBackendConfig;Entry Point Wiring
Preact (app/src/entry.tsx):
typescript
import { createPoe, PostMessageEnvironment } from "poe-apps-sdk/v1/client.js";
import { render } from "preact";
import { appClientConfig } from "../../client";
import { App } from "../../ui/App";
const environment = new PostMessageEnvironment();
const Poe = createPoe({ environment });
const store = Poe.setupStore(appClientConfig);
render(<App store={store} />, document.getElementById("root")!);React: Same pattern but use createRoot(root).render(<App store={store} />) and add registerPoeAppElement(environment).
SolidJS: Same pattern but use render(() => <App store={store} />, root).
Framework Bindings
Two optional packages provide framework-specific hooks on top of the core store client:
poe-apps-sdk/v1/react — React hook
Apps import from the poe-apps-sdk re-export (matches the gotcha in SKILL.md: "apps use poe-apps-sdk/*; platform/tests use @synced-store/*"). The underlying package is @synced-store/react, but apps should not depend on it directly.
typescript
import { useLiveQuery } from "poe-apps-sdk/v1/react";
function MessageList({ store }: { store: AppStoreClient }) {
const { data: messages, isLoading } = useLiveQuery(
store,
(tx) => tx.table("messages").entries().toArray(),
);
if (isLoading) return <div>Loading...</div>;
return <ul>{messages.map(([, m]) => <li key={m.id}>{m.text}</li>)}</ul>;
}- Returns
{ data: T | undefined, isLoading: boolean } - Resubscribes when
queryFnreference changes; keeps previous data until new results arrive - Accepts
nullfor the store parameter (returns loading state)
@synced-store/solid — SolidJS integration
Provides subscribeToTable on the store client for fine-grained reactivity:
typescript
const unsub = store.subscribeToTable("items", (entries, changes) => {
// entries: full table snapshot as [[key, value], ...]
// changes: { added: string[], modified: string[], removed: string[] }
});Use with createStore + reconcile() to preserve DOM nodes across updates (see ui-patterns.md).
When to use which: Most Poe apps use store.subscribe() (built into the core client) directly. Use the framework bindings when you want useLiveQuery's loading state management (React) or subscribeToTable's change diffs (SolidJS animations).
Package Conventions
json
{
"name": "@poe-app/my-app",
"version": "0.0.1",
"type": "module",
"dependencies": { "poe-apps-sdk": "workspace:*" },
"scripts": {
"build": "vite build",
"test": "bun test tests/mutators.test.ts",
"test:playwright": "bun run build && bunx playwright test",
"lint": "biome lint --error-on-warnings .",
"format:check": "biome format ."
}
}Re-exports (client.ts):
typescript
import type { InferSyncedStoreClient } from "poe-apps-sdk/v1/client.js";
import type { AppSchema } from "./synced-store/schema";
export type { AppSchema, appSchema } from "./synced-store/schema"; // type-only!
export type AppStoreClient = InferSyncedStoreClient<AppSchema>;
export { appMutatorHandlers } from "./synced-store/mutators";
export { appClientConfig } from "./synced-store/client-config";