Drafts outlive deployments: a user starts a form on Monday, you ship a release on Tuesday that renames phone to phoneNumber, and on Wednesday their restored draft silently drops the phone number — or crashes the form because a field that is now an array was saved as a string.

Draft persistence and autosave treats a stored draft as data about the user’s intent. Like any persisted data it has a schema, and the schema changes. This page applies the discipline of database migrations to client-side drafts: version every record, migrate step by step on read, validate the result, and fail safe.


Context and prerequisites

Form changes that break old drafts are routine:

  • Renames — phone becomes phoneNumber.
  • Splits and merges — name becomes firstName and lastName.
  • Type changes — a single tag string becomes a tags array; a free-text country becomes an ISO code.
  • Removed fields — the “fax” field is gone.
  • New required fields — the old draft simply lacks them; that is normal and not a migration problem.

Without a version stamp you can only guess which shape a stored draft has. With one, restore becomes deterministic: read the version, apply every migration from that version up to the current one in order, then validate against the current schema before the values reach the form.

A draft saved at version 1 restored by version 3 code A draft stored at version 1 has name and phone fields. Migration 1 to 2 renames phone to phoneNumber. Migration 2 to 3 splits name into firstName and lastName. The result is validated against the current partial draft schema, and only then loaded into the form. Stored v1 name: "Ada Lovelace" phone: "+44 20…" v1 → v2 rename phone to phoneNumber v2 → v3 split name into firstName, lastName Validate, then load Current draft schema. Unknown keys dropped.

The core pattern: ordered migrations with a validation gate

import { z } from "zod";

export const CURRENT_VERSION = 3;

type Json = Record<string, unknown>;
type Migration = (draft: Json) => Json;

// migrations[n] upgrades a draft FROM version n TO version n + 1.
// Never edit a migration after it has shipped: drafts in the wild depend on it.
const migrations: Record<number, Migration> = {
  1: ({ phone, ...rest }) => ({ ...rest, phoneNumber: phone }),
  2: ({ name, ...rest }) => {
    const full = typeof name === "string" ? name.trim() : "";
    const cut = full.lastIndexOf(" ");
    return cut === -1
      ? { ...rest, firstName: full, lastName: "" }
      : { ...rest, firstName: full.slice(0, cut), lastName: full.slice(cut + 1) };
  },
};

// The CURRENT shape, all optional: a draft is by definition incomplete.
const DraftSchema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  phoneNumber: z.string(),
  email: z.string(),
}).partial().strip();   // strip() drops keys from removed fields

export type Draft = z.infer<typeof DraftSchema>;

export type RestoreResult =
  | { ok: true; values: Draft; migratedFrom: number }
  | { ok: false; reason: "future-version" | "migration-failed" | "invalid" };

export function restoreDraft(stored: { schemaVersion?: number; values: Json }): RestoreResult {
  // Drafts saved before versioning existed are treated as version 1.
  const from = stored.schemaVersion ?? 1;
  if (from > CURRENT_VERSION) return { ok: false, reason: "future-version" }; // rolled-back deploy

  let values = stored.values;
  try {
    for (let v = from; v < CURRENT_VERSION; v++) {
      const step = migrations[v];
      if (!step) throw new Error(`missing migration ${v}→${v + 1}`);
      values = step(values);
    }
  } catch {
    return { ok: false, reason: "migration-failed" };
  }
  const parsed = DraftSchema.safeParse(values);
  return parsed.success
    ? { ok: true, values: parsed.data, migratedFrom: from }
    : { ok: false, reason: "invalid" };
}

Saving always writes schemaVersion: CURRENT_VERSION, so a draft migrated on restore is stored in the new shape at the next autosave and never migrated again.


Step-by-step walkthrough

  1. Stamp every stored draft with schemaVersion. Treat unstamped legacy drafts as version 1 so the first release that adds versioning can still read them.
  2. Write one migration per version step. Each is a pure function from the old shape to the next. Chaining small steps is far easier to review and test than one function that knows every historical shape.
  3. Never edit a shipped migration. Fix a mistake with a new migration. A draft saved at version 2 must go through exactly the code that produced version 3 for everyone else.
  4. Validate the migrated result against a partial schema. Use the same schema you use for step or draft validation — see partial Zod schemas for drafts and wizard steps — with unknown keys stripped.
  5. Fail safe and say so. If the version is from the future, a migration throws, or validation fails, do not load a half-migrated draft. Keep the stored copy, start a fresh form, and tell the user their earlier draft could not be restored.
  6. Re-save in the current version. The next autosave writes the migrated values with the new version stamp.
What restore does with a stored draft If the stored version is newer than the running code, which happens after a rolled-back deployment, keep the draft untouched and start fresh. If any migration step throws, keep the stored draft and start fresh with a notice. If the migrated values fail validation, do the same. Otherwise load the values into the form and re-save them at the current version on the next autosave. Stored version newer than code? Keep stored draft, start fresh yes no Did a migration step throw? Keep stored draft, notify user yes no Does the result fail validation? Keep stored draft, notify user yes no Load and re-save at v3 Next autosave stores the current shape.

Failure modes and edge cases

1. Rollbacks

If you roll back a release, the older code finds drafts stamped with a version it has never heard of. It must not attempt to read them as its own version. Returning future-version and leaving the draft untouched means that when the fix is redeployed, the draft is still there.

2. Migrations that lose information

Splitting a full name on the last space mangles “Jean de la Fontaine”. When a migration cannot be exact, prefer putting the whole value in the first field and flagging the draft for review, and let the restored form show an info message on those fields asking the user to check them.

3. Changed option lists

A select whose options changed — a plan called “pro” is now “professional”, or was retired — needs a mapping migration. For retired values, drop the field so it validates as missing and the user chooses again, rather than loading a value the select cannot display.

4. Drafts containing files

Migrations run on the values object; File entries pass through untouched as long as migrations spread the rest of the object rather than rebuilding it from a list of known keys. See storing large drafts in IndexedDB.

5. Testing migrations

Keep a fixture of a real stored draft for every historical version and assert that each restores to the expected current shape. Those fixtures are the only way a future refactor of the migration table cannot quietly break old drafts.

Draft versions across a sequence of releases Release A on day one writes version 1 drafts. Release B on day four renames phone and writes version 2. Release C on day eight splits name and writes version 3. On day nine release C is rolled back to B, which finds version 3 drafts, treats them as from the future, and leaves them alone. On day ten C is redeployed and restores the version 3 drafts normally. Release in prod A writes v1 B writes v2 C B C again v3 drafts restored 0d 2d 4d 6d 8d 10d 12d During the one-day rollback, release B leaves v3 drafts untouched instead of misreading them, so nothing is lost when C returns.

Verification checklist


Frequently Asked Questions

Can I just discard drafts whenever the form changes?

You can, and for low-value forms that is reasonable — bump a key prefix and old drafts are ignored. For anything a user spends more than a minute on, discarding their work because you renamed a field is a poor trade when a migration is a few lines.

How is this different from server-side database migrations?

The logic is the same, but client migrations run lazily, on each device, whenever an old draft is read — possibly months later. That is why they must be pure, ordered and never edited after shipping, and why a failed migration must leave the original intact.

Where should the schema version number come from?

A hand-maintained integer next to the migrations table, incremented in the same commit as the migration. Do not derive it from the app version or a build hash; those change on every release, while the draft shape changes rarely.


Related

← Draft Persistence and Autosave