移除实时协作,改为 HTTP-only 保存 + 编辑锁与版本冲突合并 - #56
Open
Coldin04 wants to merge 3 commits into
Open
Conversation
…ng and version-conflict detection Removes the Yjs/WebSocket-based realtime collaboration stack and replaces it with a simpler HTTP-only editing model, plus a lightweight WebSocket channel purely for lease-change notifications (not document content). - Delete the `packages/realtime` service, workspace's realtime handler and its Yjs state routes/columns, and related tests. - Add optimistic-concurrency conflict detection to document content saves: `UpdateContent` now takes an `expectedContentVersion` and returns 409 `CONTENT_VERSION_CONFLICT` (with the server's current version) when it doesn't match, instead of silently overwriting concurrent edits. - Add `internal/editlease`: a single-writer edit lock per document (claim/renew/release, with explicit take-over), enforced via `RequireMutation()` on the routes that mutate a document's title, excerpt, image target, public access, assets, and pasted images. - Add `internal/editlease/hub.go` + `ws_handler.go`: a process-local pub/sub hub and a new `GET /api/v1/edit-lease/documents/:id/events` WebSocket route that pushes a lease-claimed notification to every connection watching a document, so another tab/device/browser learns its lease was taken over immediately instead of waiting for its next renewal poll (up to ~20s) to fail. Browsers can't set a custom Authorization header on a WS handshake, so the connection authenticates itself with a first JSON frame right after opening instead. This is a courtesy fast path only — the REST claim/renew/release endpoints and the client's periodic renewal poll are unchanged and remain the source of truth if the socket never connects or drops. New dependency: github.com/gofiber/contrib/websocket. - Adjacent workspace changes carried along with the same refactor: extracted `acl_handler.go`/`ownership_transfer.go`, misc ACL/quota/AI/ MCP/media test and handler adjustments. Operational note: if a reverse proxy sits in front of this server, make sure it forwards the Upgrade/Connection headers for `/api/v1/edit-lease/documents/*/events`, otherwise the WebSocket handshake silently fails and clients fall back to the slower polling path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… edit-lease to one tab per device Companion to the server-side edit-lease/version-conflict work: removes the Yjs-based realtime collaboration client and reworks the editor's save path around plain HTTP + the new edit-lease/version-conflict APIs. - Delete `packages/realtime` client glue (`yjsProvider.ts`) and simplify `DocumentCollaborationSettings.svelte`/`SharedDocumentListItem.svelte` down to sharing/ACL only (no more live co-editing presence). - Editor saves now send `expectedContentVersion` + an edit-lease token (`lib/api/editor.ts`: `acquireDocumentEditLease`, `renewDocumentEditLease`, `releaseDocumentEditLease`, `updateDocumentContent`). Version-conflict resolution (previously: the only recovery from a 409 was a full page reload, discarding the local draft): - `blockMerge.ts`: a diff3-style 3-way merge over TipTap's top-level blocks. Blocks changed on only one side auto-merge with no prompt; blocks changed differently on both sides become a conflict the user picks "local" / "cloud" / (for pure insertions) "both" for. - `conflictDraft.ts`: persists the local draft + its base snapshot to localStorage the moment a conflict is detected, and restores it (and reopens the merge dialog) if the tab is closed/reloaded mid-resolution — this is the actual fix for "refreshing was the only option and it threw the draft away". - `VersionConflictDialog.svelte`: side-by-side compare/merge UI, plus a "use cloud only" escape hatch and an "export local draft as Markdown" safety valve. Edit-lease scope + instant invalidation (previously: any tab of the same browser silently shared one lease, so two tabs could edit at once): - `editDevice.ts`: switched the device/tab id and cached lease token from `localStorage` (shared by every tab) to `sessionStorage` (unique per tab) — opening a second tab now requires an explicit take-over, while reloading the same tab still reclaims its own lease. - `leaseBroadcast.ts`: same-browser tabs learn about a take-over instantly via `BroadcastChannel`. - `leaseSocket.ts`: any other tab/device/browser learns about a take-over instantly via the new server-pushed WebSocket channel, with a fixed reconnect and no other behavior change if it can't connect — the existing ~20s renewal poll remains the fallback either way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
存在已确认的问题需要修复(例如 workspace 页面 store 订阅未清理导致的潜在订阅泄漏,以及文档描述与实际“按标签页隔离”的租约实现不一致)。
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
本 PR 将协作编辑从基于 Yjs/WebSocket 的实时协作体系迁移为“HTTP 保存 + 文档级编辑锁(edit-lease)+ 内容版本冲突检测与前端三方合并”,并清理了 realtime 服务及相关前后端胶水代码,使编辑链路回归可控的单人保存模型,同时补齐锁接管与版本冲突的恢复体验。
Changes:
- 服务端新增 edit-lease(申领/续约/释放 + WS 事件通知旁路)并将正文保存改为携带
expectedContentVersion的乐观并发控制(409 冲突返回当前版本)。 - 前端移除 Yjs 协作实现,新增版本冲突草稿持久化与合并 UI,并将编辑锁改为按标签页隔离(
sessionStorage)+ BroadcastChannel/WS 即时失效通知。 - 更新部署/开发文档与依赖配置(Node/pnpm 版本、wrangler compatibility_date、删除 packages/realtime)。
File summaries
| File | Description |
|---|---|
| README.md | 更新协作/保存链路说明与环境要求(含 realtime 退役说明)。 |
| packages/web/wrangler.toml | 更新 Cloudflare compatibility_date。 |
| packages/web/vite.config.ts | 移除本地 realtime ws 代理。 |
| packages/web/src/routes/workspace/shared/+page.svelte | 读取配置来源从 realtimeConfig 改为 clientConfig(sharingEnabled)。 |
| packages/web/src/routes/workspace/+page.svelte | 同步从 clientConfig 读取 sharingEnabled。 |
| packages/web/src/routes/user/+layout.svelte | 用户设置页使用 clientConfig 判断 sharingEnabled。 |
| packages/web/src/routes/user/[[tab]]/+page.svelte | sharing tab 的开关判断改为 clientConfig。 |
| packages/web/src/locales/zh.json | 新增编辑锁/版本冲突相关文案。 |
| packages/web/src/locales/en.json | 新增编辑锁/版本冲突相关文案(英文)。 |
| packages/web/src/lib/utils/yjsProvider.ts | 删除 Yjs provider 管理器。 |
| packages/web/src/lib/stores/clientConfig.ts | 新增/重命名 clientConfig store,移除 realtime ws 配置下发依赖。 |
| packages/web/src/lib/components/workspace/SharedDocumentListItem.svelte | 共享列表入口简化为只读打开与 viewer 展示。 |
| packages/web/src/lib/components/workspace/DocumentListItem.svelte | 导出/更新流程接入 edit-lease 与 expectedContentVersion 保存。 |
| packages/web/src/lib/components/editor/VersionConflictDialog.svelte | 新增版本冲突合并对话框 UI。 |
| packages/web/src/lib/components/editor/leaseSocket.ts | 新增跨设备 edit-lease 变更 WS 订阅。 |
| packages/web/src/lib/components/editor/leaseBroadcast.ts | 新增同浏览器标签页间 lease 变更广播。 |
| packages/web/src/lib/components/editor/EditorTopBar.svelte | 移除协作在线成员/指示器相关 UI 与角色逻辑。 |
| packages/web/src/lib/components/editor/Editor.svelte | 移除 TipTap 协作扩展,调整 onContentChange 接口。 |
| packages/web/src/lib/components/editor/editor-content.css | 移除协作光标/选区相关样式。 |
| packages/web/src/lib/components/editor/editDevice.ts | 新增按标签页隔离的 deviceId/lease token(sessionStorage)。 |
| packages/web/src/lib/components/editor/DocumentCollaborationSettings.svelte | 共享成员管理 UI 收敛为 viewer + 支持转移所有权入口。 |
| packages/web/src/lib/components/editor/conflictDraft.ts | 新增冲突草稿 localStorage 持久化工具。 |
| packages/web/src/lib/api/workspace.ts | 工作区写操作统一通过 ensureDocumentEditLease 携带租约头;新增转移所有权 API。 |
| packages/web/src/lib/api/editor.ts | 保存内容/上传等 API 接入 edit-lease,新增 expectedContentVersion 与统一错误结构。 |
| packages/web/scripts/build-target.mjs | 构建脚本改为直接调用 Vite build(不再 spawn)。 |
| packages/web/pnpm-workspace.yaml | 更新 pnpm 相关 workspace 配置/overrides。 |
| packages/web/package.json | 更新 Node/pnpm 约束与依赖(移除协作依赖,新增 wrangler)。 |
| packages/server/internal/workspace/service.go | 分享角色规范化调整;外发共享文档列表收敛为 owner 视角。 |
| packages/server/internal/workspace/service_security_test.go | 新增/调整共享/写权限相关安全测试(含所有权转移)。 |
| packages/server/internal/workspace/ownership_transfer.go | 新增文档所有权转移服务逻辑(含配额/租约/邀请清理)。 |
| packages/server/internal/workspace/handler.go | 新增 TransferDocumentOwnershipHandler。 |
| packages/server/internal/workspace/handler_security_test.go | 调整 handler 权限测试(viewer-only sharing 与成员管理权限收紧)。 |
| packages/server/internal/workspace/errors.go | 新增所有权转移相关错误。 |
| packages/server/internal/workspace/dto.go | 新增转移所有权请求 DTO;ACL 注释更新。 |
| packages/server/internal/workspace/acl_handler.go | ACL handler 拆分为独立文件(非 realtime 专用)。 |
| packages/server/internal/models/models.go | 移除 DocumentBody 的 Yjs 字段;新增 DocumentEditLease 模型。 |
| packages/server/internal/media/service_test.go | 调整媒体服务测试以反映 legacy shared 角色的写入拒绝。 |
| packages/server/internal/media/handler_test.go | 调整媒体 handler 测试以反映 legacy shared 角色的写入拒绝。 |
| packages/server/internal/media/document_image_upload_test.go | 调整文档图片上传测试以拒绝 legacy shared editor。 |
| packages/server/internal/mcp/handler.go | MCP rename document 改走 ai.RenameDocument(受 lease 保护)。 |
| packages/server/internal/editlease/ws_handler.go | 新增 edit-lease 事件 WS handler(首帧 JSON 鉴权)。 |
| packages/server/internal/editlease/ws_handler_test.go | 新增 WS JWT 解析测试。 |
| packages/server/internal/editlease/manager.go | 新增 edit-lease manager(Claim/Renew/Authorize/Release)。 |
| packages/server/internal/editlease/manager_test.go | 新增 manager 行为测试(接管/续约/过期等)。 |
| packages/server/internal/editlease/hub.go | 新增进程内 pub/sub hub 以推送 lease 变更。 |
| packages/server/internal/editlease/hub_test.go | 新增 hub 测试。 |
| packages/server/internal/editlease/handler.go | 新增 edit-lease REST handlers + RequireMutation 中间件。 |
| packages/server/internal/editlease/handler_test.go | 新增 handler/middleware 测试。 |
| packages/server/internal/database/database.go | 注册 document_edit_leases 表与模型迁移。 |
| packages/server/internal/content/service.go | UpdateContent 接入 edit-lease 与 expectedContentVersion 并发控制。 |
| packages/server/internal/content/service_test.go | 调整 content service 测试以覆盖 lease 与版本冲突。 |
| packages/server/internal/content/handler.go | UpdateContentHandler 接入租约头与版本冲突错误结构。 |
| packages/server/internal/content/handler_test.go | 新增 HTTP 保存回归与版本冲突 handler 测试。 |
| packages/server/internal/content/errors.go | 新增 expectedContentVersion 校验与 ContentVersionConflictError。 |
| packages/server/internal/config/handler.go | /api/v1/config 不再下发 realtimeWsUrl。 |
| packages/server/internal/config/handler_test.go | 新增测试确保 config 不暴露 realtimeWsUrl。 |
| packages/server/internal/config/env.go | 更新 GetCollaborationEnabled 注释与移除 realtime ws env。 |
| packages/server/internal/ai/service.go | AI 写入/重命名接入一次性 edit-lease;版本冲突映射保持。 |
| packages/server/internal/ai/service_test.go | 新增测试:浏览器 lease 存在时 AI 写入应被锁定。 |
| packages/server/internal/ai/handler.go | AI handler 对 lease 错误进行状态码映射。 |
| packages/server/internal/acl/service.go | 写/成员管理权限收敛为 owner-only;实现 owner-only 快速路径。 |
| packages/server/go.sum | 新增 websocket 相关依赖校验和。 |
| packages/server/go.mod | 引入 gofiber/contrib/websocket 及间接依赖升级。 |
| packages/server/cmd/server/main.go | 注册 edit-lease REST/WS 路由;写操作挂 RequireMutation;新增 WS origin 白名单。 |
| packages/realtime/tsconfig.json | 删除 realtime 服务配置文件。 |
| packages/realtime/src/ws.d.ts | 删除 realtime ws 类型声明。 |
| packages/realtime/package.json | 删除 realtime 服务包定义。 |
| packages/realtime/.env.example | 删除 realtime 环境变量示例。 |
| docs/web_deployment.md | 更新部署说明:移除 realtime,补充 Node/pnpm 版本与 wrangler date。 |
| docs/DEV_GUIDE.md | 更新开发环境要求(Node/pnpm 版本)。 |
| .gitignore | 忽略本地依赖缓存目录(.pnpm-store 等)。 |
| .env.example | 更新协作开关注释:强调遗留共享与编辑链路解耦。 |
Review details
Files not reviewed (2)
- packages/realtime/pnpm-lock.yaml: Generated file
- packages/web/pnpm-lock.yaml: Generated file
- Files reviewed: 76/82 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+38
to
+40
| let clientConfigSignal = $state(get(clientConfig)); | ||
| clientConfig.subscribe((state) => (clientConfigSignal = state)); | ||
| const collaborationEnabled = $derived(clientConfigSignal.config?.sharingEnabled ?? false); |
Comment on lines
+82
to
+84
| - 编辑器固定使用 `PUT /api/v1/edit/documents/:id/content` 保存规范化的 `ContentJSON`。 | ||
| - 正文、编辑属性和上传统一受设备级编辑租约保护;同一设备的标签页复用令牌。 | ||
| - MCP/Open API 写入使用一次性操作租约,文档正被浏览器编辑时会返回锁定错误。 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概述
移除基于 Yjs/WebSocket 的实时协作服务,改为纯 HTTP 保存 + 文档级编辑锁(edit-lease),并针对锁被顶替、版本冲突这两类场景补齐了此前缺失的处理。
服务端(
packages/server)packages/realtime服务、workspace 的 Yjs state 路由/表字段及相关测试。content包新增乐观并发版本冲突检测:保存文档需带expectedContentVersion,不匹配时返回 409CONTENT_VERSION_CONFLICT并附带服务端当前版本号,不再默默覆盖并发编辑。internal/editlease:同一文档同一时间只允许一个编辑会话持有锁(申领/续约/释放,支持显式接管),通过RequireMutation()挂在标题、摘要、图床、公开访问、资源上传等写操作路由上。internal/editlease/hub.go+ws_handler.go:轻量级 WebSocket 通道GET /api/v1/edit-lease/documents/:id/events,锁被接管时主动推送通知,不用等最长 ~20 秒的续约轮询失败才发现——纯粹的即时通知旁路,REST 的申领/续约/释放接口和轮询兜底完全不变。浏览器原生 WebSocket 握手带不了 Authorization 头,所以鉴权改成连上后立即发一帧 JSON 完成。新增依赖github.com/gofiber/contrib/websocket。workspace/acl_handler.go、ownership_transfer.go拆分,ACL/配额/AI/MCP/media 相关测试与 handler 微调。运维提示:如果前面有反向代理,需要确认
/api/v1/edit-lease/documents/*/events这条路径的Upgrade/Connection头被正确转发,否则握手会静默失败、退化为轮询(不报错,只是变慢)。前端(
packages/web)yjsProvider.ts),DocumentCollaborationSettings.svelte/SharedDocumentListItem.svelte简化为仅保留分享/权限管理。expectedContentVersion+ edit-lease token。版本冲突处理(此前唯一的恢复方式是刷新页面,会直接丢失本地草稿):
blockMerge.ts:对 TipTap 顶层块做 diff3 式三方合并(base/local/cloud)。只有一侧改动的自动合并、无需确认;两侧改动不同的才标记为冲突,让用户逐段选择本地/云端(纯新增内容还可以选择两者都保留)。conflictDraft.ts:冲突一被检测到就把本地草稿和基准快照写入 localStorage,即使中途刷新/关闭标签页也能在下次打开时自动恢复并重新弹出合并对话框。VersionConflictDialog.svelte:左右对比 + 逐段合并 UI,附带"仅使用云端版本"和"导出本地草稿为 Markdown"两个兜底出口。编辑锁收紧为按标签页隔离 + 跨端即时失效(此前同一浏览器开多个标签页会共享同一把锁,可以同时编辑):
editDevice.ts:设备/标签页标识和缓存的 lease token 从localStorage(同浏览器所有标签页共享)改为sessionStorage(每个标签页独立)——开新标签页需要显式接管,同一标签页刷新仍可自动续锁。leaseBroadcast.ts:同浏览器标签页间通过BroadcastChannel即时感知接管。leaseSocket.ts:跨浏览器/跨设备通过新的服务端 WebSocket 通道即时感知接管,断线固定重连,连不上也不影响原有轮询兜底。验证
go build/vet/test ./...全绿(含新增的hub_test.go、ws_handler_test.go)。pnpm check(svelte-check)0 错误 0 警告。lease_claimed事件。blockMerge.ts的合并逻辑用独立脚本跑过多组场景断言(不重叠编辑自动合并、真实冲突默认选本地、同位置双端插入支持"两者都保留"、内容完全相同时零冲突)。🤖 Generated with Claude Code