「Fork The World」 02:Rdocs
by Randall · 23 Sep 2026·fork / rdocs
← /u/randall/blog
by Randall · 23 Sep 2026·fork / rdocs
页面树、实时正文、权限和公开站点,四套真相。一份 Worker,一个 Durable Object,一套不把旧客户端写回新文档的恢复协议。
上一篇 把「Fork The World」定义成复刻能力边界,而不是照着界面描一遍。Meting 的边界是九个音乐平台。这一篇的边界是 Notion 那种团队知识库:两个人同时改一页,刷新之后还在,权限撤掉之后连接也得断,旧版本恢复过来不能把别人刚写的字盖掉。
仓库是公开的:RandallAnjie/docs。线上是 docs.bigrandall.io。产品名是 Rdocs。它跑在 RandallFlare 上,用的是平台已经有的 Worker、D1、R2 和 Durable Object,没有为了这个项目去改平台。
从 2026-08-14 建仓到 2026-09-19 最后一次推送,main 上大约 150 个提交,D1 迁移从 0001 写到 0033。前半段在把 Notion 的页面、数据库、站点、通知和同步块铺开;9 月 3 日那一天几乎全是修协作链路。这篇按这个顺序写:先讲它到底在同步什么,再讲那些把 isolate 打满、把房间打 500 的坑。
它不是 Notion 的像素拷贝,也不使用 Notion 的商标和私有实现。验收标准写在仓库的 功能等价基线 里:用户能不能完成同一类任务。登录用设备密钥,不接 GitHub OAuth,也不做可编辑访客。
一句话:Rdocs 是一个把页面元数据和正文拆开的协作房间。
侧边栏里的树、标题、权限、回收站,都是 D1 里的行。你打进去的字是 Yjs 文档,住在某一个 Durable Object 的私有 SQLite 里。附件和冷快照在 R2。Worker 自己几乎不保存正文,它负责验票、验 Origin、把请求送到正确的房间。
这四件事如果挤在一张表里,实时编辑会把 D1 打成按键数据库;如果全放进 Y.Doc,移动页面、改权限、发站点就得跟着正文的 CRDT 一起旅行。所以它们分开。
房间的名字是固定的:
document:{pageId}:generation:{generation}
pageId 决定是哪一页。generation 决定是这一页的哪一代正文。恢复旧版本时不覆盖当前房间,而是另开一代。这是后面所有难点的根。
| 组件 |
|---|
页面树是 pages.parent_id 加 sort_key 的投影。它不进 Y.Doc,也不进 Durable Object。创建子页时 Worker 先确认父页属于当前组织和空间。前端再防一层孤儿节点和循环父链,免得坏元数据把一页从导航里变没。
当前技术预览一次最多返回 500 个页面。树的排序是 sort_key 再加 id,这样同一排序键也不会抖。
浏览器先要票:
POST /api/pages/{pageId}/collab-ticket
Worker 读当前页面状态和 ACL version,发一张 5 分钟的 HMAC ticket。然后两条路同时存在:
WebSocket 是 y-websocket 兼容的二进制帧:外层 0 是 sync,1 是 awareness。HTTP 通道带着客户端的 state vector 和还没被服务端确认的 update。两条路进同一个房间、同一份 Y.Doc、同一条 SQLite 序列。D1 不承载按键。
更新的顺序不能反:
只有写进 SQLite 的更新才允许进内存文档和广播。这样对象被逐出之后,还能从快照加增量恢复,而不是靠“刚才还在内存里”。
快照规则:每 100 条更新,或累计 512 KiB,或最后一个连接离开。冷启动先加载最新快照,再按 seq 补后面的增量。
输入大约 25 ms 后会进 HTTP 合批。页面可见时大约每 350 ms 对一次远端,隐藏时降到大约 1500 ms。一次瞬时失败不会把界面切成“重新连接中”。票据过期在后台续。超过 10 秒没有心跳的 HTTP awareness 会被清掉,避免留下一个已经走了的人的光标。
空间角色是 none、viewer、commenter、editor、space_admin。页面可以继承,也可以收成 restricted。none 优先于组和组织给的更高权限。历史 guest 被封顶成只读,新系统不再造访客。公开分享和 Site 访客没有成员身份,拿不到写票据。
数据库的行级规则只能把页面权限收窄,不能反过来抬高。公式、汇总、搜索、导出、AI 和 Webhook 的载荷走同一套裁剪。同步块要同时过原始页和当前容器页:任一侧撤权、锁定或换租户,资源的 ACL version 升高,已经打开的连接关掉。
Worker 对协作授权最多缓存 2 秒。权限一变,当前 isolate 的缓存清掉,并通知房间。所以“我已经连上了”不是永久许可证。
手动版本不是 D1 里的一行空元数据。创建时浏览器先把 HTTP/Yjs flush 完,房间再导出完整的 Yjs state,校验 SHA-256,写入 R2,然后才在 D1 登记 generation、seq 和对象键。
恢复如果把旧 update 直接灌进当前 Y.Doc,离线的旧客户端会把刚恢复的正文再写回去。所以恢复是新 generation:
D1 里的切换带条件:只有当前 generation 仍是旧值才成功,两个恢复不会同时生效。新房间在切换之前就初始化好。API 要求 UUID Idempotency-Key。操作状态是 pending → prepared → completed。5xx 或断线之后用同一把钥匙重试,只会接着做,不会再造一份“恢复前版本”。
旧房间之后即使收到迟到的 update,也不再是这页的权威,也进不了新房间。
跨页同步块的正文在自己的 DocumentRoom 里。引用页只存资源 ID。删掉原始块和全部副本时,不能只删一个 Yjs 节点就当完成。
引用清单在 synced_block_references 里留 30 天。恢复不回放整页快照,所以删除之后别人继续写的字还在。中途失败会等在途任务结束、释放租约、留下可重试的占位。
还有一道围栏:删除操作 ID 还在的时候,WebSocket 和 HTTP 离线入口会把旧客户端重新写回来的活动引用,再变成同一个操作的占位。恢复租约期间围栏暂停,完成后才清掉操作 ID。任一页被撤权,整体恢复就停。
Notion 自己的说明是超过 10 个副本时,Undo 恢复不了全部。Rdocs 在这件事上把保证写得更硬,代价是删除和恢复都变成跨页事务,而不是编辑器里的一次本地撤销。
Sites 以普通页面为首页。发布和改设置都要求首页的 manage_access。刷新站点页清单时向下递归,碰到 restricted、数据库页或数据库模板页就停。公开读的时候还要再验每一级祖先仍然可见,父页一隐藏,后代全部 404。
未登录的人没有组织成员身份。公开 API 只发 5 分钟、角色固定为 viewer 的协作票据。附件仍在私有 R2,按当前站点和页面范围鉴权。访客拿不到评论、版本、权限和任何写 API。
分析只存按日聚合的浏览和搜索次数,以及 站点 + 日期 + 浏览器会话 的 SHA-256。不存原始 IP。
公开历史上,8 月 15 日前后是功能铺开,9 月 3 日是协作链路的修理日,9 月 19 日是界面打磨。中间还有 8 月 16 日到 17 日的数据库网格和 Passkey 登录修复。
8 月 15 日那天的合并很密,从编辑器块、分栏、附件、同步块、提醒、站点,一直到 API token 和落地页。迁移文件的编号比提交信息更诚实:0001 是初始表,0007 开始是编辑器块,0010 起是数据库,0021 起是跨页同步块,0028 是站点,0029 和 0030 是 Notion 功能面, 是邮件入站, 是分享链接的编辑权限。
9 月 3 日的提交几乎都是 fix。那才是“复刻到能每天写”和“复刻到演示能点开”的分界。
刷新大页面时,HTTP 协作会在 WebSocket 还等着 Durable Object 回放历史的时候,POST 一整份快照。这条请求占着共享 isolate,直到超过响应头超时。提交 #84 把顺序改了:HTTP 退路等套接字失败再上;恢复时按批应用 update 并让出事件循环;快照之后的尾巴被压成一份,下次冷启动只加载这一份快照。
后来 #87 又补了一刀:WebSocket 升级先回 101,历史回放放在握手之后。握手如果要等整段 Yjs 历史,大页面和同步块会直接 502。
套接字还连着的时候,HTTP 同步如果也每隔 25 ms 重试,一个房间会被自己的退路打满。#79 改成:套接字连着时 HTTP 闲着;分片按顺序送;5xx 退避,而不是 25 ms 再打;房间拒绝重叠的 HTTP sync,直接 503。
退路是为了弱网,不是第二条全速写入通道。
RandallFlare 的 Durable Object SQL 会把参数 JSON 编码。ArrayBuffer 写进去变成 "{}",下次恢复房间就崩。#78 把 Yjs update 重新存成 base64,启动时跳过损坏行,过大的同步载荷返回 413 而不是 500。
这是平台行为,不是 Yjs 的行为。调用形状仍然是 Cloudflare 能接受的 state.storage.sql,但每个查询都要 await,因为这边的 SQL 是异步的。
一次贴进几百 KB 的文档,会超过当时的 Yjs 帧上限,把 isolate 里的 DocumentRoom 堵住,整个 Worker 502。#74 把 HTTP 和 WebSocket 都改成分片:单帧 64 KiB,拼回来最大 8 MiB。搜索投影和快照挪出同步热路径。超限返回 413,并且不再用 25 ms 的重试环。
同一批还修了粘贴 HTML:标题和列表不再被包进行内标签里,否则导出和再粘贴会一层套一层。
页面关注者一多,如果在 persist 之前扇出通知,正文延迟就跟关注人数绑在一起。房间完成落盘、应用和广播之后,只同步记一条节流过的审计,通知的 ACL 复核和批量写入放到生命周期后台任务里。权限复核有并发上限,写入按批。关注者数量不再进入按键的关键路径。
提醒分三种来源:页面、正文里的 @remind、数据库日期属性。正文节点删到最后一个同源节点才取消提醒,重复的远端删除请求也是幂等的。数据库日期清空、行归档或属性删除,和取消提醒放在同一个 D1 batch 里,避免数据已经没了、旧提醒还响。
准点投递还差一截。平台定时处理器在代码里有了,但 RandallFlare 当时没有给应用声明 cron 的控制面。人完全离线时,不能保证到点就送进收件箱。打开应用时会补投已到期的提醒。这件事记在外部依赖里,没有假装 cron 已经在跑。
9 月 3 日其余的修复都很小,但每天写字会碰到:
book-open 应该是图标,不是一段文本。这些不是架构图上的节点。不修的话,前面的房间协议只是一个能同步的空白页。
Rdocs 不用 GitHub OAuth,也没有密码。正式登录是 WebAuthn 的 discoverable credential。私钥在设备上,D1 只存 credential ID、公钥、签名计数器和备份属性。
第一次登记用管理员手里的高熵登记码做 bootstrap。登记码是平台 secret,不进仓库和数据库。登记和登录的挑战都是 5 分钟,成功就消费掉。生产上第一把设备密钥登记完,bootstrap secret 就删了。
会话令牌是随机的。D1 只存 SHA-256。浏览器拿 __Host-rdocs_session,Secure、HttpOnly、SameSite=Lax。写请求的 URL 和 Origin 必须精确等于 https://docs.bigrandall.io。
8 月 17 日有一个很具体的坑:Passkey 公钥如果不是按文本存,登录时验签对不上。#72 把它改成文本。设备密钥这种看起来“接上 WebAuthn 就结束”的功能,存储格式错一位,整个登录链就是坏的。
9 月 3 日以前,房间、票据、generation 在仓库里都有了。我刷新一篇已经写长的页面,还是会转到超时:HTTP 同步赶在套接字前面,把一份还在回放的快照塞进共享 isolate。
那天改完我又刷了一次。字还在,光标也还在。这比看着迁移从 0001 排到 0030 踏实。
登录是后来才通的。公钥在库里的格式不对,WebAuthn 验签失败,协议图照样能画,人进不去。改成文本之后才算接上。
现在就在 docs.bigrandall.io 里写东西。代码在 RandallAnjie/docs。
| 权威数据 |
|---|
| 明确不负责 |
|---|
| Worker | 路由、5 分钟 HMAC 票据、Origin、frame 上限 | 长期保存正文 |
| DocumentRoom | 这一代 Y.Doc、增量、快照 | 跨空间查询 |
| D1 | 用户、组织、空间、页面树、ACL、版本元数据 | 每一次按键 |
| R2 | 附件、导出、不可变冷快照 | 权限真相 |
00310033