Skip to content

Commit fa478b2

Browse files
committed
fix(files): fence cache writes and preserve native snapshots
1 parent 9b20724 commit fa478b2

11 files changed

Lines changed: 2025 additions & 725 deletions

File tree

apps/sim/lib/collab-doc/README.md

Lines changed: 57 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,57 @@
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

Comments
 (0)