Appearance
Schema Migrations
This guide explains how to author and test schema migrations end-to-end.
When you bump schemaVersion and add a migrateData step, 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). The harness's seedInstance(...) helper lets you simulate "an existing instance at the old schema version with realistic data, then bring up a client at the new version" so the migration runs through the real production code path.
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 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.
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 migration test
typescript
import { test, expect } from "bun:test";
import { createPoeMultiAppTestHarness } from "poe-apps-sdk/v1/test-utils.js";
import { todoClientConfig, todoBackendConfig } from "@poe-app/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.
Common pitfalls
for (const [key, value] of entries)—keyis anEntryKey, not a string.ctx.table(...).scan().entries()yields[EntryKey, JSONValue]whereEntryKey = { sortKey, itemKey }. If you needitemKeyas a string forctx.table(...).set({ itemKey, ... }), usekey.itemKey. Castingkey as unknown as stringwill write"[object Object]"as the storageitemKeyand corrupt the row. (ctx.table(...).delete(key)acceptsstring | EntryKey, so passing theEntryKeystraight through is fine for deletes.)- 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; };