☰
loro.js 与 Rust 可移动列表 JSON 互操作修复:elem_id 改用 `L{lamport}@{peer}` 格式
2026/10/11 9:02:18 网站建设 项目流程
  • 后端

【免费下载链接】loro

Make your JSON data collaborative and version-controlled with CRDTs

项目地址:https://gitcode.com/gh_mirrors/lo/loro
点击查看免费下载

loro.js的 JSON 导出(exportJsonUpdates)此前将可移动列表(MovableList)move / set 操作的元素 ID 写成{lamport}@{peer},而 Rust 核心与loro-crdt一律使用带L前缀的L{lamport}@{peer},导致双向互操作双双失败:Rust 拒绝loro.js产出的 JSON,loro.js读取 Rust 产出的 JSON 时报counter is out of range: NaN。本文基于仓库源码与测试,完整讲解这一格式差异、修复后的导出/导入行为、旧格式兼容策略,以及对应的类型与校验实现。

一、问题背景:为什么 move / set 需要 lamport 元素 ID

在 CRDT 的可移动列表中,每个元素一经创建就拥有一个唯一且不可变的创建 ID(counter 格式,如3@0)。但 move / set 操作在定位目标元素时,使用的是该元素在创建时刻的 Lamport 时间戳,而不是 counter。这是因为:

  • move / set 属于"后发生的操作",需要引用列表中某个历史元素;
  • 在 Lamport 时钟体系下,lamport能稳定表达"该元素在哪个逻辑时刻被创建",配合peer即可全局唯一标识元素,且无需维护 counter 与元素之间的动态映射。

这一点在核心实现中有直接体现:crates/loro-internal/src/container/list/list_op.rs中MovableListOp::Move与MovableListOp::Set携带的字段正是elem_id: IdLp(L29-L32),而crates/loro-common/src/lib.rs中IdLp结构体即{ peer, lamport }对(L525 附近),与ID(counter 格式)是两种不同的标识类型。

二、核心变更:导出统一为L{lamport}@{peer}

本次 changeset(.changeset/loro-js-json-movable-elem-id.md)将loro.js的 JSON 更新导出格式对齐到 Rust 与loro-crdt:

  • 修复前:exportJsonUpdates将 move / set 的elem_id写成{lamport}@{peer}(如26@0);
  • 修复后:统一写成L{lamport}@{peer}(如L26@0)。

对应实现位于 loro-js/src/runtime/document.ts:导出movable-list-move与movable-list-set操作时,都通过formatJsonIdLp格式化elem_id。该函数(L10175-L10180)的实现为:

function formatJsonIdLp( id: { readonly peer: bigint; readonly lamport: number }, peerMap?: JsonPeerMap, ): JsonIdLp { return `L${id.lamport}@${(peerMap?.get(id.peer) ?? id.peer).toString()}` as JsonIdLp; }

注意两点细节:

  1. peer 映射(peer compression):exportJsonUpdates默认开启 peer 压缩(withPeerCompression = true,见 document.ts),因此导出的elem_id中@后面的部分可能是指向peers数组的索引,而非原始 peer 值;formatJsonIdLp通过peerMap?.get(id.peer) ?? id.peer完成映射。
  2. lamport 取自元素创建 ID:formatJsonIdLp接收的是元素 ID 的{ peer, lamport },lamport 与元素一一对应,因此格式稳定、可被对方直接解析。

三、类型层面:elem_id字段类型改为JsonIdLp

变更同步反映在公开 TypeScript 类型上。在 loro-js/src/runtime/types.ts 中:

export type JsonOpID = `${number}@${PeerID}`; /** A lamport-based element ID, `L{lamport}@{peer}`, as used by movable-list moves and sets. */ export type JsonIdLp = `L${number}@${PeerID}`;

JsonIdLp是带L前缀的模板字面量类型,从类型系统层面锁死"可移动列表元素 ID 必须以L开头"的约束。对应的操作联合类型(L97-L103)中,move 与 set 两个分支都使用elem_id: JsonIdLp:

| { readonly type: "move"; readonly from: number; readonly to: number; readonly elem_id: JsonIdLp } | { readonly type: "set"; readonly elem_id: JsonIdLp; readonly value: JsonValue }

与之对比,普通操作的 ID(如 text/list 的start_id)仍使用无前缀的JsonOpID = ${number}@${PeerID}(见 L83 的 delete 分支)。类型系统由此精确区分了"counter 类 ID"与"lamport 类 ID"两种标识体系。

四、导入侧:接受L格式,同时兼容旧格式

importJsonUpdates(document.ts)解析 move / set 时,通过parseIdLp处理elem_id(L10397-L10422)。关键实现(L10275-L10277):

// Rust writes `L{lamport}@{peer}`; loro.js 0.2.1 and earlier omitted the `L`. const parseIdLp = (value: unknown): CodecId => parseId(typeof value === "string" && value.startsWith("L") ? value.slice(1) : value);

这段注释与代码揭示了完整的兼容策略:

  • 优先解析新格式:以L开头的字符串先剥掉前缀,再按{lamport}@{peer}解析;
  • 兼容旧格式:不带L前缀(0.2.1 及更早版本导出的数据)同样被接受,不会因升级导致旧数据无法导入;
  • 解析结果被还原为elementId: { peer, lamport },进入内部movable-list-move/movable-list-set操作内容。

也就是说,本次变更做到了"导出只写新格式、导入新旧通吃",避免破坏既有loro.js 0.2.x生态中已产出的 JSON 更新数据。

五、旧问题的根因:counter is out of range: NaN

changeset 中提到的报错counter is out of range: NaN,根因在于格式标识错位:

  • Rust 侧IdLp的解析实现(crates/loro-common/src/id.rs)严格要求字符串必须以L开头,否则直接返回DecodeError("Invalid ID format"):
impl TryFrom<&str> for IdLp { type Error = LoroError; fn try_from(value: &str) -> Result<Self, Self::Error> { if value.split('@').count() != 2 || !value.starts_with('L') { return Err(LoroError::DecodeError("Invalid ID format".into())); } ... } }
  • 其Display与Debug输出同样为L{lamport}@{peer}(L16-L32),可见L前缀是 Rust 端IdLp的强制编码约定。

因此,当loro.js以{lamport}@{peer}形式导出elem_id时,Rust 解析失败;反过来,loro.js读取 Rust 产出的L{lamport}@{peer}时,旧版本代码未剥离L前缀,把L26这类内容当作纯数字 counter 解析,从而产生NaN并触发counter is out of range错误。修复后的parseIdLp正是为这一方向补齐了前缀处理。

六、MoonBit 侧的对应实现:格式约定跨语言一致

仓库中 MoonBit 的独立 codec 实现(moon/loro_codec)同样遵循L前缀约定,可作为格式一致性的旁证:

  • 导出:moon/loro_codec/json_schema_export_helpers.mbt的idlp_string_with_peer_index生成"L" + lamport + "@" + peer_index(L29-L36);changes_json_helpers.mbt的idlp_string同样拼接"L" + lamport + "@" + peer(L7-L9);
  • 导入:moon/loro_codec/json_schema_import_ops_movable_list.mbt对 move / set 均调用jsonschema_import_parse_idlp解析elem_id(L34-L45);
  • 编码层:change_block_encode_ops_values.mbt在将 move / set 写入 change block 时,分别提取elem_id.peer()注册进 peer 表、取elem_id.lamport()写入(L30-L41)。

这印证了L{lamport}@{peer}是 Loro 全栈(Rust 核心、MoonBit codec、loro.js)统一的 ID 文本表示。

七、测试佐证:Rust fixture 与非法操作用例

仓库中的测试与 fixture 直接覆盖了本次变更:

  1. Rust 产出 JSON 的读取测试数据:loro-js/tests/fixtures/rust/updates.json 中记录了 Rust 导出的可移动列表 move 操作,elem_id即为"L26@0"格式,loro.js的差分测试需能正确导入这类数据。

  2. 非法操作校验用例:loro-js/tests/movable-list-invalid-ops.test.ts 大量使用elem_id: "L1@0"、"L99@0"、"L0@1"等L前缀 ID 构造伪造操作,验证导入端对不存在的元素 ID(L99@0)、跨文档元素(L4@0)、越界 move等异常场景会正确拒绝(assertRejected),说明解析L格式的同时,合法性校验仍然严格生效。

  3. 旧数据兼容测试:loro-js/tests/legacy-data.test.ts 专门覆盖"由 loro.js 0.2.1 写出的旧 fixture 数据,升级后的版本要按 Rust 的方式读取",与本次"旧格式仍可导入"的策略相互印证。

八、升级影响与实操建议

对使用loro.js的开发者,本次变更的影响面如下:

  • 导出侧(写方):exportJsonUpdates产出的 JSON 中,MovableList 的 move / set 操作elem_id一律变为L{lamport}@{peer}。若你的下游消费者是 Rust /loro-crdt或 MoonBit codec,此变更直接消除Invalid ID format解析失败;
  • 导入侧(读方):importJsonUpdates同时接受L{lamport}@{peer}(新)与{lamport}@{peer}(旧)两种格式,旧数据无需重写即可继续导入;
  • 类型侧:elem_id的类型从普通字符串收紧为JsonIdLp模板字面量类型,TypeScript 会在编译期提示手写格式错误的 ID;
  • 无需迁移的存量数据:由于旧格式在导入端仍被接受,已导出的 JSON 更新文件不强制重新生成;但新导出的文件应默认采用L前缀格式,以与 Rust /loro-crdt保持双向兼容。

若需验证修复效果,可在仓库中运行loro.js的测试套件(cd loro-js && pnpm test),其中movable-list.test.ts、movable-list-invalid-ops.test.ts、legacy-data.test.ts覆盖了导出格式、非法 ID 拒绝与旧数据兼容三条主路径。

  • 后端

【免费下载链接】loro

Make your JSON data collaborative and version-controlled with CRDTs

项目地址:https://gitcode.com/gh_mirrors/lo/loro
点击查看免费下载
上一篇:Voron Switchwire 3D 打印机项目教程
下一篇:【免费下载】 AI Toolkit for Visual Studio Code 使用教程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询