Sign in

    moon_ycrdt

    Yjs-compatible CRDT for MoonBit: reads and writes Yjs v1 updates and y-protocols sync messages, byte-identical to Yjs.

    crdt
    yjs
    collaboration
    lib0
    Download zip
    Author
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    5 hours ago
    Downloads
    21

    No dependencies

    #moon_ycrdt

    用 MoonBit 实现的、与 Yjs 二进制格式互通的 CRDT 库。

    MoonBit 程序可以直接读写 Yjs 的更新格式(update format v1)和 y-protocols 同步消息, 与浏览器里基于 Yjs 的编辑器(Tiptap、ProseMirror、CodeMirror 等的 Yjs 绑定)、 y-websocket 服务以及 Yrs 后端交换同一份文档。

    English summary — moon_ycrdt is a MoonBit CRDT library that reads and writes the Yjs v1 binary update format and the y-protocols sync messages, so MoonBit programs can collaborate with Yjs-based editors, y-websocket servers and Yrs backends. Text, Map and Array editing produce byte-identical updates to Yjs; content it does not model (formatting, embeds, XML, sub-documents) is preserved. Every expected value in the test suite is generated by the official Yjs packages.

    模块 sayoi7799/moon_ycrdt · 许可证 Apache-2.0 · MoonBit 黑客松项目 · 更新日志

    #目录

    #定位

    本项目不比拼 CRDT 算法,而是做格式兼容。Yjs 生态已经有大量编辑器绑定、同步服务和存储后端, MoonBit 程序只要能产出和消费同样的字节,就能直接接入。因此实现处处以官方 Yjs 的行为为准: 不只是"能互相读懂",本地编辑产出的每一条更新都与 Yjs 在同样操作下产出的逐字节相同, 测试的期望值全部由官方 Yjs 生成。

    #和 MoonBit 生态中已有 CRDT 库的区别

    moon_ycrdtdowdiness/event-graph-walkermizchi/converge
    算法YATA(Yjs 的算法)eg-walker + FugueMax受 EG-Walker 启发
    同步格式Yjs update v1 二进制、Yjs 状态向量、y-protocols 消息自有的同步消息自有的同步格式
    能否直接和 Yjs / Yrs / y-websocket 交换数据能不能不能
    侧重点互通:字节级兼容算法与文档容器(树、撤销等)本地优先同步引擎(含 BFT 校验等)

    (右两列根据各自 mooncakes 页面的描述整理。)它们和本库并不冲突:需要 MoonBit 内部的高级协同功能时 可以选它们,需要和现有 Yjs 系统对接时选 moon_ycrdt。

    #安装

    在自己的 MoonBit 项目里添加依赖(需要本模块已发布到 mooncakes.io):

    moon add sayoi7799/moon_ycrdt

    然后在用到的包的 moon.pkg 里导入需要的子包:

    import { "sayoi7799/moon_ycrdt/lib0", "sayoi7799/moon_ycrdt/doc", "sayoi7799/moon_ycrdt/types", "sayoi7799/moon_ycrdt/protocol", }

    #快速上手

    下面的代码与 tests/readme_test.mbt 中的测试一致,每次 moon test 都会运行。

    // two replicas, each with its own 32-bit client id
    let alice = @doc.Doc::new(1001)
    let bob = @doc.Doc::new(2002)

    // every local transaction produces a Yjs v1 update, like doc.on('update')
    let outbox : Array[Bytes] = []
    alice.on_update((update, is_local) => if is_local { outbox.push(update) })

    let text = @types.Text::new(alice, "doc")
    text.insert(0, "Hello world")
    text.delete(5, 6)
    @types.YMap::new(alice, "settings").set("theme", String("dark"))
    @types.YArray::new(alice, "list").push([@lib0.Any::int(1), String("two")])

    // apply updates from MoonBit or Yjs; missing dependencies wait in a queue
    for update in outbox {
    bob.apply_update(update)
    }
    // bob's text is now "Hello"

    // state-vector sync: send only what the other side is missing
    let diff = @doc.encode_state_as_update(
    alice,
    target_state_vector=bob.encode_state_vector(),
    )
    bob.apply_update(diff)

    apply_update 遇到格式错误的输入会抛出 @lib0.DecodeError,并且不会修改文档。

    #富文本

    Text 支持格式属性和 Quill 风格的 delta(toDelta / applyDelta),代码与 tests/readme_test.mbt 中的测试一致,期望值由 Yjs 运行同样的步骤得到:

    let doc = @doc.Doc::new(1)
    let text = @types.Text::new(doc, "doc")
    text.insert(0, "Hello world")
    // format a range, insert with exactly the given formatting
    text.format(0, 5, [("bold", Bool(true))])
    text.insert_with_attributes(11, "!", [("italic", Bool(true))])
    // a Quill-style delta: skip 6 positions, then insert coloured text
    text.apply_delta([Retain(6, []), Insert(String("brave "), [("color", String("red"))])])
    // read the formatted text back, as a delta and as JSON
    let delta = text.to_delta()
    let json = @types.delta_to_json(delta).stringify()
    // [{"insert":"Hello","attributes":{"bold":true}},{"insert":" "},
    // {"insert":"brave ","attributes":{"color":"red"}},{"insert":"world"},
    // {"insert":"!","attributes":{"italic":true}}]
    let parsed = @types.delta_from_json(@json.parse(json))

    属性是有序的 (键, Any) 列表,值为 Null 表示取消该属性。insert 不带属性时新文本继承所在位置的格式, insert_with_attributes 则只使用给定的格式(没有列出的当前属性会被关掉,插入之后再恢复)。

    #更新格式 v2 与不加载文档的更新操作

    Yjs 有两种更新格式:v1 把字段顺序写进一个字节流,v2 把同一种字段(client id、clock、info 字节、字符串、长度……) 各放进一列,用游程编码压缩,通常更短。两种格式描述的是同一批 struct,本库都支持,并与 Yjs 逐字节对照:

    功能v1v2
    整个文档的状态Doc::encode_state_as_update(sv?)Doc::encode_state_as_update_v2(sv?)
    应用更新Doc::apply_updateDoc::apply_update_v2
    事务级更新事件Doc::on_updateDoc::on_update_v2
    解码 / 编码为 Updatedecode_update / Update::encodedecode_update_v2 / Update::encode_v2

    不需要加载文档、直接处理更新的字节(update 包,v1 和 v2 各一个,后缀 _v2):

    • merge_updates(updates):合并成一个更新,重叠的部分切开,空洞补上 Skip,删除集取并集(Y.mergeUpdates);
    • diff_update(update, state_vector):只留下对方还没有的部分(Y.diffUpdate);
    • encode_state_vector_from_update(update):这个更新单独能推出的状态向量(Y.encodeStateVectorFromUpdate)。

    摘自 tests/readme_test.mbt:

    let v2 = alice.encode_state_as_update_v2()
    bob.apply_update_v2(v2)

    // work on updates as bytes: no document is needed
    let merged = @update.merge_updates(updates)
    let sv = @update.encode_state_vector_from_update(merged)
    let missing = @update.diff_update(merged, bob.encode_state_vector())

    几个与 Yjs 一致的细节,使用时可能会遇到:

    • Yjs 读出一个更新后,会忘记"这个 map 条目带 key"那一位(有 origin 的条目线上不写 key,只在 info 字节里留一位)。 所以凡是"读出再写回"的操作(合并、差量、v1 与 v2 转换,以及带 pending 数据的 encode_state_as_update)写出的 字节里这一位都是清掉的;直接解码再编码(decode_update 后 encode)则保留原样。
    • merge_updates 只有一个输入时原样返回输入,不重新编码。
    • Embed 和格式属性的值在 v1 里是 JSON 文本,在 v2 里是 Any。本库把它们存成 JSON 文本,所以 v2 里不能用 JSON 表示的值 (Infinity、undefined、字节数组等)读出来会变成 JSON 能表示的样子,重新编码后与原字节不同。

    #对接 y-websocket

    protocol 包实现了 y-protocols 的同步消息和 y-websocket 的外层封装。连接建立后的流程 (同样摘自 tests/readme_test.mbt,那里用另一个 Doc 充当服务端):

    let doc = @doc.Doc::new(42)

    // 1. on connect, send SyncStep1 (our state vector)
    send(@protocol.Sync(@protocol.sync_step1(doc)).encode())

    // 2. broadcast every local change as an Update message
    doc.on_update((update, is_local) => {
    if is_local {
    send(@protocol.Sync(@protocol.update_message(update)).encode())
    }
    })

    // 3. handle every received frame; send back the reply, if any
    // (SyncStep1 is answered with SyncStep2; SyncStep2 / Update are applied)
    if @protocol.handle_ws_message(doc, frame) is Some(reply) {
    send(reply)
    }

    send 和 frame 由所用的 websocket 客户端提供。awareness 等非同步消息由 handle_ws_message 忽略,可以用 decode_ws_message 拿到原始字节自行处理。

    #可运行示例

    两个副本并发编辑后收敛,结果以 base64 输出:

    moon run examples/concurrent

    Yjs → MoonBit → Yjs 往返:加载 Yjs 写的文档,用状态向量做差量同步,乱序投递的更新先进入 pending、 依赖到齐后自动应用,最后导出给 Yjs。输入由 Yjs 生成:

    cd scripts && npm ci && cd ..

    node scripts/make_example_input.mjs > input.txt

    moon run examples/roundtrip -- "$(cat input.txt)"

    不带参数运行时使用内置的样例文档(同一脚本生成)。

    用 Node + Yjs 读取示例的输出(示例最后一行是 update (base64): ...):

    node scripts/read_update.mjs "<示例输出的 base64>" doc

    read_update.mjs 做的事情就是:

    import * as Y from 'yjs' const doc = new Y.Doc() Y.applyUpdate(doc, new Uint8Array(Buffer.from(base64, 'base64'))) console.log(doc.getText('doc').toString()) // "hello, Alice, Bob world"

    #协作编辑演示:y-websocket 服务端

    examples/ws_server 是一个用本库写的 y-websocket 服务端,examples/web 是连接它的网页:官方的 yjs 和 y-websocket 客户端加一个 <textarea>。两个浏览器窗口里的文本实时同步,服务端重启后内容还在。

    需要:MoonBit 工具链、Node.js 22(网页用 Vite 8 开发服务器)。服务端用 moonbitlang/async (它自带的 http 和 websocket 包),只支持 native 后端。它的 README 写的是 Linux / macOS,实际上也在 Windows 11 上编译并运行过;CI 在 ubuntu 上跑。因此 examples/ws_server 是独立的模块(有自己的 moon.mod 和 moon.work), 不是 moon_ycrdt 的依赖,也不进发布包。

    1. 启动服务端(在一个终端里):

    cd examples/ws_server

    moon run --target native . -- --port 1234 --data ./data

    看到 y-websocket server listening on ws://127.0.0.1:1234 就绪。参数都可省略:

    参数默认值含义
    --host127.0.0.1监听地址
    --port1234监听端口(y-websocket 的默认端口)
    --datadata保存文档的目录,不存在会创建
    --save-interval2有改动的房间每隔几秒保存一次

    2. 启动网页(在另一个终端里):

    cd examples/web

    npm ci

    npm run dev

    3. 打开两个浏览器窗口,都访问 http://localhost:5173/?room=demo。在一个窗口里输入,另一个窗口里文字立即出现; 两边同时输入也会收敛。换一个 ?room= 就是另一份文档。页面上的状态点是绿色表示已连上服务端。

    4. 验证重启后内容还在:在服务端的终端按 Ctrl-C,再用同一条命令启动,然后刷新浏览器窗口,文字还在。

    服务端做的事:

    • 连接路径就是房间名(ws://host:port/<房间名>),每个房间一个 Doc;房间名最长 40 个 UTF-16 码元。
    • 新连接先收到服务端的 SyncStep1;客户端的 SyncStep1 由 handle_sync_message 回复 SyncStep2;SyncStep2 和 Update 应用到文档,Doc::on_update 把结果作为 Update 广播给同房间的其他连接(不回给发送者)。
    • awareness 消息不解析,原样转发给同房间的其他连接。服务端不记录各客户端的 awareness 状态,所以新连接看不到之前 已在线的光标(演示的网页没有用 awareness)。
    • 每个房间保存为 <data>/<房间名转义后>.ydoc,内容是 encode_state_as_update 的结果,所以可以直接用 Y.applyUpdate 读取。先写 .tmp 再改名,中途崩溃不会留下写了一半的文件;第一个客户端进入房间时从文件恢复。 保存间隔内的改动在进程被强杀时会丢失。
    • 没有认证、没有 TLS,只适合本机或可信网络。

    网页的 ?server=ws://主机:端口 可以指向别的服务端,例如用官方的 y-websocket 服务端对比。网页里关闭了 BroadcastChannel(disableBc: true),否则同一浏览器的两个窗口会绕过服务端直接互通,看不出服务端在工作。

    编辑器用的是 textarea,不是 Tiptap / ProseMirror:它们的 Yjs 绑定用 Y.XmlFragment,本库还没有 Y.Xml* 的 API (见支持范围)。

    自动化测试 examples/web/test/e2e.mjs:启动编译好的服务端,用两个官方 y-websocket 客户端验证双向同步、并发收敛、 后加入的客户端、房间隔离、保存的文件能被 Yjs 读取,以及停止再启动服务端后文档仍在:

    cd examples/ws_server && moon build --target native

    cd examples/web && npm run e2e -- ../ws_server/_build/native/debug/build/sayoi7799/moon_ycrdt_ws_server/moon_ycrdt_ws_server.exe

    #API 速查

    包主要 API
    docDoc::new(client_id)、apply_update(bytes)、encode_state_as_update(sv?)、encode_state_vector()、state_vector()、on_update((update, is_local) => ...)、has_pending()、root_names(),以及 v2 的 apply_update_v2、encode_state_as_update_v2、on_update_v2;函数形式 @doc.apply_update、@doc.encode_state_as_update(doc, target_state_vector?)、@doc.encode_state_vector(和 _v2)
    typesText::new(doc, name):insert、insert_with_attributes、insert_embed、format、delete、to_string、length、to_delta、apply_delta;DeltaOp(Insert / Retain / Delete)、delta_to_json / delta_from_json<br>YMap::new(doc, name):set、get、has、delete、keys、entries、size<br>YArray::new(doc, name):insert、push、delete、get、to_array、length
    protocolSyncMessage(SyncStep1 / SyncStep2 / Update)的 encode / decode_sync_message;sync_step1、sync_step2、update_message、handle_sync_message;y-websocket:WsMessage、decode_ws_message、handle_ws_message
    lib0Encoder / Decoder(write_var_uint、write_var_int、write_var_string、write_any 等)、Any、encode_any / decode_any、to_hex / from_hex、DecodeError
    updatedecode_update / Update::encode(v2:decode_update_v2 / Update::encode_v2)、merge_updates、diff_update、encode_state_vector_from_update(各有 _v2)、StructRef、ItemRef、Content、DeleteSet、encode_state_vector / decode_state_vector

    值统一用 @lib0.Any 表示(与 lib0 的 Any 编码一一对应):Null、Undefined、Bool、Number(Double)、 BigInt、String、Array、Object(保留键的顺序)、Bytes。Any::int(n) 构造整数。 文本下标按 UTF-16 码元计算,与 JavaScript 字符串一致。

    #支持范围

    功能状态说明
    lib0 编码(varUint / varInt / varString / bytes / Any)和 v2 用的游程编码(Rle、UintOptRle、IntDiffOptRle、字符串)✅全部有官方 lib0 字节对照
    update v1 解码 / 编码✅Item、GC、Skip;9 种 content 全部可解码并原样写回
    update v2 解码 / 编码;encode_state_as_update_v2 / apply_update_v2 / on_update_v2✅与 encodeStateAsUpdateV2、applyUpdateV2、updateV2 事件和 convertUpdateFormat 逐字节一致
    merge_updates、diff_update、encode_state_vector_from_update(v1 和 v2)✅不加载文档;与 Yjs 同名函数逐字节一致,见更新格式 v2
    删除集、状态向量✅
    YATA 冲突解决✅与 Yjs 逐字节一致(见测试)
    依赖缺失的更新(pending)✅依赖到齐自动应用;encode_state_as_update 会带上 pending 数据(中间以 Skip 填补),中继不丢数据
    encode_state_as_update(doc, 状态向量?) / apply_update✅
    事务级增量更新事件✅Doc::on_update,字节与 Yjs 的 update 事件一致
    Y.Text 插入 / 删除 / 读取✅UTF-16 码元下标;切开代理对时与 Yjs 一样替换为 U+FFFD
    Y.Text 富文本:带属性插入、format、embed、to_delta、apply_delta✅每一步的 delta 和本地更新的字节都与 Yjs 一致;值可以是任意 JSON(对象、数组、数字、字符串、布尔)
    Y.Text 格式清理✅本地删除后的 cleanupFormattingGap,以及远端更新后 Yjs 另开一个事务做的清理(cleanupYTextAfterTransaction,产生只含删除的更新)都与 Yjs 一致
    Y.Map set / get / delete(Any 值、字节数组)✅
    Y.Array insert / push / delete / get✅定位、值的打包(连续普通值合成一个 Item、字节数组单独成 Item)和 push 的语义都与 Yjs 一致
    Y.Array 里创建和读写嵌套的 Y.Map / Y.Array❌嵌套的内容会保留、能合并(Yjs 在里面继续编辑,MoonBit 也能正确处理),但没有读写它的 API,get 读作 Undefined
    search marker 定位缓存(Y.Text / Y.Array)✅与 Yjs 的规则一致,见性能和"已知限制"
    y-protocols 同步消息与 y-websocket 外层封装✅与 y-protocols@1.0.7 逐字节对照
    awareness 的编码 / 解码❌awareness 消息原样透传,不解析;服务端示例也不记录各客户端的 awareness 状态
    不支持的 content(XML 类型、子文档、数组中的嵌套类型)✅ 保留原样存储并在编码时写回;Yjs 在这些类型内部继续编辑,MoonBit 也能正确合并
    Y.Xml* 的 API(包括 y-prosemirror / Tiptap 使用的 XmlFragment、XmlElement、XmlText)❌数据会保留。XmlText 的格式逻辑与 Text 相同,下一步可以复用
    快照、ychange 属性❌to_delta 不支持 snapshot 参数
    子文档加载、UndoManager、相对位置❌
    垃圾回收❌本地文档相当于 gc: false,但能解码对方发来的 GC 结构和 ContentDeleted
    y-websocket 服务端示例examples/ws_server:native 程序,房间广播和文件持久化,见协作编辑演示;不是库的一部分
    编辑器绑定示例examples/web 里的 textarea 绑定是示例代码;Tiptap / ProseMirror 需要 Y.Xml*,❌

    已知限制:

    • Yjs 的定位缓存(search marker)与带格式的文本:本库实现了 Yjs 的 search marker:每个 Y.Text / Y.Array 最多 80 个,选择最近的 marker、移动或替换最久未用的、本地编辑后平移、合并 Item 时调整、远端更新后清空, 规则逐条按 AbstractType.js 移植。这不只是为了速度:Yjs 里不带属性的 insert 和 delete 通过 marker 定位, 带属性的 insert、format 和 applyDelta 不用;marker 从目标位置附近开始,看不到它左边的格式项,所以带格式的 文本里"新文本继承什么格式"取决于 marker 在哪里。marker 属于某一个 JS 对象,规律是:
      1. 一个 YText 对象一旦整合过任何格式项(本地的或远端更新里的),marker 就被永久关闭,之后所有定位都从头 累积属性,结果是确定的。先调用 getText 再同步更新(编辑器绑定的通常用法)就是这种情形,本库与 Yjs 逐字节一致 (300 组随机并发场景和各组脚本)。
      2. 例外:根类型在内容到达之后才调用 getText。 Yjs 此时用一个新对象替换占位类型:marker 是开着的,而且这个 对象还没有"含格式"标志,所以不带属性的 insert 和 delete 看不到 marker 左边的格式;远端更新后的格式清理 也要等到整合第一个新的格式项才开始。本库实现了这两点,marker 在多次操作之间累积、被替换(超过 80 个)、 被远端更新清空的行为也一致:20 组各约 100 步的长序列与 Yjs 逐步一致(改动 marker 的上限、清空规则或最近 marker 的取舍,其中至少一个测试会失败)。"没有含格式标志所以不清理"这一条仍没有专门的测试覆盖。
    • 对象或数组形式的属性值:Yjs 在格式清理里用 === 比较解码出的值。同一个 JS 对象被两个格式项共用时 (例如 format 为恢复旧值会重用已有格式项的值对象)它们被认为相同;编码后的数据里看不出共用,本库把对象和数组 视为恒不相同。这只影响清理时多余格式项的取舍,不影响文本和格式的收敛。布尔、数字、字符串和 null 完全一致; "属性是否已经生效"的判断(equalAttrs)按 Yjs 的规则只比较一层。
    • 属性的键顺序:属性按给定的顺序写入,和 JS 对象一致,只是 JS 会把整数形式的键(如 "1")排在最前面按数值 升序,本库没有模拟。
    • delta 里的嵌套类型:文本里嵌入的共享类型在 to_delta 里读作 Insert(Undefined, …);to_delta 不支持 snapshot 和 ychange。
    • Yjs 自身的行为:并发编辑把一个 emoji 切成两半时,各个 Yjs 副本看到的文本可能不一致(一边保留 emoji,另一边是 两个 U+FFFD),与格式无关。我们在随机测试里观察到过(最初的 300 组里有 14 组),本库不试图修正它。富文本随机测试 因此把位置限制在字符边界上;代理对被切开的情形由边界情况测试覆盖。
    • Yjs 的数组不接受 undefined 和 bigint;本库照常把它们编码进数组(Yjs 能解码)。
    • 不检测 client id 冲突(Yjs 会在冲突时换 id)。每个副本应使用随机的 32 位 client id, examples/roundtrip 演示了用 @env.rand 生成。
    • 更新格式 v2 里的 JSON 值:Embed 和格式属性的值在 v1 里是 JSON 文本,在 v2 里是 Any。本库存成 JSON 文本,所以 v2 里不能用 JSON 表示的值(Infinity、undefined、字节数组)读出来会变成 JSON 能表示的样子,重新编码后与原字节不同。 v2 编码器里记住用过的键的那一行在 Yjs 里是被注释掉的,所以每个键都当新键写,本库照搬;解码时两种都认。
    • merge_updates:两个输入在同一位置分别是 GC 和 Item 时,Yjs 的排序比较函数本身不自洽,本库用稳定的插入排序, 没有照搬 V8 的排序实现;"延长已有 Skip"这个分支没有测试能走到(在实际输入里几乎不会出现)。
    • 性能:随机位置编辑已经与 Yjs 同一量级(见性能),但长文档(10 万字符)上的随机插入删除比 Yjs 慢约 2.3 到 2.9 倍,加载和导出整个文档约慢 2 倍。search marker 把沿链表走的步数降到每次约 18 步;剩下的主要是每个 client 的 struct 数组在中间插入(拆分)和删除(合并)一项时要移动后面的元素。

    #工作原理

    更新格式:一条 v1 更新由两部分组成——按 client 分组的 struct 列表和删除集。每个 struct 的第一个字节是 info:低 5 位是 content 类型,0x80 / 0x40 / 0x20 三位分别表示有 origin、有 rightOrigin、有 Map 键名。 只有两个 origin 都没有时才写父节点,否则父节点从邻居 Item 继承。删除不占 clock,用"client → (clock, 长度) 区间" 的删除集表示。lib0 的整数是变长编码;有符号整数用"符号位 + 绝对值"(−1 编成 41,不是 zigzag)。

    struct store:每个 client 一个按 clock 连续排列的数组,二分查找定位;需要从中间引用某个 Item 时把它切成两段。 事务结束时把相邻且可合并的 Item 合并回去,只检查本事务改动过的位置。

    YATA:每个 Item 记住插入时的左邻居(origin)和右邻居(rightOrigin),之后不再改变。integrate 时先在两者之间 找出所有并发插入的 Item:origin 相同时 client id 小的在左;某个 Item 的 origin 落在已扫过的区域里,说明它属于 某个并发 Item 的后续,整段跳过。所以不论更新以什么顺序到达,所有副本得到同一个序列。Map 的每个键是一条 Item 链,最右边的是当前值。

    pending:依赖(同 client 更早的 clock、origin、rightOrigin、父 Item)缺失的 struct 进入 pending 队列, 每次应用新更新时重试;删除集中还不存在的区间也会挂起。

    本地编辑与 Yjs 一致:插入的定位规则(例如 Text 会越过右侧已删除的 Item、Array 不会,push 接在最后一个 Item 后面)决定了新 Item 的 origin,这些细节都照 Yjs 实现,所以本地更新与 Yjs 逐字节相同。

    富文本:格式不是"范围标记",而是和文本放在同一条链表里的 Item(Format(key, value),长度 1,不计入文本长度), 某个位置的属性等于它左边所有格式项依次作用的结果,值为 null 表示取消。insert 带属性时,先在位置处写入与当前值不同的 格式项,再插入内容,最后写入"恢复旧值"的格式项;已经说了同样的话的格式项会被复用,避免产生冗余。format 在范围内 删掉同 key 的旧格式项,范围末尾恢复被覆盖的旧值。冗余的格式项在两个时机被清理:本地 delete 之后清理删除区间里的间隙; 远端更新之后,Yjs 会另开一个事务做清理(只含删除的更新,副本之间照常同步),本库照做,所以 MoonBit 副本中继出去的 字节与 Yjs 副本相同。属性值以 JSON 文本保存,和 Yjs 写入线上的格式一样。

    #性能

    基准测试在 benchmarks 包里,工作负载使用固定种子的伪随机数:

    moon bench -p sayoi7799/moon_ycrdt/benchmarks --target native --release

    参考数据:AMD Ryzen 9 7945HX、Windows 11、native release,单位为每次运行的平均时间。三列依次是:事务结束时对整个 client 数组做合并的版本(只检查本事务改动过的位置之前)、定位还是沿链表从头走的版本(加 search marker 之前)、现在 (定位从最近的 search marker 开始)。

    基准合并优化前marker 之前现在
    lib0:编解码 1 万个 varUint 和 1000 个 Any 对象1.80 ms1.78 ms1.74 ms
    解码并重新编码一个 4000 项的碎片化文档2.96 ms2.94 ms2.72 ms
    在文本末尾逐字输入 2000 个字符(每字一个事务)1.61 ms2.60 ms2.58 ms
    2000 次随机位置插入96.8 ms9.21 ms4.82 ms
    4000 字的文本里 1000 次随机删除208 ms18.8 ms7.96 ms
    加载一个 4000 项的碎片化文档(apply_update)4.92 ms6.53 ms6.55 ms
    导出这个文档的完整状态1.66 ms1.59 ms1.56 ms
    3 个副本 × 300 轮并发编辑并互相同步18.7 ms9.74 ms9.63 ms
    Map:在 100 个键上 set 5000 次430 ms6.56 ms6.33 ms
    Array:push 2000 次再随机插入 500 次13.8 ms6.34 ms6.00 ms
    长文本:10 万字符,5000 次随机插入 + 5000 次随机删除—668 ms188 ms
    长数组:10 万个值,5000 次随机插入 + 5000 次随机删除—744 ms192 ms

    "—"表示当时还没有这项基准。"marker 之前"一列是在加 marker 的同一台机器上重新测的,所以和第一列不是同一批数据。

    长文本的工作负载:先一次插入 10 万个字符,再每个事务插入两个字符 5000 次、删除 1-3 个字符 5000 次,位置由固定种子的 伪随机数决定;这些编辑把文本拆成约 1 万个 Item。长数组同理。

    为什么只快了 3.5 到 4 倍:marker 解决的是沿链表找位置——在这个负载里每次定位只走约 18 步(没有 marker 时平均要走 Item 总数的一半)。但耗时仍随 Item 数增长(5000 次编辑 188 ms,10000 次 775 ms),因为每个 client 的 struct 数组在中间 插入(拆分 Item)和删除(合并相邻 Item)一项时,要移动它后面所有元素的引用:在一个 3 万个对象引用的数组里, 一次插入加一次删除实测约 42 µs。这是 Yjs 同样的结构(它在 JS 里用 splice),可以用分块的存储取代,但会改动 整个 struct store,不在这一轮的范围内。

    #与 Yjs 的对比

    同样的负载在 Yjs 上跑一遍:scripts/bench_yjs.mjs 用同样的伪随机数(benchmarks/workloads.mbt 里的线性同余生成器)、 同样的种子和规模,每个编辑一个事务;两边最后的文本和数组长度一样(99899 和 94963)。每个负载在新文档里跑,先热身 3 次, 至少跑 10 次、满 1 秒,取一次运行的平均时间。Yjs 测了 gc: true(默认)和 gc: false(本库的做法:删除的内容留着)。

    cd scripts && npm ci && cd ..

    node scripts/bench_yjs.mjs

    同一台机器(AMD Ryzen 9 7945HX、Windows 11),moon_ycrdt 是 native release,Yjs 13.6.33 跑在 Node v24.20.0(V8,有 JIT)上:

    基准moon_ycrdtYjs(gc: true)Yjs(gc: false)
    在文本末尾逐字输入 2000 个字符2.74 ms3.24 ms3.43 ms
    2000 次随机位置插入4.76 ms4.98 ms5.34 ms
    4000 字的文本里 1000 次随机删除7.85 ms8.06 ms8.46 ms
    加载一个 4000 项的碎片化文档6.43 ms3.19 ms3.00 ms
    导出这个文档的完整状态1.57 ms780 µs733 µs
    Map:在 100 个键上 set 5000 次6.39 ms8.39 ms8.17 ms
    Array:push 2000 次再随机插入 500 次5.99 ms6.06 ms5.86 ms
    长文本:10 万字符,5000 次随机插入 + 5000 次随机删除183 ms63.87 ms64.61 ms
    长数组:10 万个值,5000 次随机插入 + 5000 次随机删除186 ms79.80 ms77.11 ms

    怎么读这张表:
    • 逐字输入、短文档的随机插入和删除、Map、Array 与 Yjs 在同一量级,有的略快;
    • 加载(apply_update)和导出整个文档比 Yjs 慢约 2 倍,我没有分析原因;
    • 10 万字符 / 10 万个值上的随机插入删除慢约 2.3 到 2.9 倍。这一项我分析过:search marker 之后沿链表走的步数不是瓶颈, 剩下的是 struct 数组在中间插入和删除的开销(见上面"为什么只快了 3.5 到 4 倍")。
    • 两个运行时不同(native 与 JIT),表只说明量级,不是同一条件下的严格对比。

    #测试

    所有期望值都来自官方实现(yjs@13.6.33、lib0@0.2.119、y-protocols@1.0.7),不是手写的。

    类别内容位置
    lib0 编码每种编码的字节对照,含 0、127、128、负数、2^31 以上、2^53−1、空串、中文、emoji、BOM、−0、Infinity、bigint、孤立代理项lib0/lib0_test.mbt
    解码层12 个 Yjs 生成的更新(gc / 非 gc 文本、Unicode、各种 Map 值、格式与 embed、嵌套与 XML 类型、子文档、GC 结构、多 client、差量、从 Item 中间开始的差量、含 Skip 的合并更新、旧版 ContentJSON):解码结果与 Y.decodeUpdate 一致,再编码逐字节相同update/update_test.mbt
    Yjs → MoonBit15 个场景(含乱序、重复投递):文本、Map、状态向量与 Yjs 一致,MoonBit 重新编码的整体状态与 Yjs 的逐字节相同;幂等;差量同步tests/interop_test.mbt
    MoonBit → Yjscmd/export 导出 7 组编辑脚本(Unicode 文本、全部 Any 类型、2 / 3 副本并发、编辑含格式 / embed / XML / 子文档的 Yjs 文档、Y.Array 并发编辑、富文本格式与 delta);scripts/verify_moonbit.mjs 用 Yjs 验证:完整状态读回一致、打乱顺序应用增量更新后收敛、在 Yjs 上用相同 client id 重放同样的操作得到逐字节相同的状态、不支持的内容仍在;v2 的状态用 applyUpdateV2 读回、与 encodeStateAsUpdateV2 逐字节相同;merge_updates / diff_update(v1 和 v2)与 Yjs 逐字节相同(共 132 项检查)cmd/export、scripts/verify_moonbit.mjs
    边界情况55 个场景:在含 emoji、ZWJ 序列、组合字符的字符串里的每个 UTF-16 位置插入、删除 1 / 2 个码元(会切开代理对),空插入、删光再写、末尾连续输入、client id 0 与 2^32−1、两个副本切开同一个代理对;空文档、空更新;截断与损坏的输入必须报错且不改动文档tests/edge_test.mbt
    不支持内容的保留5 类(格式与 embed、XML、含嵌套类型的数组、子文档、gc 产生的 GC 结构):MoonBit 加载 Yjs 文档、编辑自己支持的部分、再接收 Yjs 在不支持类型内部的编辑,本地更新与最终状态都与 Yjs 逐字节相同tests/preserve_test.mbt
    并发收敛300 组随机场景(种子 1–300),2–4 个副本、同位置并发插入删除、对同一组键并发写入和删除 Map、随机抽取(有重复)乱序交换:每一步的文本和 Map 都与 Yjs 一致,每个本地更新的字节都与 Yjs 发出的一致(共 10914 步)tests/convergence_test.mbt
    Y.Array200 组随机场景(种子 1–200),2–3 个副本并发 insert / push / delete、乱序重复同步:每一步数组内容与每个本地更新的字节都与 Yjs 一致;读取 Yjs 写的嵌套内容tests/array_test.mbt
    y-protocols镜像一次 Yjs 会话中的一方:SyncStep1、SyncStep2、Update 以及 y-websocket 帧都与 y-protocols 逐字节相同;两个 MoonBit 副本只靠协议消息收敛protocol/protocol_test.mbt
    属性测试quickcheck,每条 300 例:任意交换顺序收敛;重复应用幂等;缺依赖的更新进入 pending,补齐后自动应用(pending 数据经编码中继不丢失)。文本加 Map 三条;Y.Array 和富文本(含格式、format、删除)各两条(收敛、幂等)tests/property_test.mbt、tests/property_array_rich_test.mbt
    富文本:delta 读取13 个 Yjs 文档(加粗、重叠、null 取消、对象 / 数组 / 数字 / 字符串值、embed、删除、Unicode、并发格式、属性键顺序):to_delta() 的 JSON 文本与 Yjs 的 JSON.stringify(toDelta()) 逐字符相同tests/rich_delta_test.mbt
    富文本:编辑脚本由 Yjs 逐步记录:带 / 不带属性插入和 embed(11 组 53 步)、format(11 组 47 步)、applyDelta(10 组 29 步)、并发格式化(6 组 67 步)、类型晚取(7 组 35 步)。每一步比较 delta 和本地更新的字节,远端更新后 Yjs 产生的清理更新也要一致tests/rich_scripts_test.mbt
    富文本:随机并发300 组固定种子(1–300),2–4 个副本,带属性的插入、embed、format(含 null)、删除、applyDelta,乱序重复同步直到不再产生新更新;共 8734 步,其中 149 次是 Yjs 的清理更新,每一步的 delta 和更新字节都与 Yjs 一致,最终所有副本一致tests/rich_scripts_test.mbt
    更新格式 v212 个样例更新经 convertUpdateFormatV1ToV2 转成 v2 后,解码结果与 v1 相同、两个方向写回的字节与 Yjs 相同;60 组文本和 Map、40 组 Y.Array、20 组富文本场景全程交换 v2 更新(每个本地更新是 Yjs updateV2 事件的字节,Yjs 写的 v2 更新都能应用);15 个互通场景里接收方在每一步之后的整体状态(v1 和 v2,含 pending 和被覆盖的 map 键)以及对途中状态向量的差量update/update_test.mbt、tests/v2_test.mbt
    不加载文档的更新操作36 个更新池(来自已记录的场景,加上它们的整体状态快照,含和不含 GC):按顺序合并、打乱并重复、只取一部分(留下空洞)、单个更新、空列表,再对合并结果做差量(包括落在 Item 中间的状态向量)、读出状态向量,v1 和 v2 都与 Yjs 逐字节相同tests/update_ops_test.mbt
    lib0 游程编码Rle、UintOptRle、IntDiffOptRle、字符串编码器:多组序列(游程、重复的 0、32 位边界、负差值、两种长度的字符串)与 lib0 逐字节相同,并能解码回去lib0/lib0_test.mbt
    search marker:长序列20 组富文本(根类型在格式化内容到达之后才取得,各约 100 次不带属性的插入、删除和远端更新,marker 被反复创建、移动、替换和清空)、15 组各 300 步的 Y.Array:每一步的 delta 与每个本地更新的字节都与 Yjs 一致tests/rich_scripts_test.mbt、tests/array_test.mbt
    search marker:模型对照不用 Yjs:随机操作的结果与朴素模型一致,每个 marker 的 index 与扫描链表算出的位置一致(对应 Yjs 源码里被注释掉的 "assure position"),含合并时的调整和远端更新后的清空tests/marker_test.mbt
    README 示例快速上手、富文本与 y-websocket 流程的代码tests/readme_test.mbt
    示例服务端两个官方 y-websocket 客户端经 MoonBit 服务端:双向同步、并发收敛、后加入的客户端、房间隔离;保存的文件能被 Y.applyUpdate 读取;服务端重启后文档仍在。另有房间名与文件名转义的单元测试examples/web/test/e2e.mjs、examples/ws_server/store_wbtest.mbt

    #开发

    CI(.github/workflows/ci.yml)在 ubuntu、macos、windows 上运行下面这些命令, 另有一个 job 用 Node 和固定版本的 Yjs 做互通验证,一个 job(ubuntu)检查并测试 examples/ws_server、 用官方 y-websocket 客户端跑端到端测试。本地复现:

    moon check --deny-warn --target all

    moon build --target all

    moon test --target all

    moon fmt --check

    moon info && git diff --exit-code

    MoonBit → Yjs 验证:

    moon run cmd/export --target native > fixtures/moonbit_out.json

    node scripts/verify_moonbit.mjs fixtures/moonbit_out.json

    #重新生成 fixtures

    • 生成脚本:scripts/gen_fixtures.mjs
    • 版本:yjs@13.6.33、lib0@0.2.119、y-protocols@1.0.7,固定在 scripts/package.json 和 package-lock.json
    • 输出:fixtures/*.json(可读的记录),以及内嵌同样数据的 MoonBit 文件 lib0/lib0_gen_test.mbt、update/updates_gen_test.mbt、protocol/protocol_gen_test.mbt、 tests/interop_gen_test.mbt、tests/convergence_gen_test.mbt、tests/edge_gen_test.mbt、 tests/preserve_gen_test.mbt、tests/arrays_gen_test.mbt、tests/rich_*_gen_test.mbt、 tests/readme_rich_gen_test.mbt、cmd/export/yjs_base_gen.mbt (wasm-gc 后端的测试读不了文件,所以数据生成为代码)

    cd scripts && npm ci && node gen_fixtures.mjs && cd .. && moon fmt

    生成是确定性的(随机场景使用固定种子),CI 会重新生成并检查与仓库中的文件一致。

    发布到 mooncakes.io 的包不含 fixtures/ 和 docs/(见 .moonignore):测试用的数据已经内嵌在 *_gen_test.mbt 里,不需要这些 JSON 文件,完整的 fixtures/ 在 GitHub 仓库中。

    #参考与许可证

    本项目用 MoonBit 重新实现 Yjs 的协议和行为,只参考协议与行为,不逐行翻译代码。

    项目许可证参考范围
    yjsMITupdate v1 格式、struct store、YATA integrate、Y.Text / Y.Map / Y.Array 的语义(含定位、格式清理、删除集、pending 处理);生成测试数据
    lib0MIT二进制编码(varUint、varInt、字符串、Any);生成测试数据
    y-protocolsMITsync 消息格式与握手流程(protocol 包);生成测试数据

    fixtures/ 和 *_gen_test.mbt 中的测试数据由上述 MIT 许可的官方实现生成。 moon_ycrdt 本身使用 Apache-2.0 许可证(见 LICENSE)。