mcpField
Y
yjs

yjs

github.com/epicenterhq开发4.8k125 次安装2 周前更新
代码仓库

关于 yjs

记录 Epicenter 中 Yjs 14 CRDT 模式的项目专用技能,涵盖文档结构、同步、持久化和编辑器绑定,供修改共享数据行为的开发者参考,并要求关键决策依据源码或官方文档。

主要功能

  1. 01Yjs 14 文档与行结构指南
  2. 02状态向量和更新同步模式
  3. 03事务来源与撤销管理
  4. 04存储订阅与编辑器绑定
  5. 05所有者侧持久化和压缩规则
  6. 06关键行为变更的上游核验

适用场景

  1. 01设计新的共享数据文档
  2. 02实现同步或持久化变更
  3. 03排查异常文档状态或增长
  4. 04将编辑器绑定到协作内容

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/epicenterhq/epicenter --skill yjs

安装后在 Claude Code 中输入/yjs确认已加载。

附带文件references/debugging.mdreferences/document-design.md

详细介绍

Yjs 14 CRDT 模式

参考仓库

  • Yjs:用于协同编辑和离线优先数据的 CRDT 框架
  • Yjs Protocols:同步与感知功能的算法基础

上游依据

当冲突语义、事务来源、共享类型行为、更新编码、存储增长或共享类型 API 影响正确性时,请先查阅源码依据,再依赖记忆。如果 DeepWiki MCP 可用,请针对 yjs/yjs 提出范围明确的问题;对于同步和感知算法,请针对 yjs/y-protocols 提出问题。如果 DeepWiki 不可用或未索引该仓库,请直接查阅上游源码或官方文档。将 DeepWiki 用作方向指引,然后在更改代码前,针对本地锁定的 @y/y 类型定义和源码核实关键细节。

Epicenter 仅支持 @y/y 14。不要添加 yjs 13、y-indexeddb、兼容性读取器、包别名、双重 wire 协议或回退方案。现有的 Yjs 13 代码是待替换内容,而非兼容层。

对于以下已记录的稳定基础知识和仓库内模式,请跳过 DeepWiki。

在决定如何构建新的行文档之前,请阅读 references/document-design.md。计数器、用户控制的排序和嵌套结构各自有不同的冲突行为;一旦数据存在,更改这些行为的成本会很高。

当文档收敛到意外状态,或增长速度超过其内容所能解释的范围时,请阅读 references/debugging.md。

相关技能:请参阅 svelte,了解如何将 store 数据读入组件;参阅 typebox,了解 field.* 声明辅助函数所使用的 schema。

事务、来源与撤销

  • Yjs 更新具有交换性和幂等性。状态向量描述副本已有的内容;它不会对副本尚欠的内容排序。删除操作不会推进客户端时钟,因此两个副本可能持有相同的向量却存在差异。这就是为什么此 store 中的待处理义务使用日志位置而非向量表示。因此,游标和发件箱都从更新行中的同一列派生:MAX(authoritySeq) 和 authoritySeq IS NULL,从而确保二者与其所对应的字节不会出现不一致(packages/app/evidence/data/invariants.test.ts,ADR-0298)。
  • 使用 Y.encodeStateVector(doc) 描述本地时钟,然后使用 Y.encodeStateAsUpdateV2(doc, remoteStateVector) 仅发送缺失的更新。
  • 持久化并传输 updateV2 事件中的字节。使用 Y.applyUpdateV2(doc, update, origin) 重放这些字节。
  • 使用 doc.transact(() => { ... }, origin) 将多次写入的用户操作封装在事务中。这样可以减少观察者的变动,并为持久化、提供方和撤销逻辑提供有用的来源信息。
  • 将事务来源视为筛选提供方回声、应用写入的操作和撤销跟踪的边界。
  • 将 Y.UndoManager 的作用域限定为具体的共享类型。设置 trackedOrigins,调整 captureTimeout,并在逻辑上相互独立的命令之间调用 stopCapturing()。
  • 协同光标和选区锚点应使用相对位置。远程编辑会导致原始数值索引偏移。
  • Y.snapshot() 是一种依赖保留的删除历史记录的历史标记。Y.encodeStateAsUpdateV2(doc) 是自包含的检查点格式。
  • 优先使用多个顶层文档,而非 Yjs 子文档,除非 Epicenter 管理该子文档路径的整个提供方生命周期。

存储连接

  • Yjs 与网络无关。它提供 CRDT 状态、状态向量、更新和感知行为,不提供 Epicenter 的连接拓扑、授权或持久性契约。
  • 每个应用一个套接字,而不是每个打开的文档一个。副本连接到 STORE_SYNC_ROUTE.pattern(packages/sync/src/store-route.ts 中的 /api/store/v1/sync),并使用 namespace 命名工作区,使用 cursor 标明自身已持久应用的位置,因此重连是追赶进度,而不是从头开始(ADR-0222)。
  • 数据归属者绝不会出现在查询中。它由服务端根据已解析的 bearer 确定,因此客户端无法在 URL 中填入任何值来访问另一个分区(ADR-0092)。
  • 浏览器升级时必须通过且仅通过一个 bearer.<token> 子协议条目进行身份验证,因为浏览器升级无法设置 Authorization;挂载点在 101 响应中只回显主子协议,因此令牌不会往返传输。非浏览器客户端可以使用 Authorization 标头。请勿使用仅依赖 Cookie 的升级、查询字符串凭据或接收连接后再发送身份验证帧的方式。
  • 线上协议只有帧格式,没有其他内容:push、ack、refuse、entry、offer、snapshot、wanted(packages/app/src/data/sync/frames.ts)。帧不知道更新代表什么、行是什么,也不知道 Yjs 是什么,这正是为什么在这一层进行分块是安全的。
  • 大型更新会按 CHUNK_BYTES 分块,该值依据 Cloudflare 文档规定的 Durable Object SQLite 值大小上限设定,与 Yjs 无关。请勿将其调高至实测上限;Cloudflare 有权强制执行的是文档规定的限制。
  • 有意不包含 Presence,直到有具体使用方确实需要感知状态和断开连接清理。如果之后添加,感知状态应为临时数据,绝不能作为规范数据持久化到 Y.Doc 或 SQLite 更新日志中。

每个存储一个文档

Epicenter 固定使用 @y/[email protected]。一个存储对应一个 Y.Doc,其中包含名为 tables:<name> 的表根节点和一个 kv 根节点。每行都是其表根节点上的一个嵌套 Y.Node 属性。 该行的属性是 JSON 元数据,其唯一的序列子节点是稳定的正文节点(ADR-0431)。

存储会同时创建这两个节点。读取操作绝不会创建或修复节点,并要求恰好存在一个类型正确的子节点。 字段更新不能替换该子节点;删除行会移除其子树。子节点索引 0 是一种存储约定,而该子节点有自己的稳定 CRDT 标识。

get、rows 和 create 返回值快照。table.body(id) 返回实时正文,不受元数据是否符合规范的影响。body、content 和以 ! 开头的名称都是普通元数据;结构性字段 id 仍为保留字段。

const notes = defineTable({
  fields: { title: field.string(), body: field.string() },
  body: plainText(),
});

const row = db.tables.notes.create({ title: 'Trip', body: 'metadata' });
const body = db.tables.notes.body(row.id);
body?.insert(0, ['The writing.']);

保留内部使用 TypeBox 的 field.* 辅助函数。当前的双方法正文文件格式和工件序列替换规范见 packages/app/src/data/README.md。工件读取会公开 { id, fields, body };值会成为 frontmatter,编解码器会提供其下方的文本。Checkout 会根据操作类型区分名为 body 的字段与正文编辑。

只有声明过的表根节点和 KV 根节点会传入 Doc.get,该方法在未命中时会创建节点。行查找使用 getAttr,绝不会创建根节点。将行嵌套存放还能避免编码时 findRootTypeKey 为每行扫描一个文档根节点。

编辑器绑定

将编辑器绑定到 body 子节点。Honeycrisp 使用 @y/[email protected]: ynodeToPmnode(node, schema) 用于读取,pmnodeToDelta(pmNode) 则生成供 node.applyDelta 使用的插入增量。 转换器会根据 schema 填充空的顶层文档,但不会写入 Yjs。如需重写,请在同一个 store 事务中删除旧序列并应用新增量;绝不要替换 body。

编辑器规范化由 body 属性负责。父级元数据应保留在其外部。 编辑元数据不得通知 body 监听器,也不得清除编辑器撤销历史。 Honeycrisp 会在收到外部 body 编辑时清除历史,并保留撤销/重做操作的来源。 Skills CodeMirror 适配器只写入传入的 body,但每次编辑都会替换整个文本;其并发编辑行为不如增量式协作绑定。

根级覆盖会确保 RC26 和 lib0 RC32 在整个依赖闭包中保持一致。旧版渲染器和内部导出补丁已移除。新的 ProseMirror 补丁会在撤销后恢复虚拟空内容门控,以保留重做功能。不要为了适配标准 Tiptap 协作而引入 Yjs 13。

三种信号,以及各自的触发时机

  • table.subscribe 会在表格的形状发生变化时触发:添加或删除行,或编辑值。它不会因 body 节点内部的编辑而触发。它会将提交涉及的行 ID 传给监听器,因此,持有投影的使用方只需重建发生变化的部分;只需重新读取数据的使用方可以忽略这些 ID。
  • table.watch(node) 会在单个 body 节点内部发生编辑时触发,按节点自身的身份标识进行关联。
  • kv.subscribe 会在任何已声明的键发生变化时触发,且不携带任何内容。一共有十个键,列出它们没有意义。

这种区别由库本身决定。事件分发依据 transaction.changed;Yjs 会在其中填入事务直接修改的类型,因此,在 body 中按键时,该 body 的类型会出现在其中;其父级是行,而不是表格根节点。变更不会向上冒泡到表格。因此,监听表格以检测节点内部变更的界面不会收到任何通知。

所有者侧持久化

一个文档,因此只有一条链:SQLite 中的 _updates (id, bytes, authoritySeq),以及浏览器 IndexedDB 中与之匹配的对象存储。没有按文档分区,没有 _tombstones,也没有单独的 _outbox——副本仍欠缺的内容,是 authoritySeq IS NULL 的行;它由部分索引实现,而不是第二张表(ADR-0238)。不要添加单独的 IndexedDB provider 或第二个文档存储。

  • 在附加 updateV2 监听器之前先进行水合。通过监听器重放存储的字节会再次追加它们;引擎会先应用历史记录,再附加监听器,如果外部应用操作到达监听器,还会抛出异常。因此这里一旦出错,打开操作就会明确失败,而不是产生重复日志(packages/app/src/data/store/store.ts)。
  • 本地创作的追加内容会加入持久化待处理队列。由权威端接受的字节来自远程来源,不会产生出站义务;唯一的监听器会在追加前检查这一点。
  • 链按行压缩,行的 authoritySeq 决定采用哪种机制(ADR-0301)。权威端已接收的行会重放到一个新的 gc: true 文档中,并重写为一条完整的 V2 状态更新:这里采用重放而非 mergeUpdatesV2,因为合并不会执行 GC,而合并墓碑正是目的所在。encodeStateAsUpdateV2 会将缓冲的待处理状态折叠回输出,因此在依赖项缺失时执行折叠不会悄悄丢弃它们。
  • 仍然欠缺的行不能走这条路径,这一点必须处理正确。整个文档的重新编码不是可以提交给权威端的增量,因此欠缺的行会通过 mergeUpdatesV2 合并为一条可重新发送的行,并分配一个高于所有现有 ID 的新 ID。像已确认的行那样折叠它们,会导致每次按键都向权威端提交整个文档;沿用较低的 ID,则会让较早的确认给它从未包含的字节盖上已确认标记。
  • 将重放损坏视为存储故障:如果文档无法水合,就拒绝打开,而不是返回一个仅完成部分水合的句柄。

存储优化

v14 只有一种共享类型 Y.Node:映射式根节点通过 doc.get(name) 获取,文本根节点通过 doc.get(name, 'text') 获取。没有 Y.Map、Y.Text、Y.Array 或 Y.XmlFragment;代码或建议中提到这些类型,说明它们属于 Yjs 13,应当替换。

属性墓碑会永久保留键,而每次 setAttr(key, value) 都会创建一个新的内部项,并将前一个项标记为墓碑;因此,gc: true 可以将编辑了 5,000 次的字段折叠为两个 struct。

边界处的原始类型

表句柄提供的 Y.Node 是一个实时 CRDT 引用,设计上就是要绑定到编辑器。存储会将实际节点交给编辑器,而不对其进行代理,因为副本会破坏它之所以值得使用的合并能力。

原始节点可以访问其父节点和文档。它不是安全沙箱。功能必须使用 table.body(id),而不是自行构造或修复行布局。此不变量由存储负责维护,位置在 packages/app/src/data/store/document.ts。

RC26 仍将配置的子节点类型定义为 Fingerprintable,其中不包含实时 Y.Node,而且 insert 不会将 delta 子节点转换为 node 子节点。应将插入绕行逻辑限制在行创建处;不要扩大 JSON 元数据类型,也不要发布兼容别名来掩盖这一差异。更改此边界前,请先核实已安装的声明文件。

参考资料

  • 了解 Yjs - 交互式教程
  • Yjs 文档 - API 参考
  • Yjs INTERNALS.md - Yjs 的内部工作原理
  • GitHub issue #520 - 与 dmonad 讨论冲突解决
  • fractional-indexing - 生产级库
  • YATA 论文 - 学术基础
  • packages/app/src/data/store/document.ts:应用文档语法(根、行类型、字段读取)
  • packages/app/src/data/store/log.ts 和 packages/app/src/data/store/persistence.ts:持久化更新日志、发件箱和持久化队列
  • packages/app/evidence/data/invariants.test.ts:此设计所依赖的库行为,针对已安装的 rc 固定
  • packages/app/src/data/sync/:Yjs 14 线路格式(帧、连接、客户端、权威端)
  • ADR-0295:每个应用对应一个文档,行保存其富内容(取代 ADR-0248)
  • ADR-0309:历史字段/节点术语,经 ADR-0431 修订,以适配唯一子项布局
  • ADR-0221:subscribe 报告的内容及触发时机
  • ADR-0146:仅使用 Yjs 14 的持久化决策
  • ADR-0159:一个所有者端 SQLite 更新日志和共享附件接缝

评论

支持 Markdown