Appearance
Schema Migrations
The rule you must not break
Once an activity has been published, any backwards-incompatible schema change requires a migration, shipped and tested in the same change. This is the single most-missed step. A bump without a migration is not a soft reset — it forces every existing client to clear its local data and dispose, which players experience as their in-progress game or room being wiped.
If you have changed schema.ts, ask: would data written under the OLD schema still be valid under the NEW schema?
- No → backwards-incompatible → bump
TILE_SCHEMA_VERSIONAND write the migration. You are here if you renamed a field, removed a field, changed a field's type or its allowed values, made an optional field required, changed how you computeitemKey/sortKey, split/merged tables, or changed the shape of any mutation's input. - Yes → backwards-compatible → do NOT bump the version. Adding a brand-new table, a new mutator, or a new optional field (or a field with a default that old rows can omit) does not need a migration. Bumping needlessly would wipe instances for no reason.
- Unsure → treat it as incompatible and write the migration. The cost of an unnecessary migration is a few lines; the cost of a missing one is wiped user data.
Do not stop at bumping the version — the checklist for a live activity is:
- BEFORE you touch
TILE_SCHEMA_VERSION: make sure the parameterized snapshot-fixture test exists and its fixture for the CURRENT version is generated and committed (see Required before you bump — the order is load-bearing, not a style preference). - Bump
TILE_SCHEMA_VERSION. - Add the
migrationsentry withmigrateData(andmigratePendingMutationif input shapes changed). - Seed a test at the OLD version with OLD-shape data and drive the migration (see Testing Migrations — this step is not optional; a migration you have not run against real old data is a guess). Also generate + commit the new version's snapshot fixture, which the bump added to the parameterized test.
- Republish.
Required before you bump: the parameterized snapshot test
An activity whose schema version you are about to bump MUST already have a parameterized test.each snapshot test built on harness.getOrCreateSnapshot(...), with a committed fixture for the current (pre-bump) version. Add that test and commit its fixture FIRST, in its own step, before the bump.
Why the ordering is load-bearing: a fixture for version N can only be produced by code that IS at version N. It is generated by running the real client and server at that version and exporting what they actually persisted — server KV rows and the client device's local cache. Once you have bumped to N+1, today's code cannot faithfully recreate a version-N client, so that fixture is gone forever and the N→N+1 migration has no realistic old data to run against. Generate it while you still can.
This is a different test from the seeded-patch test in Testing Migrations, and you want both:
| Snapshot fixture test | Seeded-patch test | |
|---|---|---|
| Old data comes from | Real old code, recorded once | Hand-written patches |
| Covers | Server KV and the old client's local cache | Server KV only |
| Asserts | The whole chain still restores and accepts today's mutations | The specific rewrite this migration performs |
| Grows | Automatically, one case per bump | You write one per migration |
How the flow works
- The test enumerates every schema version from a checked-in baseline to
TILE_SCHEMA_VERSIONand runs one case per version, so a bump automatically adds a case. - Each case calls
harness.getOrCreateSnapshot(path, create). If the fixture file exists it is loaded; if not,create()runs and writes it. Generation is allowed only for the current version — thecreate()callback throws for any older version, because a missing historical fixture means it was deleted, not that it can be regenerated. - Fixtures must be generated locally and committed.
getOrCreateSnapshotrefuses to create anything whenCIis set and fails the run instead, so an uncommitted fixture is a red CI, never a silently regenerated one. - The restore half is the actual assertion: register the activity,
restoreSyncedStoreSnapshot(snapshot)before any root exists, then connect a client running today's code and push a mutation. That push is what drivesrunMutations→migrateData, so every older fixture re-runs your whole migration chain on every CI run.
Example test
typescript
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import {
ApiTestServer,
createPoeMultiTileTestHarness,
type PoeMultiTileSyncedStoreSnapshot,
} from "poe-tiles-sdk/v1/test-utils.js";
import { tileBackendConfig } from "../synced-store/backend-config";
import { tileClientConfig } from "../synced-store/client-config";
import { TILE_SCHEMA_VERSION } from "../synced-store/tile-schema-version";
const TILE_TYPE_ID = "todo-list";
/** The version the snapshot harness landed at — never lower this, never regenerate below it. */
const SNAPSHOT_BASELINE_SCHEMA_VERSION = 4;
const USER_ID = "todo-snapshot-user";
const CLIENT_ID = "todo-snapshot-client";
const DEVICE_ID = "todo-snapshot-device";
// A real list has a mix of states, not one happy-path item — model both.
const FIXTURE_DONE_TODO_TEXT = "Finished before the snapshot";
const FIXTURE_OPEN_TODO_TEXT = "Still open at snapshot time";
const schemaVersions = Array.from(
{ length: TILE_SCHEMA_VERSION - SNAPSHOT_BASELINE_SCHEMA_VERSION + 1 },
(_, index) => SNAPSHOT_BASELINE_SCHEMA_VERSION + index,
);
describe("todo-list checked-in migration snapshots", () => {
test.each(schemaVersions)(
"schema v%d snapshot restores and accepts today's mutations",
async (schemaVersion) => {
const fixturePath = join(
import.meta.dir,
"fixtures",
`todo-list-v${schemaVersion}.json`,
);
// ---- 1. Load the checked-in fixture, or generate it once at the CURRENT version.
const source = createPoeMultiTileTestHarness({ backend: new ApiTestServer() });
let snapshot: PoeMultiTileSyncedStoreSnapshot;
try {
snapshot = await source.getOrCreateSnapshot(fixturePath, async () => {
if (schemaVersion !== TILE_SCHEMA_VERSION) {
throw new Error(
`Missing historical todo-list snapshot v${schemaVersion}: restore it from version control`,
);
}
await source.registerRootTile({
typeId: TILE_TYPE_ID,
clientConfig: tileClientConfig,
backendConfig: tileBackendConfig,
});
await source.seed.user({ userId: USER_ID });
const root = source.createRoot({
userId: USER_ID,
clientId: CLIENT_ID,
deviceId: DEVICE_ID,
clientConfig: tileClientConfig,
});
await root.poe.store.waitForServerData();
// Write the data a real user of THIS version would have: one item
// they already finished, and one they haven't gotten to yet.
await root.poe.store.mutate.setTodo({
id: "done-todo",
text: FIXTURE_DONE_TODO_TEXT,
done: true,
updatedAt: 1_000,
});
await root.poe.store.mutate.setTodo({
id: "open-todo",
text: FIXTURE_OPEN_TODO_TEXT,
done: false,
updatedAt: 1_000,
});
await root.poe.store.waitForSync();
await root.poe.store.pull();
root.dispose(); // roots must be disposed before exporting their device cache
return source.exportSyncedStoreSnapshot({
stores: [{ storeTypeId: TILE_TYPE_ID, instanceId: USER_ID }],
deviceIds: [DEVICE_ID],
});
});
} finally {
source.dispose();
}
// The fixture really is old data, not silently re-recorded at today's version.
expect(snapshot.stores[0]?.schemaVersion).toBe(schemaVersion);
// ---- 2. Restore it under TODAY's code and drive the migration chain.
const restored = createPoeMultiTileTestHarness({ backend: new ApiTestServer() });
try {
await restored.registerRootTile({
typeId: TILE_TYPE_ID,
clientConfig: tileClientConfig,
backendConfig: tileBackendConfig,
});
await restored.seed.user({ userId: USER_ID });
// MUST happen before any root creates a device.
await restored.restoreSyncedStoreSnapshot(snapshot);
const root = restored.createRoot({
userId: USER_ID,
clientId: CLIENT_ID,
deviceId: DEVICE_ID,
clientConfig: tileClientConfig,
});
await root.poe.store.waitForServerData();
// ---- 3. Immediately after restore, BEFORE any push: migrations have
// NOT run yet (they run on push, not pull) — this is the raw fixture
// data exactly as version N wrote it. Check every row you staged, not
// just the one field today's migration happens to touch.
expect(
await root.poe.store.query((tx) => tx.table("todos").get("done-todo")),
).toMatchObject({ text: FIXTURE_DONE_TODO_TEXT, done: true });
expect(
await root.poe.store.query((tx) => tx.table("todos").get("open-todo")),
).toMatchObject({ text: FIXTURE_OPEN_TODO_TEXT, done: false });
// ---- 4. Push mutations to force `runMutations` -> `migrateData` on
// older fixtures, the way a real user would keep using the tile: they
// finish the item that was open, and add a new one.
await root.poe.store.mutate.setTodo({
id: "open-todo",
text: FIXTURE_OPEN_TODO_TEXT,
done: true,
updatedAt: 2_000,
});
await root.poe.store.mutate.setTodo({
id: "post-restore-todo",
text: "Created after restore",
done: false,
updatedAt: 2_000,
});
await root.poe.store.waitForSync();
await root.poe.store.pull();
// ---- 5. The already-finished item is untouched, the item you just
// finished picked up the change, and the new write landed — all three,
// not just the one this migration was written for.
expect(
await root.poe.store.query((tx) => tx.table("todos").get("done-todo")),
).toMatchObject({ text: FIXTURE_DONE_TODO_TEXT, done: true });
expect(
await root.poe.store.query((tx) => tx.table("todos").get("open-todo")),
).toMatchObject({ text: FIXTURE_OPEN_TODO_TEXT, done: true });
expect(
await root.poe.store.query((tx) => tx.table("todos").get("post-restore-todo")),
).toMatchObject({ text: "Created after restore" });
root.dispose();
} finally {
restored.dispose();
}
},
);
});Working with it
- Model a real user's session, not a minimal probe. This fixture is your one chance to capture what version N's data actually looked like — build it that way. Exercise every table and every mutator a real user of this activity would have touched by now, not just the one field your first migration happens to care about: a returning user's history (a finished match, a past score) AND their in-progress state (a draft, a mid-run save, an unconfirmed selection) if the activity has both. An in-progress row is exactly the shape a careless migration is most likely to mishandle — it is easy to write a migration that only considers "finished" rows and silently corrupts or drops anything mid-flight. "Small" is about row COUNT, not state coverage: a handful of rows spread across every table beats many rows piled into one.
- Assert on every piece of state you staged, immediately after restore AND after the post-restore push. Query every table you wrote to, not only the field the current migration touches, and check both moments: right after
restoreSyncedStoreSnapshot(before any push — migrations have not run yet, so this is the raw fixture data as version N wrote it) and again after the push (confirming the migration's own writes, and everything else, are still exactly what you staged). A fixture whose only assertion is "some row exists" will pass through a migration that mangles a field two versions from now, because nothing ever checked it. - Adding the test to an existing activity: commit it with only the current version's fixture. That baseline constant is the earliest version you will ever be able to prove; it does not reach back before the test existed, and that is expected.
- When you bump: run the suite locally once, commit the newly written
…-v<new>.jsonalongside the migration. Do not hand-edit fixtures — regenerate by deleting the file only if the version is still current.
When to bump the version
Only increment TILE_SCHEMA_VERSION for backwards-incompatible changes that require migrating existing persisted data or pending mutations. Adding a new mutator, a new table, or a new field with a default is backwards-compatible and does not need a bump. 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:
tile-schema-version.ts ← export const TILE_SCHEMA_VERSION = 2;
schema.ts ← uses TILE_SCHEMA_VERSION in defineSchema()
client-config.ts ← uses TILE_SCHEMA_VERSION in defineClientConfig()When you bump the version WITHOUT a matching migration, existing instances reload into the new code, which cannot read the old-shape data still in local storage — so that data is discarded (onSchemaVersionMismatch fires and the client disposes/rebuilds). To a player this looks like their game or room was wiped. This is exactly why a migration is mandatory for any incompatible change to a live activity.
How upgrades roll out to users
Publishing a new version does not instantly upgrade everyone who has the activity open. Understanding the rollout is what lets you test it correctly and set the right expectations with players.
The lifecycle of one upgrade:
- You publish a new version (bumped
TILE_SCHEMA_VERSION+ migration). The new bundle is now the latest published version, but nothing has changed yet for anyone who already has an instance open. - Running instances keep running the OLD code until the server notices the publish. How fast that happens depends on the instance's connection state. An instance open on someone's screen (foregrounded, connected) is not idle at the protocol level — its presence heartbeats count as server activity, so the server notices the new version within about 30 seconds (a server-side version-resolution cache bound) with no user action. An instance in a backgrounded or disconnected tab produces no server activity, so nothing happens while it just sits there.
- For a backgrounded/disconnected instance, the upgrade waits for its next "activity" against the server: opening/reopening the instance, reconnecting after the connection dropped, running an action, or returning the app to the foreground (which triggers a repair pull). At that point the server notices the newer published version.
- The migration runs once, server-side, at that moment. The server runs your
migrateDataover that instance's stored data in a single atomic step and bumps the instance's stored schema version. (This is the same server-push path your migration tests exercise — a pure read/pull without any such trigger does not migrate.) - The still-open old-code client detects the bumped version and prompts the user. For a normal activity the user sees a "Reload required" dialog with a Reload button; tapping it loads the new version against the now-migrated data. (The root manager shell shows a non-dismissible "Update required" modal that upgrades automatically instead.) A user opening the activity fresh after you published gets the new version from the start on a first-time device; a device that opened the activity before may serve its cached previous bundle for that one open (bundle resolution is cache-first for load speed) and shows the same one-tap "Reload required" prompt as soon as it connects.
Why a change you published last night can show up as an upgrade prompt this morning: an instance you left open overnight was backgrounded and its connection dropped (device sleep / backgrounding), so it produced no server activity and never re-checked for the new version (step 2). When you foregrounded the app in the morning, the repair pull ran (step 3), which triggered the migration (step 4) and then the "Reload required" prompt (step 5). It was not a fixed polling delay — the rollout to a backgrounded instance simply waits for the next activity.
How to test and verify the rollout yourself:
- The real test is the automated migration test (below) — seed old-shape data, drive the migration, assert the result. That verifies the migration logic deterministically without waiting on any rollout timing.
- To see the live prompt manually: open an instance on the old version, publish the new version, then reopen / reconnect / foreground that instance to trigger it deterministically (a foregrounded instance would also pick it up on its own within about 30 seconds, but a backgrounded one waits indefinitely). You should get the "Reload required" prompt; tap Reload and confirm the migrated data is intact.
- To try a new build before any player gets it: pin live to its current version first (
poe-tiles tiles auto-advance <tile> off, or Change version in the Release Channels card on the Activity Versions tab), then publish. Use Open preview on the new Version History row to open an isolated version-scoped room. This leaves live unchanged and does not copy live players' saved data. A fresh preview checks the new build, not migration of existing data; use the automated migration test above to prove that old-shape state is preserved.
What to tell your players (expectation-setting):
- New and reopened sessions get the new version right away — either from the start, or (on a device that cached the previous bundle) via the same one-tap "Reload required" prompt on open. Long-lived open sessions get the prompt within about half a minute while foregrounded, or the next time they come back to the app. No player action is needed beyond tapping Reload.
- Because the migration runs once server-side and every client then reloads into matching code, players keep their data across the upgrade — provided you shipped the migration. Skipping the migration is what turns an upgrade into a data wipe.
Defining a Migration
Provide migrations for each version step:
typescript
import { defineMigration } from "poe-tiles-sdk/v1/backend.js";
import { TILE_SCHEMA_VERSION } from "./tile-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: TILE_SCHEMA_VERSION, // bumped to 2 in tile-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
The parameterized snapshot test is the mandatory gate and must already exist before the bump. On top of it, 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.
The minimum required test: seed old data, run the migration against it
Every backwards-incompatible change needs at least one test that seeds an instance with original (pre-migration) data and drives the migration against it. This is the test that catches the mistakes mocks hide. The shape is always the same four steps:
typescript
import { test, expect } from "bun:test";
import { createPoeTileTestHarness } from "poe-tiles-sdk/v1/test-utils.js";
import { tileBackendConfig } from "../synced-store/backend-config";
import { tileClientConfig } from "../synced-store/client-config";
test("migrates old-shape data to the current schema", async () => {
const harness = createPoeTileTestHarness<TileSchema>({
store: { backendConfig: tileBackendConfig },
});
// 1. SEED at the OLD version with OLD-shape rows (the data real users have).
await harness.seed.syncedStoreInstance({
schemaVersion: 1, // the version this data was written under (NOT current)
patches: [
{
op: "set",
tableName: "todos",
itemKey: "todo-1",
value: { id: "todo-1", text: "old-shape row", done: false }, // no `priority` yet
},
],
});
// 2. OPEN a client at the CURRENT version.
const { store } = await harness.createClient({ userId: "alice" });
await store.waitForServerData();
// 3. PUSH any mutation to force `runMutations` → `migrateData`. A pull alone
// will NOT migrate; it returns data tagged with the stored version.
const { confirmed } = await store.mutate.setTodo({
id: "trigger", text: "trigger migration", done: false, updatedAt: Date.now(),
});
await confirmed;
// 4. ASSERT the seeded row was rewritten by the migration.
const migrated = await store.query((tx) => tx.table("todos").get("todo-1"));
expect(migrated?.priority).toBe(0); // new field the migration backfills
expect(migrated?.text).toBe("old-shape row"); // preserved
harness.dispose();
});The single-app createPoeTileTestHarness defaults the seeded instance to the harness's own (storeTypeId, instanceId), so you only pass schemaVersion + patches. You must pass the old schemaVersion explicitly — it defaults to the app's current version (which assumes the patches are already current-shape and would run no migration). The multi-app createPoeMultiTileTestHarness variant takes storeTypeId / instanceId too — see the end-to-end example below. The full API and the additional patterns worth covering are in the sections that follow.
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.seed.syncedStoreInstance(...)
Both createPoeTileTestHarness (single-app) and createPoeMultiTileTestHarness (multi-app) expose seed.syncedStoreInstance. It bypasses authorize, mutators, hooks, and broadcasting — patches go directly into KV at the schema version you specify.
typescript
await harness.seed.syncedStoreInstance({
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.seed.syncedStoreInstance({
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 { createPoeMultiTileTestHarness } 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 = createPoeMultiTileTestHarness({ backend: apiHarness });
await harness.registerRootTile({
typeId: "manager",
clientConfig: managerClientConfig,
backendConfig: managerBackendConfig,
});
await harness.registerTile({
typeId: "todo-list",
clientConfig: todoClientConfig,
backendConfig: todoBackendConfig,
});
// 1. Seed at the old schema version, before any client connects.
await harness.seed.syncedStoreInstance({
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.getOrCreateSnapshot(path, create)/harness.exportSyncedStoreSnapshot(opts)/harness.restoreSyncedStoreSnapshot(snapshot)— multi-app harness only (createPoeMultiTileTestHarness); the single-appcreatePoeTileTestHarnesshas no snapshot support.exportSyncedStoreSnapshottakes{ stores: { storeTypeId, instanceId }[], deviceIds: string[], maxRowsPerStore? }and requires every named root to be disposed first;restoreSyncedStoreSnapshotrequires the activities to be registered and no root to have created a device yet.harness.seed.syncedStoreInstance(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. On the single-app harness this defaults to the app's currentschemaVersion(seeded patches are presumed current-shape), so migration tests must pass the older version explicitly; on the multi-app harness an omitted version leaves the store uninitialized (version 0).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; };