migrating-motoko-enhanced について
Motoko 開発者向けに、拡張マイグレーションモジュールの連鎖で canister の状態変更を管理するスキルです。コンパイラーが各段階の互換性と最終状態を検証します。
主な機能
- 01マイグレーションモジュールの順次連結
- 02フィールドの追加・削除・名前変更
- 03フィールド型とデータ構造の変換
- 04互換性のコンパイラー検証
- 05アップグレード時に未適用分のみ実行
活用シーン
- 01永続 Actor フィールドの追加や削除
- 02canister アップグレード時の状態再構成
- 03コレクション内レコード構造の移行
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dfinity/ icskills --skill migrating-motoko-enhancedインストール後、Claude Code で/migrating-motoko-enhancedを入力して読み込まれたか確認します。
詳細
拡張マルチマイグレーション
マイグレーションモジュールのチェーンを通じて、Canister の状態の進化を管理します。各マイグレーションでは 1 つの論理的な変更(フィールドの追加、名前変更、削除、変換)を行い、コンパイラーがチェーン全体の整合性を検証します。
使用する場面
- 永続化されるアクターのフィールドを追加、削除、または名前変更する場合
- フィールドの型を変更する場合
- Canister のアップグレードに伴って状態を再構成する場合
- プロジェクトの
mops.tomlで[canisters.<name>.migrations]が設定されている場合
重要なルール
stableキーワード、preupgrade/postupgrade、またはインラインの(with migration = ...)は使用しないでください- アクター変数は初期化子なしで宣言します。値はマイグレーションチェーンから取得されます
- アクター本体は静的である必要があります(タイマーのような
<system>呼び出しを除き、トップレベルで副作用を起こさないでください) - 各マイグレーションファイルは
public func migration({...}) : {...}をエクスポートします - ファイルは辞書順で適用されます。タイムスタンプのプレフィックスを使用してください
ディレクトリ構成
backend/
├── main.mo
├── types.mo
├── lib/
├── mixins/
└── migrations/
├── 20250101_000000_Init.mo
├── 20250315_120000_AddProfile.mo
└── 20250601_090000_RenameField.mo
アクターの構文
拡張マイグレーションでは、アクター変数に初期化子を指定しません。
actor {
var name : Text; // value comes from migration chain
var balance : Nat; // likewise
let frozen : Bool; // let bindings can also be uninitialized
public func greet() : async Text {
"Hello, " # name # "! Balance: " # debug_show balance;
};
};
マイグレーションモジュールの構造
各マイグレーションモジュールは、入力フィールドのレコードを受け取り、出力フィールドのレコードを返します。
// migrations/20250101_000000_Init.mo
module {
public func migration(_ : {}) : { name : Text; balance : Nat } {
{ name = ""; balance = 0 }
}
}
入力/出力フィールドの意味
| フィールドの出現箇所 | 効果 |
|---|---|
| 入力と出力の両方 | フィールドが変換されます(古い値を読み取り、新しい値を生成します) |
| 出力のみ | 状態に新しいフィールドが追加されます |
| 入力のみ | フィールドが消費され、状態から削除されます |
| どちらにもない | フィールドは変更されず、そのまま引き継がれます |
状態が {a : Nat; b : Text; c : Bool} で、マイグレーションが次の場合:
module {
public func migration(old : { a : Nat; b : Text }) : { a : Int; d : Float } {
{ a = old.a; d = 1.0 }
}
}
a:Nat → Intに変換されますb:消費されます(削除されます)c:変更せず引き継がれますd:新しく導入されます- 結果:
{a : Int; c : Bool; d : Float}
よく使われるパターン
状態を初期化する(最初のマイグレーションで必須)
// migrations/20250101_000000_Init.mo
module {
public func migration(_ : {}) : { count : Nat; header : Text } {
{ count = 0; header = "default" }
}
}
フィールドを追加する
// migrations/20250201_000000_AddEmail.mo
module {
public func migration(_ : {}) : { email : Text } {
{ email = "" }
}
}
オプショナルフィールドを追加する
module {
public func migration(_ : {}) : { assignee : ?Principal } {
{ assignee = null }
}
}
フィールドの型を変更する
// migrations/20250301_000000_CountToInt.mo
module {
public func migration(old : { count : Nat }) : { count : Int } {
{ count = old.count }
}
}
フィールドの名前を変更する
// migrations/20250401_000000_RenameHeader.mo
module {
public func migration(old : { header : Text }) : { title : Text } {
{ title = old.header }
}
}
フィールドを削除する
// migrations/20250501_000000_DropEmail.mo
module {
public func migration(_ : { email : Text }) : {} {
{}
}
}
データを変換する(フィールドを分割する)
// migrations/20250601_000000_SplitName.mo
import Text "mo:core/Text";
module {
public func migration(old : { name : Text }) : { firstName : Text; lastName : Text } {
let parts = old.name.split(#char ' ');
let first = switch (parts.next()) { case (?f) f; case (null) "" };
let last = switch (parts.next()) { case (?l) l; case (null) "" };
{ firstName = first; lastName = last }
}
}
Bool をバリアントに変換する
module {
public func migration(old : { var completed : Bool }) : { var status : { #pending; #completed } } {
{ var status = if (old.completed) { #completed } else { #pending } }
}
}
コレクションの各要素に処理を適用する
import Map "mo:core/Map";
module {
type OldTask = { id : Nat; title : Text; var completed : Bool };
type NewTask = { id : Nat; title : Text; var status : { #pending; #completed } };
public func migration(old : { var tasks : Map.Map<Nat, OldTask> })
: { var tasks : Map.Map<Nat, NewTask> } {
let tasks = old.tasks.map<Nat, OldTask, NewTask>(
func(_, task) {
{
id = task.id;
title = task.title;
var status = if (task.completed) { #completed } else { #pending };
}
}
);
{ var tasks }
}
}
Map 内の各レコードにフィールドを追加する
import Map "mo:core/Map";
module {
type OldUser = { name : Text; email : Text };
type NewUser = { name : Text; email : Text; bio : Text };
public func migration(old : { users : Map.Map<Nat, OldUser> })
: { users : Map.Map<Nat, NewUser> } {
let users = old.users.map<Nat, OldUser, NewUser>(
func(_, u) { { u with bio = "" } }
);
{ users }
}
}
マイグレーションの合成方法
マイグレーションはチェーンを形成します。コンパイラーは、各マイグレーションの入力が、先行するすべてのマイグレーションによって生成された状態と互換性があることを検証します。
| マイグレーション | 入力 | 出力 | 効果 |
|---|---|---|---|
Init | {} | {name : Text; balance : Nat} | 両方のフィールドを初期化します |
AddProfile | {} | {profile : Text} | 新しいフィールドを追加します |
RenameField | {name : Text} | {displayName : Text} | name の名前を displayName に変更します |
チェーン全体の適用後:{displayName : Text; balance : Nat; profile : Text}。アクターでは、この最終状態と互換性のあるフィールドを宣言する必要があります。
ライフサイクルの例:Todo アプリ
4 回のデプロイにわたって、各パターンがどのように組み合わさるかを示します。
// migrations/20250101_000000_Init.mo
module {
public func migration(_ : {}) : { var nextId : Nat } {
{ var nextId = 0 }
}
}
// migrations/20250201_000000_AddTasks.mo
import Map "mo:core/Map";
module {
type Task = { id : Nat; text : Text; completed : Bool };
public func migration(_ : {}) : { tasks : Map.Map<Nat, Task> } {
{ tasks = Map.empty<Nat, Task>() }
}
}
// migrations/20250301_000000_TaskStatus.mo — transform Bool → variant
import Map "mo:core/Map";
module {
type OldTask = { id : Nat; text : Text; completed : Bool };
type NewTask = { id : Nat; text : Text; status : { #pending; #inProgress; #completed } };
public func migration(old : { tasks : Map.Map<Nat, OldTask> })
: { tasks : Map.Map<Nat, NewTask> } {
let tasks = old.tasks.map<Nat, OldTask, NewTask>(
func(_, task) {
{ id = task.id; text = task.text;
status = if (task.completed) #completed else #pending }
}
);
{ tasks }
}
}
// migrations/20250401_000000_AddDueDate.mo — add field to each record
import Map "mo:core/Map";
module {
type Status = { #pending; #inProgress; #completed };
type OldTask = { id : Nat; text : Text; status : Status };
type NewTask = { id : Nat; text : Text; status : Status; due : Int };
public func migration(old : { tasks : Map.Map<Nat, OldTask> })
: { tasks : Map.Map<Nat, NewTask> } {
let tasks = old.tasks.map<Nat, OldTask, NewTask>(
func(_, task) { { task with due = 0 } }
);
{ tasks }
}
}
最終状態:{ var nextId : Nat; tasks : Map.Map<Nat, { id : Nat; text : Text; status : { #pending; #inProgress; #completed }; due : Int }> }
実行時の動作
- 新規デプロイ時:すべてのマイグレーションが順番に実行されます
- アップグレード時:未適用のマイグレーションのみ実行されます(適用済みのマイグレーションはスキップされます)
- ファストフォワード:中間のデプロイを安全にスキップできます。未適用のマイグレーションはすべて順番に実行されます
- マイグレーションがトラップすると、アップグレードは中止され、Canister は旧バージョンのままになります
mops.toml の設定
[moc]
args = ["--default-persistent-actors"]
[canisters.backend]
main = "src/backend/main.mo"
[canisters.backend.migrations]
chain = "src/backend/migrations"
[canisters.<name>.migrations] を設定すると、mops は check/build/check-stable に --enhanced-migration を自動的に挿入します。--enhanced-migration を [canisters.<name>].args に追加しないでください。mops でエラーが発生します。
--enhanced-orthogonal-persistence はデフォルトで有効です。
その後は、通常どおり mops check --fix と mops build を実行できます。新しいマイグレーションファイルは、タイムスタンプのプレフィックスを付けて migrations/ の直下に追加します。
制限事項
--enhanced-migrationとインラインの(with migration = ...)は併用できません- 拡張直交永続化が必要です
- アクターの変数に初期化子を指定することはできません
- アクター本体は静的である必要があります(トップレベルの副作用は
<system>呼び出しのみ許可されます) - 各マイグレーション後の状態は、次のマイグレーションの入力と互換性がある必要があります
- 最終状態は、アクターで宣言されたフィールドと一致する必要があります
- 最後のマイグレーションの出力に含まれる、アクターで宣言されていないフィールドは拒否されます
チェックリスト
- アクターのソースと同じ場所に
migrations/ディレクトリがある - 最初のマイグレーションで、すべてのフィールドを初期化する(入力が空の
Init.mo) - 正しい順序になるよう、ファイル名にタイムスタンプのプレフィックスを付ける
- 各ファイルで
public func migration({...}) : {...}をエクスポートする - アクターの変数を初期化子なしで宣言する
-
mops.tomlで[canisters.<name>.migrations]を設定する(mops が--enhanced-migrationを挿入します) -
mops check --fixを実行して、チェーンの整合性を確認する -
mops buildを実行してコンパイルする
参考資料
- Motoko 言語全般と mo:core API のリファレンスには
motokoを読み込みます --enhanced-migrationを使わないインラインマイグレーションについてはmigrating-motokoを読み込みますmops check、mops build、ツールチェーンの設定についてはmops-cliを読み込みます