@/lib/collab-doc — server-side collaborative-document conversion
Server-side conversion between a file's markdown (the durable source of truth) and its collaborative Yjs document, so the server can own the doc: seed it, project it back to markdown, and let the agent write into it while a user is typing.
Why this exists
Collaborative file editing had two writers with no shared CRDT: copilot edit_content wrote
markdown straight to the file while the user typed into an ephemeral, client-seeded Yjs doc. They
couldn't reconcile — the agent's edit didn't stream into the editor, and last-writer clobbered. The
fix is a server-authoritative Yjs doc both sides write into, with markdown as a projection.
What Stage A (this module) provides
| Function | Purpose |
|---|---|
markdownToYDoc(md) |
Cold-start seed: file markdown → a fresh Y.Doc. |
yDocToMarkdown(ydoc) |
Projection: Y.Doc → the file's canonical markdown. |
applyMarkdownToYDoc(ydoc, md) |
Agent write: merge new content into a live Y.Doc as a minimal CRDT diff (no clobber). |
Design decisions (why it's not hacky)
- Parity by construction. The markdown↔ProseMirror step reuses the exact client engine
(
parseMarkdownToDoc/serializeDocToMarkdown,@tiptap/markdownon the shared extension set) — not a second markdown implementation — so the server can never diverge from what the editor renders. The custom-fidelity constructs (tables, footnotes, raw HTML,sim:mentions) are covered by the same code that covers them in the browser; the round-trip test asserts equivalence. - Same Yjs binding as the browser. ProseMirror↔Yjs uses
@tiptap/y-tiptap(what TipTap's Collaboration extension uses), pinned to the same version and sharing the sameprosemirror-model/yjsinstances (peer deps) — so the structure the server produces is byte-compatible with the client, targeting the same'default'fragment. - Merge, not replace.
applyMarkdownToYDocusesupdateYFragment(the primitiveySyncPluginruns on every keystroke) to apply only the diff, so Yjs reconciles the agent's write with in-flight remote edits. The test proves an agent write and a concurrent remote edit both survive. - Server-only, DOM via jsdom. The markdown engine builds a (never-mounted) TipTap editor that
needs a DOM; on the server it's backed by a single lazily-created
jsdomwindow. Lazy-required so the client bundle never pulls jsdom in.
Server-authoritative seeding (shipped alongside this module)
The realtime relay seeds each room's document from this module over an internal endpoint
(buildFileDocSeed → POST /api/internal/file-doc/seed → ensureServerSeed), which let the entire
client-seeder subsystem (election / deadlines / triedSeeders / MAX_SEED_ROUNDS / the
SEED_REQUEST handshake) be deleted. The client's connect-deadline offline fallback is deliberately
kept — it is unrelated to seeding. No feature flag: the cutover is all-at-once.
Remaining stages (future PRs)
- Durable persistence. A DB column for the Yjs binary + debounced snapshotting, so a document survives with no collaborators connected instead of being re-seeded from markdown on cold open.
- Copilot into the doc + projection.
edit_contentcallsapplyMarkdownToYDocwhen a doc is live; a debouncedyDocToMarkdownprojection keeps the file's markdown current.