Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
94a742a
feat: add keyring state migration framework to keyring-sdk
danroc Apr 8, 2026
3361c30
docs: update keyring-sdk changelog for migration framework
danroc Apr 8, 2026
091e618
refactor: use superstruct for VersionedState validation
danroc Apr 8, 2026
c12fe95
refactor: simplify migrations with entries() and getVersionAndData he…
danroc Apr 8, 2026
ad0c009
docs: simplify migration documentation
danroc Apr 8, 2026
3d24a0b
feat: add migrated flag, inputSchema validation, and idempotent const…
danroc Apr 8, 2026
1f544b7
feat: add defineMigrations for typed applyMigrations result
danroc Apr 8, 2026
53fcb9f
docs: remove redundant API reference from migrations doc
danroc Apr 8, 2026
bfd9445
docs: remove duplicate defineMigration JSDoc
danroc Apr 8, 2026
08461ac
fix: use integer() for versioned state, remove unnecessary cast in de…
danroc Apr 8, 2026
1ff2a37
docs: simplify JSDoc for defineMigration parameters
danroc Apr 8, 2026
cd11da6
chore: make `migrations` a `const` to allow type inference
danroc Apr 8, 2026
6920d01
chore: rename some tests
danroc Apr 8, 2026
be2b5cb
refactor: allow `validate` to be `undefined`
danroc Apr 9, 2026
16c0d01
docs: simplify documentation
danroc Apr 9, 2026
3aade3f
docs: improve keyring state migrations documentation
danroc Apr 10, 2026
8cd7239
docs: clarify state persistence responsibility in migrations
danroc Apr 10, 2026
e217cc8
docs: clean up unused code in migrations example
danroc Apr 10, 2026
872f1dd
chore: remove prettier config change
danroc Apr 10, 2026
f1f4f53
docs: remove redundant migrated flag from migrations example
danroc Apr 10, 2026
9ccbf0a
docs: remove redundant migrated flag reference from best practices
danroc Apr 10, 2026
03fb855
chore: remove changes to tsconfig
danroc Apr 10, 2026
e4eabba
chore: rename migrations.ts to migration.ts (migration engine)
danroc Apr 10, 2026
a63e0a8
chore: unwrap lines
danroc Apr 10, 2026
0739302
refactor(keyring-sdk): remove redundant type params inferred from sch…
danroc Apr 10, 2026
57e11c3
refactor(keyring-sdk): remove defineMigrations and use as const patte…
danroc Apr 10, 2026
1d6d984
feat: use JsonStruct to validate inner state when inputSchema is not …
danroc Apr 10, 2026
d04146a
chore: simplify comment
danroc Apr 10, 2026
2901740
chore: refine types of isVersionedState and getVersionAndData
danroc Apr 10, 2026
2f754ad
refactor(keyring-sdk): make validate always defined with no-op fallback
danroc Apr 11, 2026
b849a10
refactor(keyring-sdk): simplify state assertion in defineMigration fu…
danroc Apr 11, 2026
e84fb6f
refactor(keyring-sdk): streamline migration validation call
danroc Apr 11, 2026
2aa615e
chore: remove redundant comment
danroc Apr 11, 2026
776ca1f
chore: clarify `data` vs `state`
danroc Apr 11, 2026
200a55c
chore: simplify comment
danroc Apr 11, 2026
b23dc4e
chore: use reduce instead of array spread
danroc Apr 13, 2026
3ab5aeb
chore(keyring-sdk): fix review issues in migration module
danroc Apr 13, 2026
de9280f
chore: update changelog
danroc Apr 13, 2026
28a63ce
chore(keyring-sdk): fix import style lint error after rebase
danroc Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/keyring-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
120 changes: 120 additions & 0 deletions packages/keyring-sdk/docs/migrations.md
Original file line number Diff line number Diff line change
@@ -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<Output, Input>`**: Ensures the `migrate` function's return type matches the `schema` type at compile time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have expected to have input first, then output? But I guess it's because we want to have the type for the right version "first"?

Like:

const migrations = defineMigrations(
  // version 1 -> HdStateV1
  defineMigration<HdStateV1, HdStateV0>({
    version: 1,
    inputSchema: HdStateV0Schema,
    schema: HdStateV1Schema,
    migrate: (state) => ({
      accountCount: state.numberOfAccounts,
      mnemonic: state.mnemonic,
      hdPath: state.hdPath,
    }),
  }),
  ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's intentional since Output is the primary type. Input can be omitted and it will default to Json.

- **`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<typeof HdStateV0Schema>;

const HdStateV1Schema = object({
accountCount: number(), // renamed from numberOfAccounts
mnemonic: array(number()),
hdPath: string(),
});
type HdStateV1 = Infer<typeof HdStateV1Schema>;

const HdStateV2Schema = object({
accountCount: number(),
mnemonic: array(number()),
hdPath: string(),
createdAt: number(), // new field
});
type HdStateV2 = Infer<typeof HdStateV2Schema>;
Comment on lines +33 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: But would be nice to enforce that all schemas are JSON-compatible too (not sure we have something ready for this).

Maybe we don't need to enforce this in the superstruct itself, but in the defineMigration* we could make sure T extends Json? Just to avoid allowing state that could not be serialized to JSON?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already the case (at least in compile-time), Input and Output must extend Json.

```

### 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<HdStateV2, HdStateV1>({
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<void> {
const { data } = await applyMigrations(state, migrations);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about renaming this just migrate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

migrate is already a field of KeyringMigration, I didn't re-use it because I thought it could be confusing.


// data is typed as HdStateV2
this.#mnemonic = data.mnemonic;
this.#accountCount = data.accountCount;
this.#hdPath = data.hdPath;
}

async serialize(): Promise<VersionedState<HdStateV2>> {
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.
1 change: 1 addition & 0 deletions packages/keyring-sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './keyring-account-registry';
export * from './migration';
export * from './mnemonic';
export * from './eth';
Loading
Loading