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
Small, and worth knowing because it explains the trade-offs:
- the client opens
ws://…?doc=<name>&token=<token>&sync=changes; - the server answers
sync=changeswith the text frameautomerge-sync:changes, then sends the whole saved document as a binary; - after a local change (debounced) the client sends only the changes the server lacks
(
A.saveSince); - the server applies them to the room's document and forwards only what was new to the other peers. A payload that adds nothing (a duplicate, or bytes that are not Automerge) is neither forwarded nor saved.
On every connection, reconnects included, the client waits for the server's initial state and then sends whatever that state lacks, so an edit sent on a socket that dropped before the server got it is not lost.
A socket that does not ask for sync=changes gets no text frame and keeps exchanging whole saved
documents both ways: the server applies either shape with A.loadIncremental and sends such a
socket whole documents, so clients from before change chunks keep working in the same room. A
client against a server that never sends the acknowledgement keeps sending whole documents too.
The room's document lives in memory while it has clients. When the last one leaves, its pending
save is written and the room is dropped; the next connection loads it from storage. A room opened
only to answer a server-side call (getDocumentText, getDocumentState, versions) is dropped once
that call is done, and no room is dropped while a client, a call or a save still uses it. Saves are
debounced by debounce, but a document edited without pause is still written at least every 10
seconds (or every debounce, if longer). close() writes whatever is still pending and terminates
the sockets that are still open.
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. It lives on its own entry point,
/automerge, so an app that only uses Yjs never loads Automerge's WebAssembly:
import { useAutomergeDoc } from '@adonis-agora/collaboration-client/automerge'
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; once a reconnected socket has delivered the
server's state, whatever that state lacks is written, and the count returns to zero. 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.