CRDT for documents (5/6): migrate wire protocol to automerge-repo + cross-instance fan-out - #1753
Closed
gearnode wants to merge 28 commits into
Closed
CRDT for documents (5/6): migrate wire protocol to automerge-repo + cross-instance fan-out#1753gearnode wants to merge 28 commits into
gearnode wants to merge 28 commits into
Conversation
First step toward letting the Go server and Go agents speak the same collaboration protocol as the JavaScript automerge-repo client while keeping our pure-Go CRDT engine as the document authority. Pin @automerge/automerge-repo at exactly 2.6.0-alpha.3 (it is alpha, so the pinned version is the contract) and record the protocol under pkg/automerge/collaboration/PROTOCOL.md: the repo message union, the ephemeral gossip de-duplication by session and count, the Presence envelope and its four message types with the heartbeat and TTL defaults, and the CBOR encoding the payload uses. It also states the layering, so Automerge sync bytes stay owned by our engine, and what is deferred to the transport phase (the WebSocket adapter framing and join handshake). Add a generator that writes byte-exact fixtures using the pinned packages' own CBOR encoder, and commit fixtures for every presence and ephemeral message plus a round-trip guard, so the Go codec built next is validated against real JavaScript output rather than a reading of the type declarations. cbor-x emits 16-bit map-length prefixes rather than the canonical short form, which the fixtures capture. No Go code yet: this establishes the pinned contract and ground-truth fixtures the implementation phases build against. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Implement the message layer of the automerge-repo protocol in Go so the server and Go agents can exchange presence and route sync without re-implementing the CRDT wire format. CBOR is handled by github.com/fxamacker/cbor/v2 rather than a hand-rolled codec. The codec uses one strict shared decode mode: indefinite lengths and CBOR tags are forbidden, duplicate map keys are an error, nesting, map, array, and payload sizes are bounded, and encoding is deterministic core CBOR. cbor-x emits valid CBOR our decoder accepts, and our deterministic output decodes cleanly in cbor-x; byte identity is deliberately not a goal, semantic interoperability is. presence.go models the four presence messages inside the __presence envelope, keeping application values as raw CBOR so a caller decodes them into its own type, and enforces that each type carries only its meaningful fields. message.go models the document-scoped repo messages (sync, request, ephemeral, doc-unavailable) with sync bytes left opaque, plus the ephemeral session/count de-duplication key. Tests decode the exact bytes the pinned JavaScript client produces for every presence type, confirm an ephemeral message carries a presence payload our presence codec reads, round-trip every repo message, and assert the decoder rejects duplicate keys and cross-type fields. Two fuzz targets confirm decoding arbitrary bytes never panics; both are wired into the fuzz-automerge make target. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Pin @automerge/automerge-repo-network-websocket at 2.6.0-alpha.3 and implement the Go side of its wire framing so the gateway and Go agents speak it directly. Each binary WebSocket frame is one CBOR message encoded with the same repo helper used for payloads, so the frame codec reuses the strict shared modes. The handshake is a client join advertising protocol version "1", answered by a server peer selecting a version or an error frame before the socket closes; after that both directions exchange the document messages from the message layer, each as its own frame. There is no length prefix because the WebSocket message boundary is the framing, and no leave frame because a disconnect is the socket closing. transport.go models the join, peer, and error frames plus PeerMetadata, with a FrameKind peek so a reader routes a frame without decoding it into the wrong shape. PROTOCOL.md gains the transport section, including the rule that a repo peer id is peer-chosen and must never be treated as a user identity; the gateway authenticates the connection out of band. Tests decode the exact handshake and framed-message bytes the pinned adapter emits, confirm a framed ephemeral still carries a presence payload our codec reads, and round-trip and validate the frames. The legacy encoder.js and WSShared.js in the adapter are unused by the current adapters and are noted as such. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add ServerSession, the transport-agnostic core of the gateway. It negotiates the join/peer handshake, routes inbound document frames, and de-duplicates gossiped ephemeral messages, all without I/O or CRDT state. The server wiring only has to move bytes and act on the returned decisions: document authority stays with pkg/automerge and peer authentication stays with the server connection. Accept turns a client join into a peer reply, or, for an unsupported protocol version, an accepted=false handshake carrying an error frame to send before closing; a malformed frame is the only hard error. Receive classifies a post-handshake frame as sync to feed a SyncState, an ephemeral to fan out, a doc-unavailable, or an ignored control frame, and refuses any document frame that arrives before the handshake. Ephemeral de-duplication keeps the highest count seen per session rather than a set of every message: the protocol guarantees a session's count strictly increases, so this is both correct and bounded to one entry per session. RemotePeerID is exposed but documented as peer-chosen, never an authenticated identity. Tests drive the handshake with the real JavaScript join frame, reject an unsupported version, route a sync frame, and cover ephemeral dedup across repeats, older counts, and distinct sessions. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add ServerConn, the synchronous driver that turns the session state machine and an Automerge sync state into the server side of one collaboration connection. It owns no socket and starts no goroutines: the adapter reads a frame, calls a method, and sends the frames returned, which keeps the whole loop deterministic and unit-testable and leaves I/O, rooms, and authentication to the server wiring. The behaviour follows the repo synchronizer for a peer that is the document authority, captured in PROTOCOL.md: Start runs the handshake and then announces by draining the sync state into sync frames; Receive applies an inbound sync or request with ReceiveMessage and answers with the generated sync frames, forwards a non-duplicate ephemeral unchanged for the room to fan out, and ignores duplicates, doc-unavailable, and control frames. Because the server always holds the document it only ever emits sync, never request or doc-unavailable. SyncChanged drains sync frames when the document advances from another source. The driver depends only on a small SyncSession interface, which *automerge.SyncState satisfies with no adapter (asserted at compile time). An integration test drives a real empty client sync state through the loop and confirms it converges to the server's document and text, exercising the interop linchpin end to end: repo sync payloads are our engine's sync messages unchanged. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add a live interop test that proves the Go gateway speaks the real automerge-repo protocol, not just our reading of it. A Go WebSocket server backed by ServerConn serves a seeded document, and a real @automerge/automerge-repo client, driven by a small Node script, finds the document by URL and prints what it materialized. The test asserts the client reconstructs the server's text, exercising the handshake, CBOR framing, and the full sync loop against the actual client. Node 22 provides a global WebSocket, so the client script needs no extra dependency. The test is gated on AUTOMERGE_REPO_INTEROP_CLIENT so the default run does not require Node; the test-automerge-repo-interop make target sets it, and the conformance package's check script now also syntax-checks the client. The test file is named interop_client_test.go rather than with a _js suffix, which Go would treat as a GOOS=js build constraint and silently exclude. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add ClientConn, the client-side mirror of ServerConn, so Go AI agents join collaboration as first-class repo peers. Like the server driver it owns no socket and starts no goroutines: the caller sends the join frame it returns, then feeds inbound frames and sends what comes back. Start emits the join. Receive completes the handshake on the peer reply and emits the client's initial sync, applies inbound sync or request and emits the resulting sync, de-duplicates and surfaces ephemeral payloads for the application, and reports error and doc-unavailable frames. The first outbound message is a request when the local document started empty and a sync otherwise, matching the repo synchronizer's isNew rule. SyncChanged pushes local edits and Ephemeral builds a presence or other gossip frame with the caller's session id and count. The driver depends only on the SyncSession interface, so *automerge.SyncState drives it directly. Tests move frames between a ClientConn and a ServerConn until quiescent and assert convergence both ways: an empty agent learns the server's document, and an agent that already has content pushes it to an empty server. Handshake, first-message-is-request, and server-error paths are covered too. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
The repo client picks the automerge:<id> URL it wants, so a gateway that hard-codes a document id can only answer clients that happen to agree on it. NewServerConn required a non-empty id and announced the document proactively in Start, which only works when both peers share the id ahead of time (the interop test hard-codes both sides). Add NewAdoptingServerConn: it announces nothing on the handshake and binds to the id in the client's first sync or request frame, then answers for that id and rejects any other frame on the connection. A fixed-id connection now also rejects frames for a foreign document instead of silently answering with its own id. This removes the need for a server/frontend id-derivation contract before the production endpoint can serve arbitrary documents. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Repo-protocol presence and cursors travel as opaque ephemeral frames rather than the legacy structured presence snapshots, and the hub had no way to relay an arbitrary frame to a room's other peers. Give each room peer a dedicated ephemeral channel, separate from the sync/presence wake channel so a dropped best-effort gossip frame never coalesces with or masks a sync refresh. BroadcastEphemeral fans a frame out to every other local peer and skips the originating one; delivery is best-effort, dropping when a peer's buffer is full since the sender re-emits its ephemeral state. Fan-out is scoped to this server instance. Cross-instance gossip needs the payload carried through realtime.Events, which today only signals that a document changed; that remains a follow-up. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Record how the tested ServerConn/ClientConn drivers slot into the authenticated production endpoint by reusing the existing collaboration hub for auth, document authority, persistence, and sync fan-out, plus the new document-id adoption and ephemeral fan-out primitives. Spell out the contracts that still need the migrated frontend and a Postgres-backed integration test before the endpoint is enabled: cross-instance ephemeral gossip, seeding under the repo protocol, and how a repo client presents its auth credential on the upgrade. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
A remote collaborator's caret published as an integer offset drifts the moment anyone types before it: every downstream offset shifts by one and the caret lands on the wrong character. An Automerge cursor is a stable address that resolves to the same character after arbitrary concurrent edits, which is exactly what a remote caret needs. Add TextSelectionValue, the presence-update value for a caret or selection: the addressed text field plus stable anchor and head cursors (the bytes from Text.Cursor), with encode, decode, and validation helpers and a NewTextSelectionPresence constructor. The presence layer only transports the cursor bytes, so it stays independent of the CRDT engine; the server and Go agents create and resolve them. A round-trip test seeds text, publishes a caret through the presence envelope, inserts characters ahead of it, and shows the same cursor still resolves to the original character where a stored offset would not. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
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>
This was referenced Aug 21, 2026
Closed
Contributor
Author
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.
Part of the split of #1657 ("CRDT for documents") into a review stack. Stack index: 5/6. Stacked on #1752.
What this PR does
The architectural core of the migration: a new Go client/server codec and WebSocket transport for the real automerge-repo wire protocol, a session state machine, gateway wiring (opaque ephemeral fan-out through the collaboration hub, serving documents over the automerge-repo protocol, seeding repo documents from stored content server-side), document-id derivation kept in parity with the JS client, cross-instance ephemeral relay over Postgres NOTIFY, and cursor-based selection presence.
Review lens
This is the "why we're doing this migration" PR and the largest one after stage 2. If it's too much in one pass, it splits cleanly at the "Pin the repo gateway integration contract" commit into protocol/gateway bring-up vs. cross-instance fan-out + selection cursors — flag in review if you'd like it broken up further and I'll split it.
Stack
automerge/1-prototype-bringup(CRDT for documents (1/6): bring up Automerge-backed collaboration (prototype) #1749): bring up Automerge-backed collaboration (prototype)automerge/2-engine-core-parity(CRDT for documents (2/6): Automerge engine core + full upstream interop parity #1750): Automerge engine core + full upstream interop parityautomerge/3-engine-hardening-refactor(CRDT for documents (3/6): fuzzing, ProseMirror render parity, internal refactor #1751): fuzzing, ProseMirror render parity, internal refactorautomerge/4-api-cleanup-perf(CRDT for documents (4/6): public API cleanup + save/load performance #1752): public API cleanup + save/load performanceautomerge/6-frontend-cutover-legacy-removal: cut the frontend over, remove the legacy protocol