Appearance
Schema Migrations
Increment APP_SCHEMA_VERSION when you change your schema. The version lives in its own constant file (set up from day zero — see api-patterns.md) so client and server read the same value without bundling Zod on the client:
app-schema-version.ts ← export const APP_SCHEMA_VERSION = 2;
schema.ts ← uses APP_SCHEMA_VERSION in defineSchema()
client-config.ts ← uses APP_SCHEMA_VERSION in defineClientConfig()When versions mismatch without a migration, the client clears local data, fires onSchemaVersionMismatch, and disposes.
Defining a Migration
Provide migrations for each version step:
typescript
import { defineMigration } from "poe-tiles-sdk/v1/backend.js";
import { APP_SCHEMA_VERSION } from "./app-schema-version";
const migration1to2 = defineMigration(v1Mutators, v2Mutators, {
migrateData: async (ctx) => {
// Transform existing data
const items = await ctx.table("todos").scan().entries().toArray();
for (const [key, value] of items) {
await ctx.table("todos").set({
...key,
value: { ...value, priority: 0 },
});
}
},
migratePendingMutation: {
// Transform in-flight mutations from old clients
addTodo: (args, emit) => {
emit("addTodo", { ...args, priority: 0 });
},
},
});
const schema = defineSchema({
schemaVersion: APP_SCHEMA_VERSION, // bumped to 2 in app-schema-version.ts
migrations: { "1to2": migration1to2 },
// ...
});Pending Mutation Handlers
Each mutation handler in migratePendingMutation can:
- Transform args — call
emit()with modified input - Rename — emit a different mutation name
- Drop — don't call
emit() - Expand — call
emit()multiple times to produce several mutations from one
Testing Migrations
Cover two things on every non-trivial migration: data migration end-to-end through the harness, and pending-mutation replay as a direct unit test.
When migrations run
migrateData runs server-side as part of runMutations — i.e. when a client pushes a mutation. A pure pull does not trigger migrations; the server returns data tagged with whatever schemaVersion is currently stored. So a data-migration test must:
- Seed an instance at the old
schemaVersionwith old-shape patches. - Open a client at the new
schemaVersion. - Issue any mutation through the client to force the upgrade.
- Assert the rewritten state.
Mock-based unit tests that fake the ctx object can pass while the migration silently misbehaves under the real backend (e.g. wrong shape passed to ctx.table().set(), mishandled EntryKey vs string for itemKey). Driving the migration through the real production code path catches these.
harness.seedInstance(...)
Both createPoeAppTestHarness (single-app) and createPoeMultiAppTestHarness (multi-app) expose seedInstance. It bypasses authorize, mutators, hooks, and broadcasting — patches go directly into KV at the schema version you specify.
typescript
await harness.seedInstance({
patches: [
{
op: "set",
sortKey: "item/seeded",
tableName: "items",
itemKey: "todo-1",
// Shape from a previous schema version — `migrateData` will rewrite it.
value: {
id: "todo-1",
text: "old-shape todo",
completed: false,
order: 1, // dropped in v4
createdAt: 1,
updatedAt: 1,
},
},
],
schemaVersion: 3, // pre-migration version
});Single-app harness defaults the instance to the harness's own (storeTypeId, instanceId); the multi-app variant takes both as named arguments:
typescript
await multiHarness.seedInstance({
storeTypeId: "todo-list",
instanceId: "room-1",
schemaVersion: 3,
patches: [/* ... */],
});The caller is responsible for ensuring patches are consistent with the seeded schemaVersion — there is no validation. If you seed nonsense, the migration will see nonsense.
End-to-end data-migration test
typescript
import { test, expect } from "bun:test";
import { createPoeMultiAppTestHarness } from "poe-tiles-sdk/v1/test-utils.js";
import { todoClientConfig, todoBackendConfig } from "@poe-tile/todo-list";
test("v3 → current migrates `order` to `sortKey`", async () => {
const harness = createPoeMultiAppTestHarness({ backend: apiHarness });
await harness.registerRootApp({
typeId: "manager",
clientConfig: managerClientConfig,
backendConfig: managerBackendConfig,
});
await harness.registerApp({
typeId: "todo-list",
clientConfig: todoClientConfig,
backendConfig: todoBackendConfig,
});
// 1. Seed at the old schema version, before any client connects.
await harness.seedInstance({
storeTypeId: "todo-list",
instanceId: "room-1",
schemaVersion: 3,
patches: [
{
op: "set",
tableName: "items",
itemKey: "v3-todo",
value: {
id: "v3-todo",
text: "from v3",
completed: false,
order: 1,
createdAt: 1,
updatedAt: 1,
},
},
],
});
// 2. Open a client at the current (newer) schema version.
const root = harness.createRoot({ userId: "alice" });
const child = await root.mountChild({
typeId: "todo-list",
clientConfig: todoClientConfig,
instanceId: "room-1",
});
await child.poe.store.waitForServerData();
// 3. Issue a mutation to drive the schema upgrade.
const { confirmed } = await child.poe.store.mutate.setTodo({
id: "trigger",
text: "trigger migrations",
completed: false,
createdAt: 2,
updatedAt: 2,
sortKey: "item/trigger",
});
await confirmed;
// 4. Assert the seeded row was rewritten by `migrateData`.
const item = await child.poe.store.query((tx) =>
tx.table("items").get("v3-todo"),
);
expect(item?.sortKey).toBeDefined(); // v3→v4 added `sortKey`
expect(item?.text).toBe("from v3");
harness.dispose();
});Patterns worth covering
When evolving schemas with non-trivial migrateData, write at least one test for each of:
- Old → current chain. Seed at the lowest schema version your app's data ever ran at and drive forward. Catches missing chain links, ordering bugs, and accumulated rewrites that only break across multiple steps.
- Field rename / drop. Seed with the old field name, assert the new field is set and the old one is gone (when the migration is meant to strip it).
- Storage relocation. If a
migrateDatastep deletes from one storagesortKeyand re-inserts at another (e.g. moving from default""to"item/{uuid}"), seed at the old storage location and assert the row is queryable at the new location after the migration. - Ordering preservation. If the old shape carries an ordering field (e.g. numeric
order) that the migration converts to asortKey, seed several items out of insertion order and assert the post-migrationsortKeyordering matches the originalorderordering. - Idempotent skip. Seed a row that already matches the new shape (e.g. has the new field, lacks the legacy field) at the old schema version. Assert the migration leaves it untouched.
Pending-mutation replay
Each migratePendingMutation handler is a pure function from (args, emit) to emitted mutations. Call it directly with synthetic args and an emit spy — no harness needed:
typescript
import { test, expect } from "bun:test";
import { migration1to2 } from "./migrations";
test("addTodo gains priority on replay", () => {
const emitted: { name: string; args: unknown }[] = [];
migration1to2.migratePendingMutation!.addTodo!(
{ id: "t1", text: "buy milk" },
(name, args) => emitted.push({ name, args }),
);
expect(emitted).toEqual([
{ name: "addTodo", args: { id: "t1", text: "buy milk", priority: 0 } },
]);
});Cover rename, drop (no emit call), and expand (multiple emit calls) the same way.
Common pitfalls
for (const [key, value] of entries)—keyis anEntryKey, not a string.ctx.table(...).scan().entries()yields[EntryKey, JSONValue]whereEntryKey = { sortKey, itemKey }. Bothsetanddeleteacceptstring | EntryKey, so the natural patterns areset({ ...key, value })anddelete(key)— no manual extraction needed. Never cast withkey as unknown as string: it bypasses the type system, and against older platform versions it stored"[object Object]"as the literalitemKeyand corrupted the row.- Migrations don't run on pull. A test that just opens a client and reads will see data at the stored
schemaVersion, not the client's target. You must push a mutation to driverunMutationsand the schema upgrade. migrateDataand the row'svalue.sortKeyare not the same as the storagesortKey. Some apps store a fractional-indexsortKeyinside the row's value (used for client-side ordering) and use a different sort key as the KV storage key (used for scan ordering). Be explicit about which one you mean; reread the schema before writing the migration'sset(...)call.
Reference
harness.seedInstance(opts)API:storeTypeId: string(multi-app only) — the app whose instance you're seeding.instanceId: string(multi-app only) — the instance to seed.schemaVersion?: number— the version stored on KV after the seed. Migrations from this version forward will run on the next push.codeVersionId?: string | null— optional code-version pin.patches: Patch[]— KV patches to write directly. Bypass authorize/mutators/hooks/broadcasting.
Patchshape (from@synced-store/shared/protocol):typescripttype PatchSet = { op: "set"; tableName: string; itemKey: string; sortKey?: string; value: JSONValue; }; type PatchDel = { op: "del"; tableName: string; itemKey: string; sortKey?: string; };