|
1 | | -# `@/lib/collab-doc` — server-side collaborative-document conversion |
2 | | - |
3 | | -Server-side conversion between a file's **markdown** (the durable source of truth) and its |
4 | | -collaborative **Yjs document**, so the server can own the doc: seed it, project it back to |
5 | | -markdown, and let the agent write into it while a user is typing. |
6 | | - |
7 | | -## Why this exists |
8 | | - |
9 | | -Collaborative file editing had two writers with no shared CRDT: copilot `edit_content` wrote |
10 | | -markdown straight to the file while the user typed into an ephemeral, client-seeded Yjs doc. They |
11 | | -couldn't reconcile — the agent's edit didn't stream into the editor, and last-writer clobbered. The |
12 | | -fix is a **server-authoritative Yjs doc** both sides write into, with markdown as a projection. |
13 | | - |
14 | | -## What Stage A (this module) provides |
15 | | - |
16 | | -| Function | Purpose | |
17 | | -|---|---| |
18 | | -| `markdownToYDoc(md)` | Cold-start seed: file markdown → a fresh `Y.Doc`. | |
19 | | -| `yDocToMarkdown(ydoc)` | Projection: `Y.Doc` → the file's canonical markdown. | |
20 | | -| `applyMarkdownToYDoc(ydoc, md)` | Agent write: merge new content into a live `Y.Doc` as a minimal CRDT diff (no clobber). | |
21 | | - |
22 | | -### Design decisions (why it's not hacky) |
23 | | - |
24 | | -- **Parity by construction.** The markdown↔ProseMirror step reuses the *exact* client engine |
25 | | - (`parseMarkdownToDoc` / `serializeDocToMarkdown`, `@tiptap/markdown` on the shared extension set) — |
26 | | - not a second markdown implementation — so the server can never diverge from what the editor |
27 | | - renders. The custom-fidelity constructs (tables, footnotes, raw HTML, `sim:` mentions) are covered |
28 | | - by the same code that covers them in the browser; the round-trip test asserts equivalence. |
29 | | -- **Same Yjs binding as the browser.** ProseMirror↔Yjs uses `@tiptap/y-tiptap` (what TipTap's |
30 | | - Collaboration extension uses), pinned to the same version and sharing the same `prosemirror-model` |
31 | | - / `yjs` instances (peer deps) — so the structure the server produces is byte-compatible with the |
32 | | - client, targeting the same `'default'` fragment. |
33 | | -- **Merge, not replace.** `applyMarkdownToYDoc` uses `updateYFragment` (the primitive `ySyncPlugin` |
34 | | - runs on every keystroke) to apply only the diff, so Yjs reconciles the agent's write with in-flight |
35 | | - remote edits. The test proves an agent write and a concurrent remote edit both survive. |
36 | | -- **Server-only, DOM via jsdom.** The markdown engine builds a (never-mounted) TipTap editor that |
37 | | - needs a DOM; on the server it's backed by a single lazily-created `jsdom` window. Lazy-required so |
38 | | - the client bundle never pulls jsdom in. |
39 | | - |
40 | | -## Server-authoritative seeding (shipped alongside this module) |
41 | | - |
42 | | -The realtime relay seeds each room's document from this module over an internal endpoint |
43 | | -(`buildFileDocSeed` → `POST /api/internal/file-doc/seed` → `ensureServerSeed`), which let the entire |
44 | | -client-seeder subsystem (election / deadlines / `triedSeeders` / `MAX_SEED_ROUNDS` / the |
45 | | -`SEED_REQUEST` handshake) be deleted. The client's connect-deadline offline fallback is deliberately |
46 | | -**kept** — it is unrelated to seeding. No feature flag: the cutover is all-at-once. |
47 | | - |
48 | | -## Remaining stages (future PRs) |
49 | | - |
50 | | -- **Durable persistence.** A DB column for the Yjs binary + debounced snapshotting, so a document |
51 | | - survives with no collaborators connected instead of being re-seeded from markdown on cold open. |
52 | | -- **Copilot into the doc + projection.** `edit_content` calls `applyMarkdownToYDoc` when a doc is |
53 | | - live; a debounced `yDocToMarkdown` projection keeps the file's markdown current. |
| 1 | +# Server-side collaborative documents |
| 2 | + |
| 3 | +This module converts workspace Markdown files to and from the shared TipTap/Yjs document. |
| 4 | +Markdown is the durable file content; the persisted Yjs binary retains the causal identities and |
| 5 | +deletion history needed to reconnect existing clients. Equal Markdown does not imply equal history. |
| 6 | + |
| 7 | +## Persistence and seeding |
| 8 | + |
| 9 | +- Convert with the same editor extensions and Markdown pipeline used by the client. |
| 10 | +- Preserve native Yjs snapshots. Normalize the Markdown projection, not a detached shared tree: |
| 11 | + deleting an empty paragraph in a saved snapshot can delete text a disconnected peer types there later. |
| 12 | +- Persist the relay's native full snapshot. Reject a different document identity; stale content |
| 13 | + writes also require a throwaway merge proving that the candidate does not omit durable content. |
| 14 | +- Commit the prepared binary and Markdown pointer in the same file-row transaction. The content |
| 15 | + version and exact cached binary/source hashes must still match their preparation inputs. |
| 16 | +- Cache-only saves and seeds use the same file-row lock and revision check. Unchanged snapshots |
| 17 | + validate their revision without rewriting the row. Simultaneous cold seeds adopt the winning identity. |
| 18 | +- Keep conversion and blob I/O outside the transaction. Bound loaded and prepared binary states to |
| 19 | + 12 MiB; oversized or unavailable cache reads fail rather than masquerading as an absent document. |
| 20 | +- Retry content/cache conflicts a bounded number of times from fresh reads. Infrastructure errors |
| 21 | + propagate so callers can retry without acknowledging an uncommitted snapshot. |
| 22 | + |
| 23 | +External Markdown writes reconcile through `applyMarkdownToYDoc`, using the existing |
| 24 | +`updateYFragment` binding. Equivalent normalized bodies leave the native tree untouched; actual |
| 25 | +content changes apply a diff. This preserves unaffected identities, but is not a guarantee that |
| 26 | +arbitrary structural rewrites retain every concurrent edit. |
| 27 | + |
| 28 | +## Compatibility and limits |
| 29 | + |
| 30 | +All cache writers must use the shared transaction/revision protocol. Drain older application writers |
| 31 | +before relying on its guarantees; an old unconditional writer does not participate in the fence. |
| 32 | +This change does not alter the document schema or migrate existing documents. |
| 33 | + |
| 34 | +Legacy caches can contain private normalization deletions that the live relay never received. |
| 35 | +Unconditionally merging those caches into snapshots can delete delayed edits or prevent saving. |
| 36 | +The relay remains the snapshot owner, as before; this change does not solve retention of extra |
| 37 | +cache-only operations invisible in Markdown. Safely unifying all historical state requires an |
| 38 | +explicit legacy compatibility plan, not a hash check or a guess at deletion provenance. |
| 39 | + |
| 40 | +Native nested-list reparenting can lose concurrent edits to moved content in the current binding. |
| 41 | +A stable-parent list representation requires a separately tested schema and offline-update migration; |
| 42 | +rebuilding a Y.Doc or changing its identity is not a safe migration. |
| 43 | + |
| 44 | +Conversion is server-side and uses a lazily initialized jsdom window for TipTap. It must not enter |
| 45 | +a client bundle. |
| 46 | + |
| 47 | +## Precedent |
| 48 | + |
| 49 | +- [Yjs document updates](https://docs.yjs.dev/api/document-updates): native update merging and encoded state. |
| 50 | +- [Hocuspocus persistence](https://tiptap.dev/docs/hocuspocus/guides/persistence): preserve Yjs binary |
| 51 | + rather than recreating it from JSON on reconnect. |
| 52 | +- [PostgreSQL row locking](https://www.postgresql.org/docs/current/explicit-locking.html): serialize |
| 53 | + conflicting commits under the existing file-row lock. |
| 54 | + |
| 55 | +The repository tests cover conversion, native-history preservation, cache conflicts, seed races, |
| 56 | +and file-manager transaction wiring. Real PostgreSQL concurrency and live collaboration require |
| 57 | +integration validation in addition to those unit tests. |
0 commit comments