Skip to content

Synced-Store API Patterns

Table of Contents

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 queryFn reference changes; keeps previous data until new results arrive
  • Accepts null for 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";