Skip to content

CRDT for documents - #1657

Closed
gearnode wants to merge 196 commits into
mainfrom
cursor/automerge-foundation-6fb7
Closed

CRDT for documents#1657
gearnode wants to merge 196 commits into
mainfrom
cursor/automerge-foundation-6fb7

Conversation

@gearnode

@gearnode gearnode commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a Probo-owned, no-CGO Go API backed by official Rust Automerge 0.10 through WASI/wazero
  • ship a pure-Go Automerge engine as the default, with the Rust/WASM engine retained as a differential oracle
  • reach complete V2 interoperability parity: every interop-required upstream Rust test is covered and the zero-pending audit gate passes
  • write compacted document-chunk snapshots that are byte-identical to Rust/JS save(), replacing the change-stream save
  • beat native Rust on create, map, text editing, and repeated save; load is faster than the WASM oracle though still behind native Rust
  • persist draft snapshots in PostgreSQL with row-locked CRDT merging and authenticated WebSocket sync
  • bind Tiptap rich text to Automerge and safely migrate existing ProseMirror JSON
  • materialize each CRDT update to validated ProseMirror JSON for publishing, PDFs, MCP, and reloads
  • expose revision-guarded live-document tools and stable cursors for Go agents
  • reconnect peers while retaining unsynchronized local changes
  • persist short-lived presence and render remote collaborator cursors across server instances
  • support paragraphs, headings, lists, blockquotes, code blocks, common marks, hard breaks, and horizontal rules
  • ABI/checksum-validate the engine, fuzz malformed documents, and audit Rust advisories/licenses/sources
  • keep table documents on the legacy path pending a lossless table CRDT representation

Interoperability parity

AUTOMERGE_REQUIRE_FULL_INTEROP=1 passes: 312 interop-required upstream tests covered, 0 pending.

Capabilities added to close the final gaps:

  • Transaction isolationisolate pins reads and writes to a historical frontier and branches using derived concurrency actors (matching Rust's with_concurrency scheme); merges stay hidden until integrate, and repeated isolate/integrate cycles reproduce the reference exactly.
  • Compacted snapshot saveSave writes a single document chunk (frontier changes plus indexed ancestry, deletes folded into successor lists, columns DEFLATE-compressed above the reference threshold), byte-identical to the reference for the same history; Save(ctx, NoCompress()) and Save(ctx, DiscardOrphans()) cover the option combinations, and the save falls back to the faithful change stream when a history cannot be compacted.
  • Orphan retention — saves append retained orphan changes and loads fall back to a dependency-tolerant path that applies what it can and queues the rest, while still rejecting a bare orphan with no base.
  • Isolation-aware incremental diff — diffs chain through the isolation frontiers recorded in a window so the patch stream matches the reference across isolate/integrate; materialization now skips losing conflict alternatives at a map key.
  • V2 sync internals — the empty message round-trips to the reference wire bytes, and Bloom false-positive recovery converges on both engines (the native engine compares heads exactly rather than using Bloom filters, so it is immune to false positives by construction).

Two behaviours are documented rather than silently asserted: observe_counter_change_application is covered as native-matches-reference because the pinned reference collapses the applied counter change into a single put, and the dangling-mark error path (marking past the end of the text) is now differentially gated across randomized scenarios. See PARITY_PLAN.md.

Legacy V1 sync, JavaScript binding ergonomics, and Rust-internal rustdoc examples are intentionally out of scope and classified as api-convenience or language-specific (44 api-convenience covered, 233 pending, 123 language-specific).

Robustness fixes

Two production-reported defects were root-caused and fixed with regression gates:

  • Snapshot load dropped ancestry — a document chunk only stores hashes for the frontier, so every non-head change decoded without a hash or bytes and never entered the change graph; the document read fine but ChangesSince aborted with "cannot compute changes from unknown heads", wedging collaboration. The decoder now rebuilds each change's identity (dependencies, hash, and bytes) while loading, verified byte-identical to the reference. No stored data was lost, so affected documents recover on next load.
  • Sync could fail to quiesce — an orphaned change and a stale read-only need could each keep the protocol from settling; both now converge, covered by a model-based sync chaos test.

ChangesSince also degrades to the consistent reachable prefix instead of failing the whole read, so a single unreachable change can never again take a document offline.

Performance

Several quadratic and rebuild-on-every-call paths were removed. Sequence reads, per-keystroke splices, and map-property reads are backed by incremental indexes (insertion order, materialized elements/values, cumulative UTF-16 offsets, insert-order positions, and a map-property index) that extend in place on append and rebuild lazily otherwise. The compacted save is cached against a mutation revision so repeated saves of an unchanged document are O(1). Load decode buffers are presized to the counts they are about to hold.

Native Go vs native Rust (median of 3; Rust/Go above 1 means Go is faster):

Workload Size Native Go Native Rust Rust/Go
create 1.47 µs 1.38 µs 0.94x
map 100 0.24 ms 0.96 ms 3.97x
map 1000 2.64 ms 10.15 ms 3.85x
text 100 0.10 ms 0.70 ms 6.88x
text 1000 1.16 ms 6.88 ms 5.93x
load 10000 6.23 ms 2.45 ms 0.39x
save 10000 (unchanged) 60 ns 29.8 µs 496x

Highlights from this pass: text typing became linear in document size (a thousand inserts fell from 7.5 ms to 1.16 ms, ~5.9x faster than Rust), repeated save fell from 3.9 ms to tens of nanoseconds (the collaboration snapshot path), and load fell from 11 ms to 6.2 ms with memory halved from 27 MB to 14 MB.

The save row is the cached, unchanged-document case (the server snapshot pattern); the first save after an edit rebuilds and re-encodes the document. load is faster than the WASM oracle but still ~2.5x slower than native Rust — the remaining gap is per-operation decode allocations and change-identity reconstruction for document chunks, and is the main open optimization.

Testing

  • Go race tests and vet across Automerge, coredata, probo, and console API (4,889 tests; database-backed integration tests skipped without PostgreSQL)
  • zero-pending V2 interoperability audit gate
  • randomized/property differential testing for marks (including the out-of-range dangling-mark error path), byte-identity gates for concurrent edits and snapshot encoding, and a model-based sync chaos test
  • malformed-input fuzzing across load, core operations, decode, sync messages, and rendering
  • official Automerge JavaScript interoperability suite
  • official Rust benchmark-battery fixture replay (Rust to Go to Rust)
  • native and WASM engine benchmarks, plus Go/native-Rust comparison
  • Rust formatting, clippy with warnings denied, and cargo-deny audit
  • UI and console TypeScript checks, rich-editor Vitest collaboration tests, production console build, and ESLint on changed TypeScript files
Open in Web Open in Cursor 

Summary by cubic

Live, cross‑instance CRDT collaboration for documents using Automerge, with incremental sync, presence, and a console editor that collaborates in real time. A pure‑Go Automerge 0.10 engine is now the default (with a pinned Rust/WASM oracle); previously editors saved isolated ProseMirror JSON, now they sync live while the JSON path remains the publishing projection.

Tests +5646 -0

  • Differential parity and interop suites for decode/encode/materialize/diff (including incremental), sync (v1/v2, read‑only), rich‑text spans/marks/blocks, rollback; UI collaboration tests, fuzzing, and JS oracle fixtures.

Service +3976 -0

  • New owned pkg/automerge (pure‑Go default, Rust/WASM oracle): stable cursors; current‑state and diff with incremental cursors; rich‑text spans/marks/blocks; persisted sync state.
  • Collaboration codec/transport for @automerge/automerge-repo: incremental sync with in‑flight ack tracking and topological apply; preserved remote hashes; CBOR presence; in‑memory rooms; append‑only log with compaction; PostgreSQL NOTIFY fanout; graceful shutdown.

App: console +613 -3

  • Bind RichEditor via createRichEditorAutomergeDocument over authenticated WebSocket with reconnect/backoff; pause edits during catch‑up; render remote cursors.
  • Enable dev WS proxy.

Package: ui +1884 -145

  • Collaborative RichEditor via @automerge/prosemirror: custom sync plugin with structural span updates, presence, table and divider support, and collaborative undo.
  • Keep slash commands out of CRDT history and hide placeholder during commands; export createRichEditorAutomergeDocument and supportsRichEditorCollaboration.

Package: automerge-conformance +4517 -0

  • JS oracle, pinned parity inventory generator, collaboration fixtures, and mappings.

Package: automerge-benchmark +2588 -0

  • Go/Rust harnesses and compare script; official upstream fixtures runner.

Package: i18n +5 -2

  • humanizeSeconds returns session/persistent strings for non‑expiring values (optional storageType).

Package: coredata +1 -1

  • Test script tolerates no tests.

Package: helpers +1 -1

  • Test script tolerates no tests.

Package: hooks +1 -1

  • Test script tolerates no tests.

Package: relay +1 -1

  • Test script tolerates no tests.

Package: routes +1 -1

  • Test script tolerates no tests.

Other +592 -138

  • CI: automerge-battery and test-js workflows; Make targets for parity/fuzz/bench/fixtures; ignore WASM artifacts; trufflehog excludes.
  • Deps: add github.com/tetratelabs/wazero, github.com/coder/websocket, github.com/rivo/uniseg, github.com/fxamacker/cbor/v2; lockfile updates; benchmark script.

Written for commit 23ad6ad. Summary will update on new commits.

Review in cubic

cursoragent and others added 17 commits August 7, 2026 12:53
Own the Go API and WASI adapter while using the official Rust engine as
the initial correctness oracle. This avoids CGO and keeps backend
replacement under our control.

Add convergence and JavaScript interoperability checks so a future
native Go engine can be evaluated independently before adoption.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Provide peer-specific sync state and message exchange through the owned
Go API. Persistable sync state allows WebSocket sessions to reconnect
without coupling callers to the Rust reference backend.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Store version-scoped Automerge snapshots in PostgreSQL and expose an
authenticated WebSocket sync endpoint. Row locking and CRDT merging keep
concurrent writers convergent across server instances.

Add stable text cursors and a service operation so Go agents can edit the
same live document without replacing the frontend state.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Seed supported ProseMirror documents into Automerge rich text and bind
Tiptap transactions to the live CRDT over the authenticated WebSocket.
Remote changes now appear in open editors while the existing JSON save
path remains the publication and PDF projection.

Fall back safely for schema nodes that cannot round-trip yet, and disable
the collaborative editor after a connection loss to prevent divergence.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Render Automerge rich-text spans to validated ProseMirror JSON inside the
same transaction that persists CRDT state. Agent-only edits now update
publishing, PDF, MCP, and reload paths without requiring an open browser.

Expose revision-guarded read and edit tools so Go agents retry safely when
a customer changes the document concurrently.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Keep unsynchronized local CRDT changes across WebSocket failures and retry
with bounded exponential backoff. Editing pauses while disconnected and
resumes after the peer catches up with PostgreSQL state.

Verify the embedded engine checksum, disable unsupported collaborative
undo, and fuzz malformed Automerge documents to harden the boundary.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Persist short-lived selection presence in PostgreSQL so remote cursors work across server instances and disappear after disconnects or expiry. The editor relays throttled heartbeats and renders deterministic collaborator selections without exposing names.

Also map hard breaks and horizontal rules through Automerge rich text and the server-side ProseMirror projection.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Check advisories, licenses, duplicate crates, wildcard requirements, and dependency sources with the pinned Rust toolchain. The policy allows only licenses present in the locked WASM graph and documents the single unavoidable syn version split.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Start the clean-room Go engine with bounded LEB128, RLE, column, change, compressed-change, and document decoders. Validate chunk checksums and decompression limits before exposing any operation state.

Cross-check JavaScript-generated change metadata and official WASM document snapshots while keeping the reference backend as production default.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Decode v0.10 batch operations, booleans, deltas, scalars, object IDs, sequence keys, and predecessor groups into a bounded Go operation graph.

Apply causal changes, resolve deterministic concurrent insert ordering, track heads, and match JavaScript materialization for concurrent text histories applied in either order.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Parse and reproduce v1 and v2 sync wire messages with bounded heads, needs, bloom filters, changes, and capability flags. Cross-check byte-for-byte output against JavaScript-generated messages while the native peer state machine remains gated.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add a clean-room native parser for Automerge 0.10 document, change,
and compressed-change chunks. Validate checksums, canonical encodings,
column structure, operation identifiers, and dependency graph invariants.

Keep the implementation internal and leave the reference WASM backend as
the production default while native materialization remains incomplete.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Replace the first container prototype with the independently tested decoder that reconstructs snapshot changes and operations, preserves unknown data, validates ownership and causal frontiers, and fuzzes malformed histories.

Retain the native materializer and sync wire codec on the stronger typed representation. Production still uses the WASM oracle until mutation, encoding, and peer-state parity are complete.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Serialize typed Go operations into official change chunks with deterministic actor tables, scalar metadata, predecessor groups, checksums, and implicit operation IDs.

Load the native output in official Automerge JavaScript and verify materialized content, metadata, and hashes before enabling mutation APIs.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Implement native map and text mutations, UTF-16 splices, canonical cursors, commits, saves, snapshot extension, deterministic merges, heads, persisted sync state, and bidirectional synchronization with the reference backend.

Keep WASM as the default while rich-text marks, full cursor deletion semantics, compaction, and exhaustive sync transcripts remain under differential validation.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Complete native snapshot loading, rich-text span hydration, canonical cursor behavior, causal partial-change merging, actor reuse, and bidirectional reference sync. Keep explicit reference constructors as the independent oracle.

Randomized text histories, repeated mixed-peer transcripts, overlapping rich-text marks, official JavaScript change loading, race tests, and 319k malformed-input fuzz executions now pass with native New and Load.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
@cursor cursor Bot changed the title Establish trusted Automerge reference backend Add native realtime document collaboration Aug 7, 2026
cursoragent and others added 9 commits August 7, 2026 15:27
Apply the repository whitespace policy across the collaboration code and correct staticcheck and errcheck findings for error text, string iteration, and DEFLATE reader cleanup.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Track native sync messages in flight and stop generating until the remote peer responds. This lets the server drain one outbound message and quiesce instead of closing and reconnecting in a loop.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Order every change in a sync payload by its causal dependencies before applying it. ProseMirror can emit a reconciliation child before its parent when Enter splits a block, which previously closed the WebSocket and triggered reconnect loops.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Store imported change chunks byte-for-byte instead of re-encoding them under new hashes. Queue causally incomplete changes across messages, request their missing dependencies, and apply the queue once each parent arrives.

This fixes the follow-up edit loop after a successful block split and preserves unknown columns and compressed changes during persistence.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Show the block and slash menus during collaboration while filtering only the unsupported table action. Headings, lists, code blocks, blockquotes, Mermaid, and dividers are available again without enabling lossy table serialization.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Encode table and row containers as explicit Automerge block markers so row boundaries survive sync. Cells retain independent text and colspan, rowspan, and colwidth attributes while TableKit menus and commands remain enabled.

Add seed migration, native Go hydration, server-side ProseMirror projection, cell-edit and row-insert plugin tests, and concurrent row convergence coverage.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Keep Tiptap's local history extension enabled with Automerge. Local table and text edits are encoded as inverse CRDT changes on undo, while remote sync transactions remain excluded from the local history stack.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Mark horizontal rules as explicit block markers instead of inline embeds. This closes the active paragraph before insertion, preventing the Automerge ProseMirror adapter from producing a null content match.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Round-trip the code-block language attribute through Automerge and ProseMirror. Mermaid blocks now retain language=mermaid after local edits, remote sync, reload, and server-side projection instead of degrading to ordinary code blocks.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
@gearnode gearnode changed the title Add native realtime document collaboration CRDT for documents Aug 8, 2026
cursoragent and others added 2 commits August 8, 2026 13:05
Compute the causal change set missing from each peer's acknowledged heads and send exact raw change chunks. Full snapshots are now reserved for unknown baselines, while explicit need requests return only requested changes.

Add a regression proving steady-state frames contain one new change and remain smaller than the accumulated document.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Share one Automerge document per active version within a server process and wake every peer immediately after applying an edit. Same-instance collaboration no longer waits for the 500 ms PostgreSQL reconciliation tick.

Keep row-locked persistence and periodic refresh as durability and cross-instance fallbacks, with reference-counted room teardown after the last peer leaves.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
cursoragent and others added 20 commits August 12, 2026 13:50
Record that carets and selections travel as a TextSelectionValue of
stable Automerge cursors rather than integer offsets, and why: offsets
drift under concurrent edits while cursors stay anchored.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Browser tabs and Go agents must reference a version's document by the
same automerge-repo id, or their sync and, especially, their ephemeral
gossip do not line up: a peer drops an ephemeral frame whose document id
it does not recognise.

Add a dependency-free base58check codec and the repo document-id helpers:
EncodeDocumentID/DecodeDocumentID round-trip the 16-byte id, ValidateID
checks it, and DeriveDocumentID hashes an arbitrary seed (a version GID)
to a stable id so every peer computes the same one without coordination.
AutomergeURL/ParseAutomergeURL wrap and unwrap the automerge: scheme.

The codec is validated against a genuine @automerge/automerge-repo
document id, which exercises the checksum and alphabet against real
upstream output rather than against itself.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Record the decisions the gateway and frontend must share to migrate
document editing to the automerge-repo protocol: version-scoped routing,
deterministic document-id derivation, unchanged cookie/bearer auth, and
server-authoritative seeding, plus how presence, reconnect, and rollout
work.

This is the artifact that unblocks the endpoint: it settles the three
contracts that could not be guessed as production code and points each at
the primitives already built and tested in this package.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The browser must compute the same automerge-repo document id for a
version as a Go agent, or their sync and ephemeral presence do not line
up. Port the base58check codec and derivation from
pkg/automerge/collaboration/documentid.go to TypeScript in @probo/ui and
export it for the console.

The vitest suite decodes a genuine automerge-repo id and, crucially,
asserts deriveDocumentId matches the Go DeriveDocumentID byte-for-byte
for a set of seeds, so the two implementations cannot drift.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Update the gateway contract status now that the TypeScript
deriveDocumentId mirror ships and is verified byte-identical to the Go
implementation.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Mount a /repo route beside the custom /sync route so the two collaboration
protocols coexist during migration. It reuses the existing auth and the
hub's room, persistence, and cross-instance refresh, and drives the wire
protocol with the tested adopting ServerConn: the client picks the
document id, the server answers for it. Presence and cursors ride repo
ephemeral gossip, fanned out to a room's other peers with the hub's
ephemeral primitives.

Because the repo protocol has no seed handshake, the route refuses an
unseeded document rather than serve an empty one as authoritative;
server-side seeding is a separate contract item.

The loop is a standalone function so it can be driven end-to-end in Go by
a real ClientConn with no database: one test converges a client on the
seeded document, another gossips an ephemeral between two clients through
the hub. Both pass under the race detector.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Update the gateway contract status: the /repo route is wired and driven
end-to-end in Go, with Postgres integration, live JS interop, and
server-side seeding still outstanding.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Seeding a collaboration document server-side needs the forward direction
of the ProseMirror bridge, which existed only in TypeScript. Add ToSpans,
the inverse of Render: it walks a ProseMirror document into the rich-text
spans Text.UpdateSpans writes, mirroring how @automerge/prosemirror
flattens the tree.

Every block node emits a marker carrying its full ancestor path of
Automerge block types; list wrappers are transparent and only their items
become blocks; marks map through the shared schema ledger. A container's
first paragraph folds into the container's own content the same way the
renderer reconstructs it: a list item always folds its first paragraph
because the renderer always re-synthesizes one, while a blockquote or
table cell only folds a non-empty first paragraph (or a sole child), so an
empty first paragraph with siblings survives.

The gate is a round trip over the shared 283-document corpus: converting
the canonical ProseMirror JSON to spans, writing them into a fresh
document, and rendering it back reproduces the input exactly, which is the
property server-side seeding relies on.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The repo protocol has no seed handshake, so the /repo route previously
refused an unseeded document. Seed it server-side instead: the connection
that claims the seed converts the version's stored ProseMirror content to
spans with prosemirror.ToSpans, writes them into the shared document, and
persists. Persisting marks the state seeded, so later connections skip
seeding and just sync.

An in-Go test drives a real ClientConn against the loop for an unseeded
version and confirms the client materializes the seeded content, covering
the whole server-authoritative seeding path without a database.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Update the gateway contract: ProseMirror to spans conversion and
server-authoritative seeding now ship, leaving the Postgres seed-lifecycle
integration test as the remaining seeding work.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Presence and cursors are relayed between server instances over the
collaboration NOTIFY channel, which until now carried only a bare
document-version id meaning "this document changed". Add a typed envelope
that coexists with that signal: a bare id is never valid JSON, so the two
payload kinds never collide.

The envelope carries the version id, the publishing instance id (so a
server can ignore its own echo), and the opaque frame. Encoding rejects a
payload over a NOTIFY-safe size so an oversized frame falls back to
local-only fan-out rather than failing the notify.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add NotifyCollaborationEphemeral: it encodes an opaque repo gossip frame
into the cross-instance envelope and fires it over the collaboration
NOTIFY channel, stamped with the publishing instance id. It is
fire-and-forget, touches no document, and runs outside any transaction;
an oversized frame returns an error so the caller keeps local fan-out.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Give the hub a per-instance id and teach notifyExternal to tell the two
NOTIFY payload kinds apart: a bare version id still wakes peers to
refresh, while an ephemeral envelope is fanned out to the room's local
peers. A server ignores its own echo, since it already delivered the
frame directly when it published it.

The /repo loop now relays each gossiped frame to other instances through
a publisher hook wired to NotifyCollaborationEphemeral, in addition to
the existing local BroadcastEphemeral. A relay failure or oversized frame
is logged and skipped rather than dropping the connection.

Tests cover the hub delivering an external frame while suppressing its own
echo, and the loop handing each gossiped frame to the publisher.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Update the gateway contract and protocol notes: repo presence and cursors
now propagate between server instances over the NOTIFY channel, leaving
only the Postgres delivery path to integration-test.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Repo presence carries a collaborator's caret as stable Automerge cursors
rather than integer offsets, so a remote caret stays on the right
character while other people type. Add the browser helper that builds a
selection from the editor's caret offsets and resolves a selection back to
offsets against the current document, plus a collapsed-caret check.

The shape (field, anchor, head) mirrors the Go TextSelectionValue so both
describe the same thing; the cursor values are the JavaScript Automerge
cursor type exchanged between browser peers.

A vitest suite proves a caret stays anchored across a concurrent insertion
where a stored offset would drift, and that a range selection round-trips.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Record that the browser cursor-based selection helper ships and is tested
for cursor stability, leaving the repo NetworkAdapter and live validation
as the remaining frontend work.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The presence decorations work in ProseMirror position space, but stable
cursors live in Automerge text-offset space. Add the bridge in @probo/ui:
presenceFromPmSelection turns a caret or selection into stable cursors to
publish, and pmSelectionFromPresence resolves a remote selection back to
ProseMirror positions against the current document.

The position mapping reuses @automerge/prosemirror's own conversion
helpers so it stays consistent with the editor binding. A vitest suite
builds the real schema adapter and a document and checks a caret
round-trips, a remote caret stays anchored across a concurrent insertion,
and a selection range preserves its endpoints.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Replace the custom collaboration transport with the official
automerge-repo client. connectRepoDocument opens a Repo over a
WebSocketClientAdapter pointed at the version's /repo endpoint and finds
the server-seeded document by its derived automerge: URL, so the client
no longer seeds or runs a hand-rolled sync loop.

Presence rides repo ephemeral messages carrying stable cursors: the
handle maps the editor's caret to Automerge cursors on the way out and
resolves remote peers' cursors back to positions on the way in,
accumulating the latest per peer and pruning ones that go quiet. The
editor and its presence decorations are unchanged; only the transport
behind the handle differs.

Delete the custom AutomergeDocumentHandle and add the automerge-repo
dependencies.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The editor now speaks the automerge-repo protocol exclusively, so drop the
custom collaboration stack: the /sync route and its handler, the
DB-backed collaborator-presence storage (service and coredata), and the
hub's structured presence fan-out. The handler file keeps only the pieces
the repo handler shares: the handler type, connection authorization, and
the WebSocket read/write helpers.

Presence and cursors now travel solely as repo ephemeral gossip, so the
room's presence map, UpdatePresence, and the wake channel's presence
fields are gone; the hub retains sync fan-out, persistence, and the
opaque ephemeral relay.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Update the gateway contract: the editor now uses the automerge-repo
client over /repo with stable-cursor presence, and the legacy /sync
route, custom handle, and DB presence are removed.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
@gearnode

Copy link
Copy Markdown
Contributor Author

Closing this in favor of a review stack — same change, split into 6 sequential PRs for easier review:

  1. CRDT for documents (1/6): bring up Automerge-backed collaboration (prototype) #1749 — bring up Automerge-backed collaboration (prototype)
  2. CRDT for documents (2/6): Automerge engine core + full upstream interop parity #1750 — Automerge engine core + full upstream interop parity
  3. CRDT for documents (3/6): fuzzing, ProseMirror render parity, internal refactor #1751 — fuzzing, ProseMirror render parity, internal refactor
  4. CRDT for documents (4/6): public API cleanup + save/load performance #1752 — public API cleanup + save/load performance
  5. CRDT for documents (5/6): migrate wire protocol to automerge-repo + cross-instance fan-out #1753 — migrate wire protocol to automerge-repo + cross-instance fan-out
  6. CRDT for documents (6/6): cut the frontend over, remove the legacy protocol #1754 — cut the frontend over, remove the legacy protocol

Each PR is based on the previous stage's branch; merge in order top to bottom.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants