关于 migrating-motoko-actors
面向使用 mops 管理迁移链的 Motoko canister 开发者,说明何时可依赖隐式兼容,以及如何编写显式状态转换并保留迁移历史。
主要功能
- 01区分隐式与显式迁移
- 02规定时间戳迁移文件命名
- 03说明稳定状态和 Actor 声明
- 04提供状态转换模式
- 05介绍 mops 兼容性检查
适用场景
- 01规划会改变稳定状态的 canister 升级
- 02升级时添加或重命名字段
- 03转换已存储的记录或集合值
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill migrating-motoko-actors安装后在 Claude Code 中输入/migrating-motoko-actors确认已加载。
附带文件examples.md
详细介绍
Motoko Actor 迁移
在正交持久化中迁移 Actor 状态,以便在 canister 升级期间使用的专家指南。
关键:切勿使用经典持久化或升级前后钩子
不要使用经典(旧版)持久化,也不要使用 system func preupgrade / system func postupgrade。这些方式容易出错,发生陷阱时可能导致 canister 卡住,也无法扩展。
也不要使用旧版 (with migration = ...) Actor 附加语法。本指南介绍的是 由 mops 管理的迁移。如果某项变更需要显式迁移,请将其写成 src/backend/migrations/ 中带时间戳的文件;迁移链会自动重放。
稳定状态的初始化方式
稳定 Actor 字段在 Actor 主体中没有初始值设定项。为这些字段赋值的是 src/backend/migrations/ 中的迁移链:运行时会按字典序遍历该链,在全新安装时运行所有文件,在升级时只运行尚未应用于 canister 的文件。
何时使用本指南
- 规划或实现会更改 Actor 状态的 canister 升级
- 决定使用隐式迁移还是显式迁移
- 编写或重构迁移函数
- 首次引入稳定状态(使用
OldActor = {}的迁移,或者如果第一项迁移仍待处理,则将其合并进去)
迁移文件夹规则
- 所有迁移都放在
src/backend/migrations/中。 全新安装时,按迁移文件名的字典序重放完整迁移链;升级时,只运行晚于已部署链末项的迁移。(该目录在mops.toml的[canisters.<name>.migrations]下通过chain = ...声明;使用非默认 canister 名称的导入项目可能会将其放在其他位置——请查看mops.toml,不要想当然。) - 每次构建最多只能有一项待处理迁移(
mops.toml中的check-limit = 1)。如果已有迁移待处理(尚未应用),请编辑该文件以合并后续变更,而不是新增迁移。超出限制时,mops check会比较已部署的.most基线,并指出应合并到哪个最新待处理文件中。如果托管平台负责管理迁移部分和check-limit,切勿通过编辑它们来清除错误。 - 新文件名只使用 UTC 时间戳,不加后缀:
YYYYMMDD_HHMMSS.mo。时间戳必须晚于所有现有文件。请勿在文件名中注明变更内容(不要使用AddPriority、AddTags、Init等名称)——任何类似功能名称的文件名都会诱使你在下次变更时新增文件,而不是编辑待处理的文件。 - 切勿修改、删除或重命名已应用的迁移。 已应用的迁移按模块名称跟踪,因此重命名会让运行时将该文件视为从未应用;修改已应用的文件也不会执行。在某些平台上,已应用的迁移为只读,写入会直接失败。尚未应用的迁移属于待处理迁移:请编辑它,而不是为同一变更再新增一项迁移。
- 迁移必须自包含。 在迁移文件中内联旧类型和新类型。只允许导入
mo:core/...和 mops 包,绝不要导入../types或任何项目模块。迁移链会永久重放;冻结的迁移如果导入了Types.Note,一旦Note以不兼容的方式发生变化,就会变得不再正确。允许导入包的原因是组件自有的不透明状态:AccessControl.initState()只能通过导入包来构造,包路径为mo:caffeineai-authorization/access-control。 mops check --fix会自动验证升级兼容性。
两种迁移类型
1. 隐式迁移(无需代码)
如果新程序与旧程序兼容,运行时便允许升级。无需迁移函数。
对于下方隐式迁移列表中的变更,请勿添加迁移文件。 每个迁移文件都会在全新安装时永久重放;添加无操作文件会使迁移链膨胀、拖慢全新安装,还会产生无法删除的冻结产物。迁移链足够长时,部署会变得无法完成,且重试也无法恢复;
mops check也不会发出警告。迁移越少越好。身份映射 = 无需迁移。 如果你的
(old : OldActor) : NewActor函数体只是old(或逐字段重建相同记录且不进行任何转换),则此变更与稳定类型兼容——删除该文件。
无需显式迁移的变更(典型的兼容变更):
- 更改字段的可变性(
var改为let,或反之) - 添加变体构造器
- 将
Nat改为Int(以及其他安全的超类型) - 其他符合 Motoko 稳定子类型规则的变更
需要显式迁移的变更:
- 移除上一版本公开的任何 actor 字段——在
OldActor中接收该字段,并在NewActor中省略 - 提升为
Any或其他有损超类型 - 重命名字段(在迁移中将旧字段映射到新字段)
- 非平凡转换(例如,将
Int转换为Float、重组记录) - 添加新的稳定字段(初始值必须来自迁移;不允许内联初始化器)
- 首次引入稳定状态(由迁移提供初始值;actor 只声明稳定字段的类型,不使用内联初始化器)
2. 显式迁移(迁移函数)
当新状态的形状或类型不是旧状态的简单兼容扩展时使用。
如果已有待处理迁移,则扩展该迁移;否则,在 src/backend/migrations/ 中添加带时间戳的新文件。迁移链会自动重放。
规则:
- 迁移函数类型:
(old : OldActor) : NewActor。两个记录类型都必须是稳定类型(不能包含共享函数或本地函数;使用基本类型、记录、变体、Option、Map、List 等)。 - 定义域
OldActor:旧稳定字段的记录(名称和类型与上一版本相同)。如果已退役的 actor 缺少定义域所需的字段,升级会触发陷阱并中止。 - 陪域
NewActor:新稳定字段的记录;每个字段都必须存在于新 actor 中,且新 actor 中该字段的类型必须是陪域类型的超类型。在输出中使用var x = ...或x = ...,以匹配 actor 字段的var或let。 - 全新安装时,整个迁移链会按顺序从空 actor 开始重放(第一个迁移的
OldActor = {});升级时,只会运行比已部署迁移链尾部更新的条目。例外:在从旧式持久化转换而来的项目中,第一个文件的OldActor是转换前的稳定类型形状,而不是{}——请保留原样(参见troubleshooting-motoko-migrations)。 - 每个
NewActor字段的值都来自迁移函数体。在增强模式下,actor 函数体不包含初始化器。 - 为清晰起见,规范写法是在
OldActor和NewActor中列出所有稳定字段(包括未更改的字段)。也支持只列出发生变更字段的OldActor/NewActor部分形式:未更改的字段会自动保留。无论采用哪种形式,列在OldActor中但未列在NewActor中的字段都表示显式丢弃(可能导致数据丢失)。 - 如果迁移函数触发陷阱,升级会中止,canister 会保留在旧版本。迁移应保持纯粹,并避免执行可能意外触发陷阱的操作。
多步升级(例如从 v1 升级到 v2,再升级到 v3):每个升级步骤都有一个从上一已部署版本开始的迁移。下一版本可以使用新的迁移(如果变更与稳定类型兼容,也可以不使用迁移)。
编写迁移
选择一个纯 UTC 时间戳文件名(YYYYMMDD_HHMMSS.mo,无后缀),确保其排序位置晚于所有现有条目,然后编写模块:
module {
type OldActor = { /* old stable fields, inlined */ };
type NewActor = { /* new stable fields */ };
public func migration(old : OldActor) : NewActor {
{ /* produce new fields from old */ };
};
};
- 必须是一个
module,导出public func migration(old : OldActor) : NewActor(必须使用此函数名 — 链式运行器会按名称查找它)。 OldActor和NewActor都必须内联在文件中 — 不得导入项目。
Actor 主体:仅声明类型,不写初始化器
稳定变量只声明类型,不设初始值。瞬态 let/var 字段照常使用初始化器。
此无初始化器规则适用于增强迁移项目(本技能的主题),在此类项目中,初始值由迁移链管理。在没有增强迁移链的普通项目中,稳定字段照常在 Actor 主体内使用内联值初始化(例如
actor { let m = Map.empty<Nat, Text>(); })。
actor {
let tasks : Map.Map<Text, Task>;
var nextId : Nat;
transient var cache = List.empty<Text>();
};
初始值来自迁移链。首次引入稳定状态时,请编写一个迁移,其中 OldActor = {},且 NewActor 列出 main.mo 中声明的所有稳定字段。迁移主体必须为每个字段生成一个值。缺少字段会导致兼容性警告,并破坏后续升级。
如果任何示例直接在 Actor 主体中展示了内联初始化器,例如
let accessControlState = AccessControl.initState();或let users = Map.empty<Principal, User>();,则这些示例早于增强迁移。在增强迁移中,这会导致编译错误(M0014、M0250)。此类示例仅可作为状态结构的参考:将字段名和类型复制到 Actor 中(不带初始化器),并将初始化表达式移入迁移函数的NewActor输出中。
稳定兼容(定义)
如果旧签名中的每个稳定字段 id : T 都满足以下条件,则旧签名与新签名稳定兼容:
- 新签名中有一个稳定字段
id : U,且 T 是 U 的稳定子类型(旧值可以无数据损失地用作新字段的初始值)。 - 新签名可以包含旧签名中没有的其他字段(每个字段都由迁移提供)。
- 匹配字段的可变性可以不同(
var与非var)。
因此:可以扩大类型(例如从 Nat 改为 Int),也可以更改可变性,无需显式迁移。不能删除字段、重命名字段或缩窄类型。新增稳定字段始终需要迁移:不允许使用内联初始化器,因此新字段的初始值必须来自迁移文件的 NewActor。
模式
添加带默认值的记录字段
使用 record with newField = value 为每个元素添加字段:
// Add required field zipCode to each taxpayer
old.taxPayers.map<Nat, OldTaxPayer, NewTaxPayer>(
func(_id, oldTaxPayer) { { oldTaxPayer with zipCode = "" } }
);
对于新的可选字段,将其设为 null:
{ task with var status = task.status; var assignee = null : ?Principal }
添加或扩展变体类型
添加变体标签 — 将旧值转换为新变体类型:
var status = bounty.status : NewTaskStatus // OldTaskStatus had #open;#claimed;#completed; New adds #expired
将 Bool 替换为变体:
var status = if (task.completed) { #completed } else { #pending };
映射集合
使用 map<K, OldV, NewV>(func(key, oldVal) { ... }) 转换 Map 中的每个值:
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 };
添加新的顶层稳定字段(根据旧状态计算)
在迁移输出中包含新字段,并根据旧字段计算:
let times = List.repeat<Time.Time>(0, old.messages.size());
{ var messages = old.messages; var times };
重构:更改状态结构
当新状态具有不同的结构时,请提供一个将旧字段类型转换为新类型的转换函数,并按照应用程序的迁移逻辑实现。根据旧状态在迁移中构建新值,并返回与 NewActor 匹配的记录。
更改字段类型
重命名/概念变更 — 例如,将 artist : Text 改为 artists : Set.Set<Text>:
{ oldPainting with artists = Set.singleton<Text>(oldPainting.artist) }
重命名或更改单个字段的类型
输入记录使用旧名称/类型,输出记录使用新名称/类型:
// 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() };
};
};
有意丢弃字段
在迁移输入中接收该字段,但不将其包含在输出中。预计编译器会发出警告;请确保这是有意的数据丢失。
添加新字段
在 main.mo 中添加该字段,只写类型(不要添加初始化值);同时在下一个迁移的 NewActor 中添加该字段,并将其值设为根据旧字段计算出的结果或常量。在增强迁移机制下,Actor 主体无法提供初始值。
迁移时的语义
- 迁移函数会在升级时、升级上下文中运行一次。迁移主体中的
Time.now()等值表示升级时间,而不是每条原始记录的创建时间。 - 不要在迁移中使用常量或魔法值;应根据迁移输入或定义明确的应用类型推导默认值。
兼容性与工具
升级安全检查会将新的 Actor 主体与上次部署的稳定签名进行比较:该签名保存在 .most 文件中(编码后的稳定签名快照,即字段名称和类型),并通过 mops.toml 中 [canisters.<name>.check-stable] 的配置引用。配置好后,mops check --fix 会自动读取该文件。托管平台通常会为你配置好这一项,并将先前的 .most 文件保留在项目中。
要让检查通过:新迁移的 OldActor 必须与先前部署的签名一致,而其 NewActor 必须与新的 Actor 主体一致,或为其稳定超类型。
要以确定性方式推导 OldActor:你的 OldActor 应与 src/backend/migrations/ 中排序在你之前的文件的 NewActor 一致(按字典顺序);如果你是链中的第一个文件,则为 {}。只有当迁移链从空 canister 开始时才适用 {};从旧版持久化转换而来的项目,其起点应为转换前的稳定结构,且已有的第一个文件已反映这一点。
升级检查清单
- 决定采用隐式方式(兼容性变更)还是显式方式(迁移文件)
- 每次构建最多只能有一个待处理的迁移文件。 创建文件前,先检查
src/backend/migrations/;如果已有待处理迁移,请编辑该文件,而不是再添加第二个 - 如果采用显式方式:选择一个不带后缀的 UTC 时间戳文件名(
YYYYMMDD_HHMMSS.mo),确保其排序晚于所有现有文件;不要在文件名中说明变更内容 - 将
OldActor设为src/backend/migrations/中排序在你之前的文件的NewActor(按字典顺序);如果你是从一开始就有迁移链的项目中的第一个文件,则设为{}。绝不可取自当前的main.mo,也不可取自该文件自身的NewActor - 当目录为空时(初始化迁移),
NewActor必须列出main.mo中声明的每个稳定字段,并为每个字段提供一个值 - 将
OldActor(使用旧类型)和NewActor(使用新类型)都内联定义;不要导入项目内容 - 实现
public func migration(old : OldActor) : NewActor - 切勿修改或删除已应用的迁移;对待处理的迁移进行编辑,不要重复添加
- 不要使用 preupgrade/postupgrade 或
(with migration = ...)进行数据迁移 - 反复运行
mops check --fix(速度快),直至通过——该命令会验证编译和升级安全性 - 最后运行一次
mops build(速度较慢),以编译后端并生成更新后的 IDL 绑定
其他资源
- 迁移示例:参阅 examples.md,其中包含简洁、独立的示例(添加字段、映射集合、添加可选字段)
- 遇到无法解释的问题时:加载
troubleshooting-motoko-migrations,了解兼容性诊断、冻结文件的写入失败、从旧版持久化转换而来的项目,以及移除迁移链的请求。常规流程无需使用。 - Motoko 常规开发:使用
writing-motoko了解语言基础、核心库参考和架构模式 - Motoko 文档:数据持久化
- Motoko 文档:兼容性