Automerge
A JSON-like CRDT document with a native change graph — the wire protocol, the dedicated useAutomergeDoc hook, why it cannot back a Y.Doc, and the single-instance constraint.
Automerge models a document as JSON you mutate inside a callback. Every mutation produces a change with a hash, and the accumulated changes are the history — no snapshot mechanism bolted on top. That native graph is the reason to choose it.
documents: {
'forms/:id': {
engine: 'automerge',
async authorize(ctx, { params }) { /* ... */ },
},
}The wire protocol
Deliberately simple, and worth knowing because it explains the trade-offs:
- the client opens
ws://…?doc=<name>&token=<token>; - the server sends the whole saved document as a binary on connect;
- after a local change the client sends its whole saved document back, debounced;
- the server merges it into the room's document and re-broadcasts to the other peers.
Full-document exchange rather than incremental deltas. Automerge compresses well, so this is fine for the structured documents it is meant for and unsuited to a 500-page manuscript.
The handshake refuses with a close code rather than an HTTP status, and the codes are meant to be read:
| Code | Meaning |
|---|---|
4000 | no doc in the query string |
4001 | no token, or one that fails verification — bad signature, expired, or issued for another document |
4003 | authorize said canRead: false — final, do not retry |
4503 | authorize itself threw, or the server has no signing key configured — retry with backoff |
4500 | the document could not be loaded from storage |
4003 and 4503 are deliberately different. "You may not" is an answer a client should respect;
"we could not tell" is a transient condition, and reporting the second as the first is how a
database blip on your side turns into a reconnect storm. useAutomergeDoc honours the distinction:
it retries a 4503 and stops dead on a 4003. Both are reported server-side through
the error stream as well.
The client hook
Automerge documents get their own hook, not useCollabDoc:
import { useAutomergeDoc } from '@adonis-agora/collaboration-client'
interface Ficha extends Record<string, unknown> {
content: string
tags: string[]
}
function FichaEditor({ id }: { id: string }) {
const { doc, status, pendingChanges, error, change } = useAutomergeDoc<Ficha>({
docName: `fichas/${id}`,
})
if (error) return <p role="alert">{error.message}</p>
if (!doc) return <p>Loading…</p>
return (
<>
<textarea
value={doc.content}
onChange={(event) => change((draft) => { draft.content = event.target.value })}
/>
{status !== 'connected' && (
<p role="status">Reconnecting — {pendingChanges} change(s) not sent yet.</p>
)}
</>
)
}change(fn) mutates a draft, applies it locally so the UI updates immediately, and schedules the
debounced send. doc is null until the first server payload lands — render a loading state rather
than reaching into it.
synced is the Automerge counterpart of the Yjs synced flag: status === 'connected' only means
the socket is open, while synced means the server's state has actually been merged on the
current connection. It goes back to false on a drop.
Reconnection
The hook rebuilds its socket on a drop, with a fresh token and the same budget as the Yjs
DocSession: exponential backoff from 1s, capped at 15s, five attempts, reset by a connection that
opens. Run out of attempts and error reads reconnect: exceeded 5 attempts (last failure: …) and
carries the real failure as its cause — the exhausted message names what kept failing rather than
replacing it.
A permanent failure is not retried at all, because retrying it cannot succeed and the retry
would end by burying the message that explained it. Those are the close codes the handshake uses for
an answer — 4000, 4001, 4003 — and a document the server routes to another engine
(useAutomergeDoc requires engine=automerge). 4503 and 4500 are outages, so they are
retried.
Changes made while disconnected are not dropped
Do not disable the editor on a disconnect. A change applied while the socket is down stays in
the local document and is counted by pendingChanges; the whole saved document is written the
moment a socket is open again, and the count returns to zero. That works precisely because this
protocol exchanges whole documents — the local doc is its own outbox.
What you owe the user is the fact, not a locked input: show pendingChanges (and status) so
nobody types into a document they believe is syncing when it is not.
useCollabDoc fails on an Automerge document
useCollabDoc returns a Y.Doc, and an Automerge document is a fundamentally different structure —
there is no honest way to hand one back as the other. Pointing useCollabDoc at a document the
server resolves to automerge puts a clear message on the hook's error and leaves status at
'error', instead of silently returning an empty document. It does not throw out of the render, so
read error rather than wrapping the hook. Two hooks, chosen by engine — this is the one seam the
library deliberately does not unify.
Both hooks live under the same CollaborationProvider, so a page with a Yjs body and an Automerge
sidebar is fine — each hook opens the document it names.
What you give up
- No rich-text editor. Tiptap, ProseMirror and CodeMirror all bind to Yjs types. Automerge has no equivalent binding here.
- No awareness. There is no cursor channel in this protocol, so
useAwarenesshas nothing to read for an Automerge document. Server-side presence still works, since it is fed by the connection lifecycle rather than the protocol. - No multi-instance broadcast. Rooms live in one process's memory and fan out to the sockets that process holds. Two Node instances mean two divergent copies. Automerge documents need a single instance, or sticky routing that guarantees one — see Production.
Versions
createVersion records the document's heads — the hashes identifying its exact state — as the
version id, and stores the saved document alongside so a restore is one load rather than a replay.
restoreVersion applies the old content as a compensating change: non-destructive, and visible to
every connected client immediately. See Versions.
Because the id is the heads, a version id is meaningful outside the library: it identifies a state Automerge itself can reason about.
Reading server-side
getDocumentText returns the document's content field as a string — the convention this driver
assumes for a text-bearing Automerge document. Anything else in the document is yours to read
through the raw state:
const bytes = await collaboration.current.getDocumentState({ docName })When to use it
Reach for Automerge when the document is structured data several clients mutate — a shared form, a configuration object, a records board — and the native change graph is worth more than the editor bindings. For anything a person types prose into or points at with a cursor, Yjs is the better answer.
Yjs (Hocuspocus)
The default engine — an embedded Hocuspocus server sharing the Adonis HTTP port, y-protocols over WebSocket, shared types for text and canvases, awareness for live cursors, and snapshot-based versions.
Edge (PartyKit / PartyServer)
Move the WebSocket to Cloudflare Durable Objects — how the worker, the signed token and the /state endpoints divide the work, what you have to own, and when the latency is worth it.