2026/10/10 5:10:44

loro.js 与 Rust 可移动列表 JSON 互操作修复:elem_id 改用 `L{lamport}@{peer}` 格式

loro.js 与 Rust 可移动列表 JSON 互操作修复:elem_id 改用 `L{lamport}@{peer}` 格式 后端【免费下载链接】loroMake your JSON data collaborative and version-controlled with CRDTs项目地址https://gitcode.com/gh_mirrors/lo/loro点击查看免费下载loro.js的 JSON 导出exportJsonUpdates此前将可移动列表MovableListmove / set 操作的元素 ID 写成{lamport}{peer}而 Rust 核心与loro-crdt一律使用带L前缀的L{lamport}{peer}导致双向互操作双双失败Rust 拒绝loro.js产出的 JSONloro.js读取 Rust 产出的 JSON 时报counter is out of range: NaN。本文基于仓库源码与测试完整讲解这一格式差异、修复后的导出/导入行为、旧格式兼容策略以及对应的类型与校验实现。一、问题背景为什么 move / set 需要 lamport 元素 ID在 CRDT 的可移动列表中每个元素一经创建就拥有一个唯一且不可变的创建 IDcounter 格式如30。但 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: IdLpL29-L32而crates/loro-common/src/lib.rs中IdLp结构体即{ peer, lamport }对L525 附近与IDcounter 格式是两种不同的标识类型。二、核心变更导出统一为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}如260修复后统一写成L{lamport}{peer}如L260。对应实现位于 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; }注意两点细节peer 映射peer compressionexportJsonUpdates默认开启 peer 压缩withPeerCompression true见 document.ts因此导出的elem_id中后面的部分可能是指向peers数组的索引而非原始 peer 值formatJsonIdLp通过peerMap?.get(id.peer) ?? id.peer完成映射。lamport 取自元素创建 IDformatJsonIdLp接收的是元素 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格式同时兼容旧格式importJsonUpdatesdocument.ts解析 move / set 时通过parseIdLp处理elem_idL10397-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: NaNchangeset 中提到的报错counter is out of range: NaN根因在于格式标识错位Rust 侧IdLp的解析实现crates/loro-common/src/id.rs严格要求字符串必须以L开头否则直接返回DecodeError(Invalid ID format)impl TryFromstr for IdLp { type Error LoroError; fn try_from(value: str) - ResultSelf, 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_indexL29-L36changes_json_helpers.mbt的idlp_string同样拼接L lamport peerL7-L9导入moon/loro_codec/json_schema_import_ops_movable_list.mbt对 move / set 均调用jsonschema_import_parse_idlp解析elem_idL34-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 直接覆盖了本次变更Rust 产出 JSON 的读取测试数据loro-js/tests/fixtures/rust/updates.json 中记录了 Rust 导出的可移动列表 move 操作elem_id即为L260格式loro.js的差分测试需能正确导入这类数据。非法操作校验用例loro-js/tests/movable-list-invalid-ops.test.ts 大量使用elem_id: L10、L990、L01等L前缀 ID 构造伪造操作验证导入端对不存在的元素 IDL990、跨文档元素L40、越界 move等异常场景会正确拒绝assertRejected说明解析L格式的同时合法性校验仍然严格生效。旧数据兼容测试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 拒绝与旧数据兼容三条主路径。赞分享后端【免费下载链接】loroMake your JSON data collaborative and version-controlled with CRDTs项目地址https://gitcode.com/gh_mirrors/lo/loro点击查看免费下载相关推荐BrowserSkill 指南让 AI Agent 复用已登录浏览器浏览器自动化无需重新登录BrowserSkill 指南让 AI Agent 复用已登录浏览器浏览器自动化无需重新登录 是否遇到过 AI Agent 想操作浏览器却卡在请先登录后端tiptap表格编辑复杂表格操作与格式化的完整方案tiptap表格编辑复杂表格操作与格式化的完整方案 表格扩展基础架构 tiptap表格功能由 packages/extension table/src/ind前端富文本UI组件插件系统MapAnything深度解析通用前馈式度量三维重建技术方案MapAnything深度解析通用前馈式度量三维重建技术方案 MapAnything是一款开创性的通用前馈式度量三维重建框架通过统一的Transformer上一篇Voron Switchwire 3D 打印机项目教程下一篇【免费下载】 AI Toolkit for Visual Studio Code 使用教程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考