Migrating Persisted Cache Schemas

Migration is the expensive option, and most applications should not take it — a query cache exists to save a round trip, and discarding it costs exactly that round trip. But when the persisted data is the application’s state offline, discarding means losing something the user believes they saved, and then migration is the only honest answer. This page is that path done properly, under Cache Versioning & Migration. Read Busting a Persisted Cache on Deploy first and confirm it does not solve your problem, and pair this with Gracefully Handling Corrupt Persisted Cache, because a migration chain is another thing that can throw on untrusted input.

Prerequisites

  • A versioned envelope around the persisted payload, not a bare DehydratedState.
  • A clear statement of which persisted data cannot be refetched.
  • A test runner that can load JSON fixtures.
A migration chain applied per entry A snapshot at schema version one enters a chain of three single-step migrations to reach version four. Each entry passes through the chain independently inside a try block, so one entry that throws is dropped while the remaining entries complete the chain and hydrate. Each entry walks the chain alone, so one bad record costs one record snapshot v1 1,000 entries v1 → v2 owner → assignee v2 → v3 epoch → ISO v3 → v4 array → {items} 12 entries throw — dropped individually counted and reported, not fatal 988 entries hydrate at v4 A try around the whole loop instead of each entry would have preserved zero.
The difference between wrapping the loop and wrapping the body is 988 entries of the user's offline data.

Step 1 — Confirm Migration Is Justified

The decision is about what an empty cache costs, and it should be written down rather than assumed.

/** Persisted under its own key, with its own version — this is the data that
 *  genuinely cannot be refetched, so it is the data that gets migrated. */
export const QUEUE_STORAGE_KEY = 'app-mutation-queue';
export const QUEUE_SCHEMA_VERSION = 4;

/** The query cache is persisted separately and is simply busted on deploy. */
export const CACHE_STORAGE_KEY = 'app-cache';

export interface QueuedMutation {
  id: string;
  mutationKey: unknown[];
  variables: unknown;
  createdAt: string;
  attempts: number;
}

Cache Behavior Analysis: Separating the two stores is what makes the decision tractable: the query cache can be busted freely because every entry is refetchable, while the mutation queue holds work the server has never seen and therefore has to survive every deploy. Keeping their versions independent means a shape change in a cached response does not force a migration of the queue, and vice versa. If you find yourself wanting to migrate the query cache too, that is usually a sign that something non-refetchable has leaked into it — the fix is to move that thing out rather than to write a migration for everything.


Step 2 — Write Each Migration as a Single Step

One function per version transition. The chain composes them, so no individual function has to know about history.

type Migration = (record: unknown) => unknown;

export const MIGRATIONS: Record<number, Migration> = {
  // v1 → v2: the API renamed `owner` to `assignee`.
  2: (record) => {
    if (!isRecord(record)) return record;
    const { owner, ...rest } = record as Record<string, unknown> & { owner?: unknown };
    return owner === undefined ? record : { ...rest, assignee: owner };
  },

  // v2 → v3: timestamps became ISO strings instead of epoch milliseconds.
  3: (record) => {
    if (!isRecord(record) || typeof record.createdAt !== 'number') return record;
    return { ...record, createdAt: new Date(record.createdAt).toISOString() };
  },

  // v3 → v4: variables were flattened; they are now nested under `variables`.
  4: (record) => {
    if (!isRecord(record) || 'variables' in record) return record;
    const { id, mutationKey, createdAt, attempts, ...rest } = record as Record<string, unknown>;
    return { id, mutationKey, createdAt, attempts, variables: rest };
  },
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

Cache Behavior Analysis: Each migration is idempotent on already-migrated input — the v2 step returns the record unchanged when owner is absent, the v4 step when variables is already present — which means a chain applied twice is harmless and a partially migrated store converges rather than corrupting. Keeping each step to one transition is what makes them testable: a v1-to-v4 megafunction has eight interacting branches by the third version and nobody can say what it does to a v2 record. The Record<number, Migration> shape means a missing step is detectable at runtime rather than silently skipped, which Step 3 relies on.


Step 3 — Isolate Failure to One Entry

The unit of failure must be one record. A try around the loop is the difference between losing twelve entries and losing a thousand.

export interface MigrationResult<T> { records: T[]; migrated: number; dropped: number; firstError?: string }

export function migrateAll<T>(
  raw: unknown[],
  fromVersion: number,
  toVersion: number,
): MigrationResult<T> {
  const records: T[] = [];
  let migrated = 0;
  let dropped = 0;
  let firstError: string | undefined;

  for (const original of raw) {
    try {
      let record = original;
      for (let version = fromVersion + 1; version <= toVersion; version++) {
        const migrate = MIGRATIONS[version];
        // A gap in the chain means we cannot reach the target shape safely.
        if (!migrate) throw new Error(`no migration defined for schema v${version}`);
        record = migrate(record);
      }
      records.push(record as T);
      migrated += 1;
    } catch (error) {
      // This one record is lost. Everything else still completes the chain.
      dropped += 1;
      firstError ??= (error as Error).message;
    }
  }

  return { records, migrated, dropped, firstError };
}

Cache Behavior Analysis: Migrations run against the deserialized payload before anything is hydrated or restored, so a record that throws never enters the live store and cannot be observed half-migrated. Treating a missing chain step as a throw rather than a skip is deliberate: silently passing a v2 record through to a v4 consumer produces a shape nothing validates, which surfaces later as an undefined field rather than here as a counted drop. Returning dropped alongside the records gives you the metric worth alerting on — a migration that quietly discards 40% of a queue looks identical, from the outside, to one that works.

Queued mutations surviving a three-version migration Bar chart of entries preserved. Discarding the whole store preserves zero. A try around the entire restore preserves zero. A try around each entry preserves 988. Per-entry plus schema validation preserves 988 and rejects the same 12 earlier. Queued mutations surviving a three-version migration 1,000-entry offline queue containing 12 malformed records discard the store 0 kept try around the whole restore 0 kept try around each entry 988 kept per entry + validation 988 kept
Both of the first two options lose a thousand records to fix twelve. Only per-entry isolation is proportionate.

Step 4 — Commit a Fixture for Every Shipped Version

Migration code outlives the writer that produced the shape it handles. Fixtures are what keep it honest afterwards.

import v1 from './fixtures/queue-v1.json';
import v2 from './fixtures/queue-v2.json';
import v3 from './fixtures/queue-v3.json';

describe('mutation queue migrations', () => {
  test.each([
    ['v1', v1, 1],
    ['v2', v2, 2],
    ['v3', v3, 3],
  ])('%s migrates cleanly to the current schema', (_label, fixture, version) => {
    const result = migrateAll<QueuedMutation>(fixture.records, version, QUEUE_SCHEMA_VERSION);

    // No drops: a real snapshot from a shipped version must migrate entirely.
    expect(result.dropped).toBe(0);
    expect(result.migrated).toBe(fixture.records.length);

    for (const record of result.records) {
      expect(typeof record.createdAt).toBe('string');
      expect(record.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
      expect(record).toHaveProperty('variables');
      expect(record).not.toHaveProperty('owner');
    }
  });

  test('a chain gap is a failure, not a silent pass-through', () => {
    const result = migrateAll([{ id: 'q1' }], 0, QUEUE_SCHEMA_VERSION);
    expect(result.dropped).toBe(1);
    expect(result.firstError).toMatch(/no migration defined/);
  });
});

Cache Behavior Analysis: Asserting dropped === 0 on a real captured snapshot is the assertion that gives the whole chain meaning — a migration that runs without throwing but discards half the records passes every shape check and still loses the user’s work. Capturing the fixture from a real build rather than hand-writing it matters because hand-written fixtures encode what you believe v2 looked like, which is exactly the belief the migration is already based on. The chain-gap test guards the failure mode that appears when someone bumps QUEUE_SCHEMA_VERSION without adding the corresponding migration, which is otherwise silent until a user with an old snapshot arrives.

Migration or discard, per persisted store Matrix of four persisted stores with the recommended policy and whether fixtures are required. Migration or discard, per persisted store Policy Fixtures needed? Query result cache Bust on schema change No Queued offline mutations Migrate, never discard Yes — one per shipped version Unsynced drafts Migrate, never discard Yes Derived indexes and search caches Rebuild from source No — they are recomputable
Fixtures are only required where migration is. That is the same short list, and it is the list worth keeping short.

Edge Cases & Gotchas

A migration that needs data it does not have. A step that must look up a value from the server cannot run at restore time, when the application may be offline. Either keep enough information in the record to migrate locally, or mark the record as needing repair and handle it once connectivity returns.

Version zero. A store written before versioning existed has no version field. Treat a missing version as zero and write a migration for it, rather than guessing which shipped shape it corresponds to.

Fixtures drift from reality. A fixture captured from a development build may differ from what production wrote. Capture from a real session, and strip anything sensitive before committing it.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
One bad record loses the entire store The try wraps the loop rather than the body Move the try inside the per-entry iteration
A record arrives in a shape nothing understands A missing chain step was skipped instead of throwing Treat an absent migration as an error
Migration passes in CI and drops records in production Fixtures were hand-written rather than captured Capture fixtures from real snapshots
Bumping the version broke restore for old users The version was bumped without a matching migration Add the chain-gap test
Migrated records lose their timestamps A step mutated the record in place and returned undefined Return a new object from every migration

Frequently Asked Questions

When is migration actually worth writing?

When an empty store means lost user work rather than a refetch. Queued offline mutations, unsynced drafts, and a genuinely offline-first dataset all qualify: the data exists only on that device, and discarding it destroys something the user believes they saved. Ordinary query results do not qualify, however large — the worst case there is one round trip, and the migration code you would have written is permanent, has to keep working against shapes you no longer produce, and multiplies the paths through your restore logic every time you add a version.

Why one migration per version rather than one big transform?

Because a single-version step is small enough to hold in your head and to test on its own, and the chain composes them for free — a snapshot three versions old simply passes through three functions in order. A single transform that has to handle every historical shape acquires a branch per version, and those branches interact: by the third version, nobody can say with confidence what it does to a v2 record with an unusual field, and the test matrix that would prove it is larger than the migrations themselves.

What should happen to an entry that fails to migrate?

Drop that entry, keep everything else, and report the count. Failing the whole restore because one record is malformed converts a small problem into total data loss, which is the opposite of why you chose migration over discarding in the first place. The count is the important half: a single dropped record out of a thousand is noise, while a migration dropping four hundred is a bug that would otherwise be invisible, because from the outside a partially successful restore looks exactly like a successful one.