Conversation
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[].
1b3ffa5 to
c7354f2
Compare
186e5ef to
f087b71
Compare
4444802 to
94a9efc
Compare
ad96b17 to
0679d1f
Compare
|
|
||
| - **`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. |
There was a problem hiding this comment.
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,
}),
}),
...There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
WDYT about renaming this just migrate?
There was a problem hiding this comment.
migrate is already a field of KeyringMigration, I didn't re-use it because I thought it could be confusing.
| defineMigration<HdStateV1, HdStateV0>({ | ||
| version: 1, | ||
| inputSchema: HdStateV0Schema, | ||
| schema: HdStateV1Schema, | ||
| migrate: (state) => ({ | ||
| accountCount: state.numberOfAccounts, | ||
| mnemonic: state.mnemonic, | ||
| hdPath: state.hdPath, | ||
| }), |
There was a problem hiding this comment.
Maybe we could auto-Infer input and output based on inputSchema and schema? 🤔
There was a problem hiding this comment.
It's already the case, they aren't necessary in this case, I can remove the types from the example.
| 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>; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
…rn for migrations
Replace optional validate with an always-present method that is a no-op when no schema is provided, and update tests to assert behavior rather than presence.
Summary
@metamask/keyring-sdkfor evolving keyring serialized statedeserialize()and wrap state in a{ version, data }envelopedefineMigration()helper provides compile-time type binding betweenmigrateand superstructschematsconfig.packages.jsonfor IDE supportTest plan
yarn workspace @metamask/keyring-sdk testpasses (85 tests, 100% coverage)yarn workspace @metamask/keyring-sdk buildsucceeds