计划执行与检查节点
executing-plans
按既定实施计划完成工作,保持过程检查和反馈。
migrating-motoko-actors【效率与协作】Motoko actor migration and schema evolution with the enhanced migration chain (migrations/). Load when upgrading canisters or changing actor state shape.
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Expert guidance for migrating actor state across canister upgrades with orthogonal persistence.
Do not use classical (legacy) persistence or system func preupgrade / system func postupgrade. They are error-prone, can leave canisters stuck if they trap, and do not scale.
Also do not use the legacy (with migration = ...) actor-attached syntax. This skill covers mops-managed migrations. When a change requires an explicit migration, write it as a timestamped file in src/backend/migrations/; the chain replays automatically.
Stable actor fields have no initializers in the actor body. The chain in src/backend/migrations/ is what gives them values: the runtime walks it in lexicographic order, running every file on a fresh install and only the files not yet applied to the canister on an upgrade.
OldActor = {}, or fold it into the first one if that is still pending)src/backend/migrations/. The full chain replays in lexicographic filename order on fresh install; on upgrade, only entries newer than the deployed tail run. (The directory is declared as chain = ... under [canisters.<name>.migrations] in mops.toml; an imported project using a non-default canister name may place it elsewhere — read mops.toml rather than assuming.)check-limit = 1 in mops.toml). If a migration is already pending (not yet applied), edit that file to fold in further changes instead of adding another. mops check compares the deployed .most baseline and names the latest pending file to fold into when the limit is exceeded. Where a hosting platform owns the migrations section and check-limit, never edit them to clear an error.YYYYMMDD_HHMMSS.mo. The timestamp must sort after every existing file. Do NOT encode the change in the name (no AddPriority, AddTags, Init, …) — any feature-ish name tempts you to add another file for the next change instead of editing the one pending file.mo:core/... and mops package imports are allowed — never ../types or any project module. The chain replays forever; a frozen migration that imported Types.Note becomes wrong the moment Note changes in an incompatible way. Component-owned opaque state is the reason package imports are allowed: AccessControl.initState() from mo:caffeineai-authorization/access-control can only be constructed by importing the package.mops check --fix automatically verifies upgrade compatibility.The runtime allows the upgrade if the new program is compatible with the old. No migration function needed.
Do NOT add a migration file for changes in the implicit list below. Every migration file replays forever on fresh install; adding a no-op file bloats the chain, slows fresh installs, and creates a frozen artifact you can never delete. A long enough chain becomes undeployable in a way no retry recovers, and
mops checkwill not warn you. Fewer migrations are always better.Identity body = no migration. If your
(old : OldActor) : NewActorbody would just beold(or rebuild the same record field-by-field with no transformation), the change is stable-compatible — delete the file.
Allowed without explicit migration (typical compatible changes):
var to let or vice versa)Nat to Int (and other safe supertypes)Require explicit migration:
OldActor, omit from NewActorAny or other lossy supertypesInt to Float, restructuring records)Use when the new state shape or types are not a simple compatible extension of the old.
Extend the pending migration if there is one; otherwise add a new timestamped file to src/backend/migrations/. The chain replays automatically.
Rules:
(old : OldActor) : NewActor. Both record types must be stable (no shared/local functions; use primitive types, records, variants, Option, Map, List, etc.).OldActor: record of old stable fields (names and types as in the previous version). If the retired actor is missing a field the domain expects, the upgrade traps and is aborted.NewActor: record of new stable fields; each field must exist in the new actor with the same name and a supertype of the codomain type. Use var x = ... or x = ... in the output to match the actor's var vs let.OldActor = {} for the first migration); on upgrade, only entries newer than the deployed tail run. Exception: in a project converted from legacy persistence the first file's OldActor is the pre-conversion stable shape, not {} — leave it alone (see troubleshooting-motoko-migrations).NewActor field's value comes from the migration body. The actor body has no initializers in enhanced mode.OldActor and NewActor (including unchanged ones) is the canonical form for clarity. A partial OldActor/NewActor that lists only the fields being changed (subset form) is also supported: unchanged fields carry through automatically. Either way, a field in OldActor but not in NewActor is an explicit discard (possible data loss).Multi-step upgrades (e.g. v1 to v2 to v3): Each upgrade step has one migration from the previously deployed version. The next version can use a new migration (or none if the change is stable-compatible).
Pick a bare UTC-timestamp filename (YYYYMMDD_HHMMSS.mo, no suffix) that sorts after every existing entry, then write the module:
module {
type OldActor = { /* old stable fields, inlined */ };
type NewActor = { /* new stable fields */ };
public func migration(old : OldActor) : NewActor {
{ /* produce new fields from old */ };
};
};
module exporting public func migration(old : OldActor) : NewActor (the function name is required — the chain runner discovers it by name).OldActor and NewActor must be inlined in the file — no project imports.Stable vars are declared with types, no initial values. Transient let/var fields use initializers as usual.
This no-initializer rule applies to enhanced-migration projects (this skill's subject), where the migration chain owns initial values. In a plain project without the enhanced-migration chain, stable fields are initialized with inline values in the actor body as usual (e.g.
actor { let m = Map.empty<Nat, Text>(); }).
actor {
let tasks : Map.Map<Text, Task>;
var nextId : Nat;
transient var cache = List.empty<Text>();
};
Initial values come from the migration chain. When you introduce stable state for the first time, write a migration whose OldActor = {} and NewActor enumerates every stable field declared in main.mo. The migration body must produce a value for each. Missing fields surface as compatibility warnings and break subsequent upgrades.
If an example anywhere shows inline initializers like
let accessControlState = AccessControl.initState();orlet users = Map.empty<Principal, User>();directly in the actor body, it predates enhanced migration. Under enhanced migration that is a compile error (M0014, M0250). Treat such examples as state-shape hints only: copy the field name and type into your actor (without initializer), and move the initializer expression into the migration function'sNewActoroutput.
Old signature is stable-compatible with new if, for every stable field id : T in the old signature:
id : U such that T is a stable subtype of U (the old value can be used as the new field's initial value without loss of data).var vs non-var).So: you can widen types (e.g. Nat to Int) and change mutability without an explicit migration. You cannot remove fields, rename them, or narrow types. Adding a stable field always requires a migration: inline initializers are not allowed, so the new field's initial value must come from a migration file's NewActor.
Use record with newField = value to add a field to every element:
// Add required field zipCode to each taxpayer
old.taxPayers.map<Nat, OldTaxPayer, NewTaxPayer>(
func(_id, oldTaxPayer) { { oldTaxPayer with zipCode = "" } }
);
For a new optional field, set it to null:
{ task with var status = task.status; var assignee = null : ?Principal }
Adding a variant tag -- cast the old value to the new variant type:
var status = bounty.status : NewTaskStatus // OldTaskStatus had #open;#claimed;#completed; New adds #expired
Replacing a Bool with a variant:
var status = if (task.completed) { #completed } else { #pending };
Transform each value in a Map with map<K, OldV, NewV>(func(key, oldVal) { ... }):
let todos = old.todos.map<TaskID, OldTask, NewTask>(
func(_, task) {
{
task with
due = 0;
var status = if (task.completed) { #completed } else { #pending };
};
}
);
{ var todos; var nextTaskId = old.nextTaskId };
Include the new field in the migration output and compute it from old fields:
let times = List.repeat<Time.Time>(0, old.messages.size());
{ var messages = old.messages; var times };
When the new state has a different structure, provide a transformation function from the old field type to the new one and implement it according to the application's migration logic. Build the new value in the migration from the old state and return a record that matches NewActor.
Rename/conceptual change -- e.g. artist : Text to artists : Set.Set<Text>:
{ oldPainting with artists = Set.singleton<Text>(oldPainting.artist) }
Input record uses old name/type, output record uses new name/type:
// src/backend/migrations/YYYYMMDD_HHMMSS.mo
import Int "mo:core/Int";
module {
type OldActor = { var state : Int };
type NewActor = { var newState : Float };
public func migration(old : OldActor) : NewActor {
{ var newState = old.state.toFloat() };
};
};
Consume it in the migration input but do not include it in the output. Expect a compiler warning; ensure the loss is intentional.
Add the field to main.mo with type only (no initializer) AND add it to the next migration's NewActor with a value computed from old fields or a constant. The actor body cannot supply the initial value under enhanced migration.
Time.now() in the migration body are the time of the upgrade, not of each original record creation.The upgrade safety check compares the new actor body against the last deployed stable signature: a .most file (an encoded snapshot of the stable signature — field names and types) referenced from mops.toml under [canisters.<name>.check-stable]. When that is configured, mops check --fix picks it up automatically. Hosted platforms typically wire this up for you and keep the previous .most alongside the project.
For the check to pass: the new migration's OldActor must match the previously deployed signature, and its NewActor must match (or be a stable supertype of) the new actor body.
To derive OldActor deterministically: your OldActor equals the NewActor of the file that precedes yours in src/backend/migrations/ (lex-order), or {} if yours is the first file in the chain. {} applies only when the chain starts from an empty canister — a project converted from legacy persistence starts from its pre-conversion stable shape instead, and its existing first file already reflects that.
src/backend/migrations/ — if a migration is already pending, edit it instead of adding a secondYYYYMMDD_HHMMSS.mo, no suffix) that sorts after every existing file; do not encode the change in the nameOldActor to the NewActor of the file that precedes yours in src/backend/migrations/ (lex-order), or {} if yours is the first file in a project that started out with a chain. Never from current main.mo. Never the file's own NewActor.NewActor must list every stable field declared in main.mo, with a value for eachOldActor (with old types) and NewActor (with new types) — no project importspublic func migration(old : OldActor) : NewActor(with migration = ...) for data migrationmops check --fix (fast) until it passes — it verifies compilation and upgrade safetymops build ONCE at the end (slow) to compile the backend and produce the updated IDL bindingstroubleshooting-motoko-migrations for compatibility diagnostics you cannot explain, write failures on frozen files, projects converted from legacy persistence, and requests to remove the chain. Not needed on the normal path.writing-motoko for language fundamentals, core library reference, and architecture patterns