diff --git a/packages/keyring-sdk/CHANGELOG.md b/packages/keyring-sdk/CHANGELOG.md index 094f7b36c..653f630dc 100644 --- a/packages/keyring-sdk/CHANGELOG.md +++ b/packages/keyring-sdk/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add keyring state migration framework ([#505](https://github.com/MetaMask/accounts/pull/505)) + - It can be used to migrate the internal state of keyrings + ### Changed - Bump `@metamask/keyring-api` from `^23.2.0` to `^23.5.0` ([#569](https://github.com/MetaMask/accounts/pull/569), [#583](https://github.com/MetaMask/accounts/pull/583), [#587](https://github.com/MetaMask/accounts/pull/587)) diff --git a/packages/keyring-sdk/docs/migrations.md b/packages/keyring-sdk/docs/migrations.md new file mode 100644 index 000000000..f07e41f3e --- /dev/null +++ b/packages/keyring-sdk/docs/migrations.md @@ -0,0 +1,120 @@ +# Keyring State Migrations + +A framework for evolving keyring serialized state across versions. Migrations run during `deserialize()` and transform old state into the current format. + +Versioned state is stored as an envelope: + +```json +{ + "version": 2, + "data": { "accountCount": 3, "mnemonic": [...], "hdPath": "m/44'/60'/0'/0" } +} +``` + +Unversioned state (vaults created before migration support was added) has no envelope. The framework treats it as version 0 and applies all migrations. + +## Key Concepts + +- **`schema`**: Validates the **output** of a migration at runtime. +- **`inputSchema`**: (Optional) Validates the **input** before the `migrate` function is called. +- **`defineMigration`**: Ensures the `migrate` function's return type matches the `schema` type at compile time. +- **`as const` arrays**: Use an array with `as const` to allow TypeScript to infer the final state type via `applyMigrations`. + +## Example + +### 1. Define State Schemas + +Define a schema for each version of your state. + +```typescript +import { object, array, number, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +const HdStateV0Schema = object({ + numberOfAccounts: number(), // legacy field name + mnemonic: array(number()), + hdPath: string(), +}); +type HdStateV0 = Infer; + +const HdStateV1Schema = object({ + accountCount: number(), // renamed from numberOfAccounts + mnemonic: array(number()), + hdPath: string(), +}); +type HdStateV1 = Infer; + +const HdStateV2Schema = object({ + accountCount: number(), + mnemonic: array(number()), + hdPath: string(), + createdAt: number(), // new field +}); +type HdStateV2 = Infer; +``` + +### 2. Define the Migration Chain + +```typescript +import { defineMigration } from '@metamask/keyring-sdk'; + +const migrations = [ + defineMigration({ + version: 1, + inputSchema: HdStateV0Schema, + schema: HdStateV1Schema, + migrate: (data) => ({ + accountCount: data.numberOfAccounts, + mnemonic: data.mnemonic, + hdPath: data.hdPath, + }), + }), + defineMigration({ + version: 2, + schema: HdStateV2Schema, + migrate: (data) => ({ ...data, createdAt: Date.now() }), + }), +] as const; +``` + +### 3. Implement in your Keyring + +```typescript +import { applyMigrations, getLatestVersion } from '@metamask/keyring-sdk'; +import type { VersionedState } from '@metamask/keyring-sdk'; +import type { Json } from '@metamask/utils'; + +class MyKeyring { + async deserialize(state: Json): Promise { + const { data } = await applyMigrations(state, migrations); + + // data is typed as HdStateV2 + this.#mnemonic = data.mnemonic; + this.#accountCount = data.accountCount; + this.#hdPath = data.hdPath; + } + + async serialize(): Promise> { + return { + version: getLatestVersion(migrations), + data: { + mnemonic: this.#mnemonic, + accountCount: this.#accountCount, + hdPath: this.#hdPath, + }, + }; + } +} +``` + +## Best Practices + +- **`as const` arrays**: Declare the migrations array with `as const` so TypeScript infers the final state type from `applyMigrations`. Without it, `data` falls back to `Json`. +- **Idempotent migrations**: Design migrations so re-running them on already-migrated data is harmless. +- **Immutability**: Treat the input `data` as immutable within the `migrate` function. +- **Schema coverage**: Ensure `schema` covers all fields expected in the new version to prevent runtime errors. + +## Constraints + +- **Sequential versions**: migrations must be numbered 1, 2, 3, ... with no gaps. +- **Forward-only**: there is no downgrade path; code that does not understand the versioned envelope will fail on migrated state. diff --git a/packages/keyring-sdk/src/index.ts b/packages/keyring-sdk/src/index.ts index 170478da5..874f77ce5 100644 --- a/packages/keyring-sdk/src/index.ts +++ b/packages/keyring-sdk/src/index.ts @@ -1,3 +1,4 @@ export * from './keyring-account-registry'; +export * from './migration'; export * from './mnemonic'; export * from './eth'; diff --git a/packages/keyring-sdk/src/migration.test.ts b/packages/keyring-sdk/src/migration.test.ts new file mode 100644 index 000000000..fd9c23dc9 --- /dev/null +++ b/packages/keyring-sdk/src/migration.test.ts @@ -0,0 +1,585 @@ +import type { Infer } from '@metamask/superstruct'; +import { object, number, string, array } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; + +import type { KeyringMigration } from './migration'; +import { + applyMigrations, + defineMigration, + getLatestVersion, + isVersionedState, +} from './migration'; + +// -- State types used across tests ----------------------------------------------- + +type UnversionedHdState = { + oldField: string; + existing: boolean; +}; + +type HdStateV1 = { + oldField: string; + existing: boolean; + newField: string; +}; + +type HdStateV2 = { + existing: boolean; + newField: string; + renamedField: string; +}; + +type PrivateKeysV1 = { + keys: string[]; + format: string; +}; + +// -------------------------------------------------------------------------------- + +describe('isVersionedState', () => { + it('returns true for a valid versioned state', () => { + expect(isVersionedState({ version: 1, data: { foo: 'bar' } })).toBe(true); + }); + + it('returns true when data is an array', () => { + expect(isVersionedState({ version: 0, data: ['a', 'b'] })).toBe(true); + }); + + it('returns true when data is null', () => { + expect(isVersionedState({ version: 0, data: null })).toBe(true); + }); + + it('returns false for a plain object without version', () => { + expect(isVersionedState({ foo: 'bar' })).toBe(false); + }); + + it('returns false for an array', () => { + expect(isVersionedState(['a', 'b'] as unknown as Json)).toBe(false); + }); + + it('returns false for null', () => { + expect(isVersionedState(null)).toBe(false); + }); + + it('returns false when version is not a number', () => { + expect(isVersionedState({ version: '1', data: {} })).toBe(false); + }); + + it('returns false when data is missing', () => { + expect(isVersionedState({ version: 1 })).toBe(false); + }); +}); + +describe('getLatestVersion', () => { + it('returns 0 for empty migrations', () => { + expect(getLatestVersion([])).toBe(0); + }); + + it('returns the last migration version', () => { + const migrations: KeyringMigration[] = [ + { version: 1, migrate: (state) => state }, + { version: 2, migrate: (state) => state }, + { version: 3, migrate: (state) => state }, + ] as const; + expect(getLatestVersion(migrations)).toBe(3); + }); + + it('returns the version for a single migration', () => { + const migrations: KeyringMigration[] = [ + { version: 1, migrate: (state) => state }, + ] as const; + expect(getLatestVersion(migrations)).toBe(1); + }); +}); + +describe('defineMigration', () => { + it('attaches a validate function that asserts against the schema', () => { + const V1Schema = object({ count: number() }); + + const migration = defineMigration({ + version: 1, + schema: V1Schema, + migrate: () => ({ count: 42 }), + }); + + expect(() => migration.validate({ count: 42 })).not.toThrow(); + expect(() => migration.validate({ count: 'wrong' })).toThrow( + 'Expected a number', + ); + }); + + it('attaches a no-op validate function when schema is omitted', () => { + const migration = defineMigration({ + version: 1, + migrate: () => ({ count: 42 }), + }); + + expect(() => migration.validate({ count: 42 })).not.toThrow(); + expect(() => migration.validate({ wrongField: 'x' })).not.toThrow(); + }); + + describe('when inputSchema is provided', () => { + it('applies the migration when input matches the inputSchema', async () => { + const V0Schema = object({ oldCount: number() }); + + const migrations = [ + defineMigration({ + version: 1, + inputSchema: V0Schema, + migrate: (state) => ({ count: state.oldCount }), + }), + ] as const; + + const result = await applyMigrations({ oldCount: 7 }, migrations); + + expect(result).toStrictEqual({ + version: 1, + data: { count: 7 }, + migrated: true, + }); + }); + + it('throws before calling migrate when input does not match inputSchema', async () => { + const V0Schema = object({ oldCount: number() }); + + const migrateFn = jest.fn(); + const migrations = [ + defineMigration({ + version: 1, + inputSchema: V0Schema, + migrate: migrateFn, + }), + ] as const; + + await expect( + applyMigrations({ wrongField: 'oops' }, migrations), + ).rejects.toThrow('Expected a number'); + + expect(migrateFn).not.toHaveBeenCalled(); + }); + + it('validates input as JSON even when inputSchema is omitted', async () => { + const migrateFn = jest.fn(); + const migrations = [ + defineMigration({ version: 1, migrate: migrateFn }), + ] as const; + + // undefined is not valid JSON + await expect( + applyMigrations(undefined as unknown as Json, migrations), + ).rejects.toThrow( + 'Expected a value of type `JSON`, but received: `undefined`', + ); + + expect(migrateFn).not.toHaveBeenCalled(); + }); + }); +}); + +describe('applyMigrations', () => { + describe('when given unversioned state', () => { + it('applies all migrations to an unversioned object', async () => { + const migrations = [ + defineMigration({ + version: 1, + migrate: (state) => { + const prev = state as UnversionedHdState; + return { ...prev, newField: 'added' }; + }, + }), + defineMigration({ + version: 2, + migrate: (state) => { + const prev = state as HdStateV1; + return { + existing: prev.existing, + newField: prev.newField, + renamedField: prev.oldField, + }; + }, + }), + ] as const; + + const result = await applyMigrations( + { oldField: 'value', existing: true } satisfies UnversionedHdState, + migrations, + ); + + expect(result).toStrictEqual({ + version: 2, + data: { + existing: true, + newField: 'added', + renamedField: 'value', + } satisfies HdStateV2, + migrated: true, + }); + }); + + it('applies all migrations to an unversioned array', async () => { + const migrations = [ + defineMigration({ + version: 1, + migrate: (state) => { + const keys = state as string[]; + return { keys, format: 'v1' }; + }, + }), + ] as const; + + const result = await applyMigrations( + ['key1', 'key2'] as unknown as Json, + migrations, + ); + + expect(result).toStrictEqual({ + version: 1, + data: { + keys: ['key1', 'key2'], + format: 'v1', + } satisfies PrivateKeysV1, + migrated: true, + }); + }); + + it('wraps in envelope at version 0 when no migrations exist', async () => { + const result = await applyMigrations({ foo: 'bar' }, []); + + expect(result).toStrictEqual({ + version: 0, + data: { foo: 'bar' }, + migrated: false, + }); + }); + + it('wraps array state in envelope at version 0 when no migrations exist', async () => { + const result = await applyMigrations(['a', 'b'] as unknown as Json, []); + + expect(result).toStrictEqual({ + version: 0, + data: ['a', 'b'], + migrated: false, + }); + }); + }); + + describe('when given versioned state', () => { + it('skips already-applied migrations', async () => { + type StateV2 = { existing: boolean; v2: boolean }; + + const migrateFn = jest.fn((state) => { + const prev = state as { existing: boolean }; + return { ...prev, v2: true }; + }); + + const migrations: KeyringMigration[] = [ + { version: 1, migrate: (state) => state }, + { version: 2, migrate: migrateFn }, + ] as const; + + const result = await applyMigrations( + { version: 1, data: { existing: true } }, + migrations, + ); + + expect(migrateFn).toHaveBeenCalledWith({ existing: true }); + expect(result).toStrictEqual({ + version: 2, + data: { existing: true, v2: true } satisfies StateV2, + migrated: true, + }); + }); + + it('returns state unchanged when already at latest version', async () => { + const migrateFn = jest.fn((state) => state); + + const migrations: KeyringMigration[] = [ + { version: 1, migrate: migrateFn }, + ] as const; + + const result = await applyMigrations( + { version: 1, data: { foo: 'bar' } }, + migrations, + ); + + expect(migrateFn).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + version: 1, + data: { foo: 'bar' }, + migrated: false, + }); + }); + + it('applies multiple pending migrations in order', async () => { + type StateV3 = { original: boolean; migrated: boolean }; + + const order: number[] = []; + + const migrations = [ + defineMigration({ + version: 1, + migrate: (state) => { + order.push(1); + return state; + }, + }), + defineMigration({ + version: 2, + migrate: (state) => { + order.push(2); + return state; + }, + }), + defineMigration({ + version: 3, + migrate: (state) => { + order.push(3); + const prev = state as { original: boolean }; + return { ...prev, migrated: true }; + }, + }), + ] as const; + + const result = await applyMigrations( + { version: 1, data: { original: true } }, + migrations, + ); + + expect(order).toStrictEqual([2, 3]); + expect(result).toStrictEqual({ + version: 3, + data: { original: true, migrated: true } satisfies StateV3, + migrated: true, + }); + }); + + it('treats explicitly versioned state at version 0 as unversioned', async () => { + const migrations = [ + defineMigration({ + version: 1, + migrate: (state) => { + const prev = state as UnversionedHdState; + return { ...prev, newField: 'added' }; + }, + }), + ] as const; + + const result = await applyMigrations( + { version: 0, data: { oldField: 'value', existing: true } }, + migrations, + ); + + expect(result).toStrictEqual({ + version: 1, + data: { + oldField: 'value', + existing: true, + newField: 'added', + } satisfies HdStateV1, + migrated: true, + }); + }); + }); + + describe('when migrate is async', () => { + it('applies the migration successfully', async () => { + type StateV1 = { foo: string; async: boolean }; + + const migrations = [ + defineMigration({ + version: 1, + migrate: async (state) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + const prev = state as { foo: string }; + return { ...prev, async: true }; + }, + }), + ] as const; + + const result = await applyMigrations({ foo: 'bar' }, migrations); + + expect(result).toStrictEqual({ + version: 1, + data: { foo: 'bar', async: true } satisfies StateV1, + migrated: true, + }); + }); + }); + + describe('when inferring the result data type', () => { + it('infers the data type from the last migration when using as const', async () => { + type StateV1 = { count: number }; + type StateV2 = { count: number; label: string }; + + const migrations = [ + defineMigration({ + version: 1, + migrate: () => ({ count: 1 }), + }), + defineMigration({ + version: 2, + migrate: (state) => ({ ...state, label: 'hello' }), + }), + ] as const; + + const { data } = await applyMigrations({}, migrations); + + expect(data.count).toBe(1); + expect(data.label).toBe('hello'); + }); + + it('falls back to Json when array is typed as KeyringMigration[]', async () => { + type StateV1 = { count: number }; + + const migrations: KeyringMigration[] = [ + defineMigration({ + version: 1, + migrate: () => ({ count: 1 }), + }), + ]; + + const { data } = await applyMigrations({}, migrations); + + expect((data as StateV1).count).toBe(1); + }); + }); + + describe('when a schema is provided', () => { + it('completes the migration when output matches the schema', async () => { + const OutputSchema = object({ + name: string(), + count: number(), + }); + + const migrations = [ + defineMigration({ + version: 1, + schema: OutputSchema, + migrate: () => ({ name: 'test', count: 42 }), + }), + ] as const; + + const result = await applyMigrations({}, migrations); + + expect(result).toStrictEqual({ + version: 1, + data: { name: 'test', count: 42 }, + migrated: true, + }); + }); + + it('throws when the migration output does not match the schema', async () => { + const OutputSchema = object({ + name: string(), + count: number(), + }); + type OutputState = Infer; + + const migrations = [ + defineMigration({ + version: 1, + schema: OutputSchema, + // @ts-expect-error - intentionally invalid return for test + migrate: () => ({ name: 'test', count: 'not a number' }), + }), + ] as const; + + await expect(applyMigrations({}, migrations)).rejects.toThrow( + 'Expected a number', + ); + }); + + it('validates each migration step independently', async () => { + const V1Schema = object({ items: array(string()) }); + type StateV1 = Infer; + + const V2Schema = object({ items: array(string()), total: number() }); + type StateV2 = Infer; + + const migrations = [ + defineMigration({ + version: 1, + schema: V1Schema, + migrate: () => ({ items: ['a', 'b'] }), + }), + defineMigration({ + version: 2, + schema: V2Schema, + migrate: (state) => { + const prev = state as StateV1; + return { ...prev, total: prev.items.length }; + }, + }), + ] as const; + + const result = await applyMigrations({}, migrations); + + expect(result).toStrictEqual({ + version: 2, + data: { items: ['a', 'b'], total: 2 } satisfies StateV2, + migrated: true, + }); + }); + }); + + describe('when given invalid input', () => { + it('throws when migration versions are not sequential', async () => { + const migrations: KeyringMigration[] = [ + { version: 1, migrate: (state) => state }, + { version: 3, migrate: (state) => state }, + ] as const; + + await expect(applyMigrations({}, migrations)).rejects.toThrow( + 'Invalid migration: expected version 2 at index 1, got 3', + ); + }); + + it('throws when migration versions contain duplicates', async () => { + const migrations: KeyringMigration[] = [ + { version: 1, migrate: (state) => state }, + { version: 1, migrate: (state) => state }, + ] as const; + + await expect(applyMigrations({}, migrations)).rejects.toThrow( + 'Invalid migration: expected version 2 at index 1, got 1', + ); + }); + + it('throws when migrations do not start at version 1', async () => { + const migrations: KeyringMigration[] = [ + { version: 2, migrate: (state) => state }, + ] as const; + + await expect(applyMigrations({}, migrations)).rejects.toThrow( + 'Invalid migration: expected version 1 at index 0, got 2', + ); + }); + + it('throws when state version is newer than latest migration', async () => { + const migrations: KeyringMigration[] = [ + { version: 1, migrate: (state) => state }, + ] as const; + + await expect( + applyMigrations({ version: 5, data: {} }, migrations), + ).rejects.toThrow( + 'State version 5 is newer than the latest migration version 1', + ); + }); + + it('propagates errors from migrate functions', async () => { + const migrations: KeyringMigration[] = [ + { + version: 1, + migrate: (): never => { + throw new Error('Migration failed'); + }, + }, + ] as const; + + await expect(applyMigrations({}, migrations)).rejects.toThrow( + 'Migration failed', + ); + }); + }); +}); diff --git a/packages/keyring-sdk/src/migration.ts b/packages/keyring-sdk/src/migration.ts new file mode 100644 index 000000000..73290d18a --- /dev/null +++ b/packages/keyring-sdk/src/migration.ts @@ -0,0 +1,284 @@ +import type { Infer, Struct } from '@metamask/superstruct'; +import { assert, integer, is, object } from '@metamask/superstruct'; +import { JsonStruct } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; + +/** + * A single migration step that transforms keyring state from one version to the next. + * + * The generic `Output` parameter controls the return type of `migrate`. Use + * {@link defineMigration} to create migrations with compile-time type binding between + * `migrate` and `schema`. + * + * Use an array with `as const` so that `applyMigrations` can infer the last migration's + * output type and return a typed `data` field. + * + * @example + * ```typescript + * const V1Schema = object({ accountCount: number(), hdPath: string() }); + * type V1State = Infer; + * + * const migration = defineMigration({ + * version: 1, + * schema: V1Schema, + * migrate: (data) => { + * const prev = data as V0State; + * return { accountCount: prev.numberOfAccounts, hdPath: prev.hdPath }; + * }, + * }); + * ``` + */ +export type KeyringMigration< + Output extends Json = Json, + Input extends Json = Json, +> = { + /** + * The version this migration produces. Must be sequential starting from 1. + */ + version: number; + /** + * Transform state from the previous version to this version. + * + * Receives the raw inner data (not the versioned envelope). May be sync or async to + * support complex operations like re-deriving data from secrets. + * + * @param data - The state data from the previous version. + * @returns The migrated data. + */ + migrate(data: Input): Output | Promise; + /** + * Optional validation function called by `applyMigrations` after this migration step. + * + * When using {@link defineMigration} with a `schema`, this is wired up automatically. + * + * @param data - The output of `migrate` to validate. + */ + validate?(data: unknown): void; +}; + +/** + * Create a migration with compile-time type binding between `migrate` and `schema`. + * + * Unlike constructing a {@link KeyringMigration} directly, `defineMigration` ensures + * that `schema` validates the same `Output` type that `migrate` returns. This prevents + * mismatched schemas at compile time. + * + * The `schema` is a superstruct `Struct`. `defineMigration` wires it into the + * `validate` callback automatically using `assert`. + * + * When `inputSchema` is provided, state is validated against it before `migrate` is + * called, and the `Input` type parameter is inferred so `migrate` receives a typed + * argument with no manual cast needed. + * + * Returns `KeyringMigration` so that {@link applyMigrations} can infer the + * output type. + * + * @param config - The migration configuration. + * @param config.version - The version this migration produces. + * @param config.migrate - Transform state from the previous version. + * @param config.schema - Optional schema to validate the migration output. + * @param config.inputSchema - Optional schema to validate the migration input. + * @returns A typed migration for use in arrays. + * @example + * ```typescript + * const V0Schema = object({ numberOfItems: number() }); + * const V1Schema = object({ count: number() }); + * + * const migration = defineMigration({ + * version: 1, + * inputSchema: V0Schema, + * schema: V1Schema, + * migrate: (data) => ({ count: data.numberOfItems }), + * }); + * ``` + */ +export function defineMigration< + Output extends Json, + Input extends Json = Json, +>(config: { + version: number; + migrate: (data: Input) => Output | Promise; + schema?: Struct; + inputSchema?: Struct; +}): KeyringMigration & { validate(data: unknown): void } { + const { version, schema, inputSchema, migrate } = config; + return { + version, + migrate: (data: Input): Output | Promise => { + // `assert` can't accept `Struct | Struct`; cast is safe because + // `Input extends Json`. + assert(data, (inputSchema ?? JsonStruct) as Struct); + return migrate(data); + }, + validate: (data: unknown): void => { + if (schema) { + assert(data, schema); + } + }, + }; +} + +/** + * Superstruct schema for the versioned state envelope. + */ +export const VersionedStateStruct = object({ + version: integer(), + data: JsonStruct, +}); + +/** + * Versioned state envelope wrapping the actual keyring data. + * + * After migrations are applied, state is always wrapped in this format. `serialize()` + * should produce this envelope so that subsequent `deserialize()` calls can detect the + * version. + */ +export type VersionedState = Omit< + Infer, + 'data' +> & { + data: Data; +}; + +/** + * Return value of {@link applyMigrations}. + * + * Extends {@link VersionedState} with a `migrated` flag that is `true` when at least + * one migration was applied during the call. Callers can use this to detect that the + * in-memory state has been upgraded and schedule a persist so the new version is + * written to storage — even when no other state change happens in the session. + * + * `Data` is inferred as the last migration's output type when the migrations array is + * declared with `as const` — see {@link applyMigrations}. + */ +export type MigrationResult = VersionedState & { + migrated: boolean; +}; + +/** + * Type guard to check if a value is a {@link VersionedState} envelope. + * + * @param state - The value to check. + * @returns `true` if the value is a versioned state envelope. + */ +export function isVersionedState( + state: State | VersionedState, +): state is VersionedState { + return is(state, VersionedStateStruct); +} + +/** + * Returns the highest version number from a migrations array, or 0 if empty. + * + * @param migrations - The migrations array. + * @returns The latest version number. + */ +export function getLatestVersion( + migrations: readonly KeyringMigration[], +): number { + return migrations.reduce((max, { version }) => Math.max(max, version), 0); +} + +/** + * Validate that migrations are sequential starting from 1 with no gaps or duplicates. + * + * @param migrations - The migrations array to validate. + * @throws If migrations are invalid. + */ +function validateMigrations(migrations: readonly KeyringMigration[]): void { + for (const [index, migration] of migrations.entries()) { + const expectedVersion = index + 1; + + if (migration.version !== expectedVersion) { + throw new Error( + `Invalid migration: expected version ${expectedVersion} at index ${index}, got ${migration.version}`, + ); + } + } +} + +/** + * Get the version number from state, treating unversioned state as version 0. + * + * @param state - The state to check. + * @returns The version number. + */ +function getVersionAndData( + state: State | VersionedState, +): VersionedState { + return isVersionedState(state) + ? { version: state.version, data: state.data } + : { version: 0, data: state }; +} + +/** + * Extracts the output type of the last migration in a readonly tuple. + * + * Used by {@link applyMigrations} to infer the `data` type of the result when the + * migrations array is declared with `as const`. + */ +type LastOutput = + Migrations extends readonly [...KeyringMigration[], infer Last] + ? Last extends KeyringMigration + ? Output + : Json + : Json; + +/** + * Apply pending migrations to keyring state. + * + * Handles both versioned state (wrapped in `{ version, data }` envelope) and + * unversioned legacy state (treated as version 0). + * + * Use an array with `as const` to declare the migrations and get a typed `data` field + * without a cast in `deserialize()`: + * + * ```typescript + * const migrations = [ + * defineMigration({ version: 1, ... }), + * defineMigration({ version: 2, ... }), + * ] as const; + * + * const { data } = await applyMigrations(state, migrations); + * // data is V2 + * ``` + * + * Without `as const` (array typed as `KeyringMigration[]`), `data` falls back to + * `Json`. + * + * @param state - The serialized keyring state (from vault or previous serialize). + * @param migrations - Ordered array of migrations to apply. + * @returns The migrated state wrapped in a versioned envelope, plus a `migrated` flag. + */ +export async function applyMigrations< + Migrations extends readonly KeyringMigration[], +>( + state: Json, + migrations: Migrations, +): Promise>> { + validateMigrations(migrations); + + const latestVersion = getLatestVersion(migrations); + let { version, data } = getVersionAndData(state); + + if (version > latestVersion) { + throw new Error( + `State version ${version} is newer than the latest migration version ${latestVersion}`, + ); + } + + let migrated = false; + for (const migration of migrations) { + if (version < migration.version) { + data = await migration.migrate(data); + version = migration.version; + migrated = true; + + // This will throw if the validation fails, so it's not possible to return a + // partially migrated state. + migration.validate?.(data); + } + } + + return { version, data, migrated } as MigrationResult>; +}