Yjs-compatible CRDT for MoonBit: reads and writes Yjs v1 updates and y-protocols sync messages, byte-identical to Yjs.
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.
| moon_ycrdt | dowdiness/event-graph-walker | mizchi/converge | |
|---|---|---|---|
| 算法 | YATA(Yjs 的算法) | eg-walker + FugueMax | 受 EG-Walker 启发 |
| 同步格式 | Yjs update v1 二进制、Yjs 状态向量、y-protocols 消息 | 自有的同步消息 | 自有的同步格式 |
| 能否直接和 Yjs / Yrs / y-websocket 交换数据 | 能 | 不能 | 不能 |
| 侧重点 | 互通:字节级兼容 | 算法与文档容器(树、撤销等) | 本地优先同步引擎(含 BFT 校验等) |
moon add sayoi7799/moon_ycrdtimport {
"sayoi7799/moon_ycrdt/lib0",
"sayoi7799/moon_ycrdt/doc",
"sayoi7799/moon_ycrdt/types",
"sayoi7799/moon_ycrdt/protocol",
}// 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)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))| 功能 | v1 | v2 |
|---|---|---|
| 整个文档的状态 | Doc::encode_state_as_update(sv?) | Doc::encode_state_as_update_v2(sv?) |
| 应用更新 | Doc::apply_update | Doc::apply_update_v2 |
| 事务级更新事件 | Doc::on_update | Doc::on_update_v2 |
| 解码 / 编码为 Update | decode_update / Update::encode | decode_update_v2 / Update::encode_v2 |
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())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)
}moon run examples/concurrentcd scripts && npm ci && cd ..node scripts/make_example_input.mjs > input.txtmoon run examples/roundtrip -- "$(cat input.txt)"node scripts/read_update.mjs "<示例输出的 base64>" docimport * 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"cd examples/ws_servermoon run --target native . -- --port 1234 --data ./data| 参数 | 默认值 | 含义 |
|---|---|---|
| --host | 127.0.0.1 | 监听地址 |
| --port | 1234 | 监听端口(y-websocket 的默认端口) |
| --data | data | 保存文档的目录,不存在会创建 |
| --save-interval | 2 | 有改动的房间每隔几秒保存一次 |
cd examples/webnpm cinpm run devcd examples/ws_server && moon build --target nativecd examples/web && npm run e2e -- ../ws_server/_build/native/debug/build/sayoi7799/moon_ycrdt_ws_server/moon_ycrdt_ws_server.exe| 包 | 主要 API |
|---|---|
| doc | Doc::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) |
| types | Text::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 |
| protocol | SyncMessage(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 |
| lib0 | Encoder / Decoder(write_var_uint、write_var_int、write_var_string、write_any 等)、Any、encode_any / decode_any、to_hex / from_hex、DecodeError |
| update | decode_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 编码(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*,❌ |
moon bench -p sayoi7799/moon_ycrdt/benchmarks --target native --release| 基准 | 合并优化前 | marker 之前 | 现在 |
|---|---|---|---|
| lib0:编解码 1 万个 varUint 和 1000 个 Any 对象 | 1.80 ms | 1.78 ms | 1.74 ms |
| 解码并重新编码一个 4000 项的碎片化文档 | 2.96 ms | 2.94 ms | 2.72 ms |
| 在文本末尾逐字输入 2000 个字符(每字一个事务) | 1.61 ms | 2.60 ms | 2.58 ms |
| 2000 次随机位置插入 | 96.8 ms | 9.21 ms | 4.82 ms |
| 4000 字的文本里 1000 次随机删除 | 208 ms | 18.8 ms | 7.96 ms |
| 加载一个 4000 项的碎片化文档(apply_update) | 4.92 ms | 6.53 ms | 6.55 ms |
| 导出这个文档的完整状态 | 1.66 ms | 1.59 ms | 1.56 ms |
| 3 个副本 × 300 轮并发编辑并互相同步 | 18.7 ms | 9.74 ms | 9.63 ms |
| Map:在 100 个键上 set 5000 次 | 430 ms | 6.56 ms | 6.33 ms |
| Array:push 2000 次再随机插入 500 次 | 13.8 ms | 6.34 ms | 6.00 ms |
| 长文本:10 万字符,5000 次随机插入 + 5000 次随机删除 | — | 668 ms | 188 ms |
| 长数组:10 万个值,5000 次随机插入 + 5000 次随机删除 | — | 744 ms | 192 ms |
cd scripts && npm ci && cd ..node scripts/bench_yjs.mjs| 基准 | moon_ycrdt | Yjs(gc: true) | Yjs(gc: false) |
|---|---|---|---|
| 在文本末尾逐字输入 2000 个字符 | 2.74 ms | 3.24 ms | 3.43 ms |
| 2000 次随机位置插入 | 4.76 ms | 4.98 ms | 5.34 ms |
| 4000 字的文本里 1000 次随机删除 | 7.85 ms | 8.06 ms | 8.46 ms |
| 加载一个 4000 项的碎片化文档 | 6.43 ms | 3.19 ms | 3.00 ms |
| 导出这个文档的完整状态 | 1.57 ms | 780 µs | 733 µs |
| Map:在 100 个键上 set 5000 次 | 6.39 ms | 8.39 ms | 8.17 ms |
| Array:push 2000 次再随机插入 500 次 | 5.99 ms | 6.06 ms | 5.86 ms |
| 长文本:10 万字符,5000 次随机插入 + 5000 次随机删除 | 183 ms | 63.87 ms | 64.61 ms |
| 长数组:10 万个值,5000 次随机插入 + 5000 次随机删除 | 186 ms | 79.80 ms | 77.11 ms |
| 类别 | 内容 | 位置 |
|---|---|---|
| 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 → MoonBit | 15 个场景(含乱序、重复投递):文本、Map、状态向量与 Yjs 一致,MoonBit 重新编码的整体状态与 Yjs 的逐字节相同;幂等;差量同步 | tests/interop_test.mbt |
| MoonBit → Yjs | cmd/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.Array | 200 组随机场景(种子 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 |
| 更新格式 v2 | 12 个样例更新经 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 |
moon check --deny-warn --target allmoon build --target allmoon test --target allmoon fmt --checkmoon info && git diff --exit-codemoon run cmd/export --target native > fixtures/moonbit_out.jsonnode scripts/verify_moonbit.mjs fixtures/moonbit_out.jsoncd scripts && npm ci && node gen_fixtures.mjs && cd .. && moon fmt| 项目 | 许可证 | 参考范围 |
|---|---|---|
| yjs | MIT | update v1 格式、struct store、YATA integrate、Y.Text / Y.Map / Y.Array 的语义(含定位、格式清理、删除集、pending 处理);生成测试数据 |
| lib0 | MIT | 二进制编码(varUint、varInt、字符串、Any);生成测试数据 |
| y-protocols | MIT | sync 消息格式与握手流程(protocol 包);生成测试数据 |
Install
Download zipYjs-compatible CRDT for MoonBit: reads and writes Yjs v1 updates and y-protocols sync messages, byte-identical to Yjs.