Skip to content

feat: add keyring state migration framework#505

Draft
danroc wants to merge 35 commits intomainfrom
dr/kering-migrations
Draft

feat: add keyring state migration framework#505
danroc wants to merge 35 commits intomainfrom
dr/kering-migrations

Conversation

@danroc
Copy link
Copy Markdown
Contributor

@danroc danroc commented Apr 8, 2026

Summary

  • Adds a versioned migration framework to @metamask/keyring-sdk for evolving keyring serialized state
  • Migrations run during deserialize() and wrap state in a { version, data } envelope
  • defineMigration() helper provides compile-time type binding between migrate and superstruct schema
  • Includes developer documentation with integration examples
  • Adds jest types to tsconfig.packages.json for IDE support

Test plan

  • yarn workspace @metamask/keyring-sdk test passes (85 tests, 100% coverage)
  • yarn workspace @metamask/keyring-sdk build succeeds
  • No lint errors

danroc added 7 commits April 8, 2026 18:07
Add a versioned migration framework that allows keyrings to evolve their
serialized state format over time. Migrations run during deserialize()
and wrap state in a { version, data } envelope.

- KeyringMigration type with generic Output extends Json
- defineMigration() helper for compile-time type binding between
  migrate and superstruct schema
- applyMigrations() runner with sequential version validation
- isVersionedState() type guard and getLatestVersion() helper
- Developer documentation with integration examples
- Add jest types to tsconfig.packages.json for IDE support
Replace manual type checks in isVersionedState with a VersionedStateStruct
schema using superstruct. Derive VersionedState type from the struct.
Use JsonStruct from @metamask/utils for the data field.
…raint

- applyMigrations now returns MigrationResult with a migrated boolean so
  callers can detect when migrations ran and schedule a persist
- defineMigration gains an optional inputSchema (Struct<Input>) that
  validates state before migrate is called; the Input type parameter
  removes the need for manual casts inside migrate
- migrations.md updated: inputSchema example, migrated usage in
  deserialize, output-as-input validation chain note, and a new
  Idempotent migrations constraint
defineMigrations uses rest parameters to infer a tuple type, which lets
applyMigrations resolve data as the last migration's output without any
cast or as const:

  const migrations = defineMigrations(
    defineMigration<V1, V0>({ version: 1, ... }),
    defineMigration<V2, V1>({ version: 2, ... }),
  );
  const { data } = await applyMigrations(state, migrations); // data: V2

Falls back to Json when the array is typed as KeyringMigration[].
@danroc danroc force-pushed the dr/kering-migrations branch from 1b3ffa5 to c7354f2 Compare April 8, 2026 21:39
@danroc danroc force-pushed the dr/kering-migrations branch from 186e5ef to f087b71 Compare April 8, 2026 22:43
@danroc danroc force-pushed the dr/kering-migrations branch from 4444802 to 94a9efc Compare April 9, 2026 06:52
@danroc danroc force-pushed the dr/kering-migrations branch from ad96b17 to 0679d1f Compare April 9, 2026 07:29

- **`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.


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.

Comment on lines +62 to +70
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

Choose a reason for hiding this comment

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

Maybe we could auto-Infer input and output based on inputSchema and schema? 🤔

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, they aren't necessary in this case, I can remove the types from the example.

Comment on lines +33 to +53
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>;
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.

…emas

When both `schema` and `inputSchema` are provided to `defineMigration`,
TypeScript infers `Output` and `Input` automatically. Remove the explicit
generic arguments and redundant `migrate` return type annotations in
those cases across docs, tests, and JSDoc examples.

The `@ts-expect-error` test for intentionally invalid output retains its
explicit `<OutputState>` type param since the compile-time check only
fires when TypeScript knows the expected output type.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants