- 后端
【免费下载链接】loro
Make your JSON data collaborative and version-controlled with CRDTs
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; }注意两点细节:
- peer 映射(peer compression):
exportJsonUpdates默认开启 peer 压缩(withPeerCompression = true,见 document.ts),因此导出的elem_id中@后面的部分可能是指向peers数组的索引,而非原始 peer 值;formatJsonIdLp通过peerMap?.get(id.peer) ?? id.peer完成映射。 - 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 直接覆盖了本次变更:
Rust 产出 JSON 的读取测试数据:loro-js/tests/fixtures/rust/updates.json 中记录了 Rust 导出的可移动列表 move 操作,
elem_id即为"L26@0"格式,loro.js的差分测试需能正确导入这类数据。非法操作校验用例: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格式的同时,合法性校验仍然严格生效。旧数据兼容测试: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
相关推荐
BrowserSkill 指南:让 AI Agent 复用已登录浏览器,浏览器自动化无需重新登录
BrowserSkill 指南:让 AI Agent 复用已登录浏览器,浏览器自动化无需重新登录 是否遇到过 AI Agent 想操作浏览器,却卡在"请先登录"
后端tiptap表格编辑:复杂表格操作与格式化的完整方案
tiptap表格编辑:复杂表格操作与格式化的完整方案 表格扩展基础架构 tiptap表格功能由 packages/extension table/src/ind
前端富文本UI组件插件系统MapAnything深度解析:通用前馈式度量三维重建技术方案
MapAnything深度解析:通用前馈式度量三维重建技术方案 MapAnything是一款开创性的通用前馈式度量三维重建框架,通过统一的Transformer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考