mcpField
M
migrating-motoko-actors

migrating-motoko-actors

github.com/caffeinelabs开发25.1k 次安装今天更新
代码仓库

关于 migrating-motoko-actors

面向使用 mops 管理迁移链的 Motoko canister 开发者,说明何时可依赖隐式兼容,以及如何编写显式状态转换并保留迁移历史。

主要功能

  1. 01区分隐式与显式迁移
  2. 02规定时间戳迁移文件命名
  3. 03说明稳定状态和 Actor 声明
  4. 04提供状态转换模式
  5. 05介绍 mops 兼容性检查

适用场景

  1. 01规划会改变稳定状态的 canister 升级
  2. 02升级时添加或重命名字段
  3. 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 文档:兼容性

评论

支持 Markdown