Skip to content

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_VERSION AND 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 compute itemKey/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:

  1. 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).
  2. Bump TILE_SCHEMA_VERSION.
  3. Add the migrations entry with migrateData (and migratePendingMutation if input shapes changed).
  4. 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.
  5. 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 testSeeded-patch test
Old data comes fromReal old code, recorded onceHand-written patches
CoversServer KV and the old client's local cacheServer KV only
AssertsThe whole chain still restores and accepts today's mutationsThe specific rewrite this migration performs
GrowsAutomatically, one case per bumpYou write one per migration

How the flow works

  • The test enumerates every schema version from a checked-in baseline to TILE_SCHEMA_VERSION and 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 — the create() 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. getOrCreateSnapshot refuses to create anything when CI is 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 drives runMutationsmigrateData, 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>.json alongside 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:

  1. 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.
  2. 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.
  3. 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.
  4. The migration runs once, server-side, at that moment. The server runs your migrateData over 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.)
  5. 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:

  1. Seed an instance at the old schemaVersion with old-shape patches.
  2. Open a client at the new schemaVersion.
  3. Issue any mutation through the client to force the upgrade.
  4. 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 migrateData step deletes from one storage sortKey and 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 a sortKey, seed several items out of insertion order and assert the post-migration sortKey ordering matches the original order ordering.
  • 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)key is an EntryKey, not a string. ctx.table(...).scan().entries() yields [EntryKey, JSONValue] where EntryKey = { sortKey, itemKey }. Both set and delete accept string | EntryKey, so the natural patterns are set({ ...key, value }) and delete(key) — no manual extraction needed. Never cast with key as unknown as string: it bypasses the type system, and against older platform versions it stored "[object Object]" as the literal itemKey and 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 drive runMutations and the schema upgrade.
  • migrateData and the row's value.sortKey are not the same as the storage sortKey. Some apps store a fractional-index sortKey inside 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's set(...) call.

Reference

  • harness.getOrCreateSnapshot(path, create) / harness.exportSyncedStoreSnapshot(opts) / harness.restoreSyncedStoreSnapshot(snapshot) — multi-app harness only (createPoeMultiTileTestHarness); the single-app createPoeTileTestHarness has no snapshot support. exportSyncedStoreSnapshot takes { stores: { storeTypeId, instanceId }[], deviceIds: string[], maxRowsPerStore? } and requires every named root to be disposed first; restoreSyncedStoreSnapshot requires 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 current schemaVersion (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.
  • Patch shape (from @synced-store/shared/protocol):
    typescript
    type PatchSet = {
      op: "set";
      tableName: string;
      itemKey: string;
      sortKey?: string;
      value: JSONValue;
    };
    type PatchDel = {
      op: "del";
      tableName: string;
      itemKey: string;
      sortKey?: string;
    };