Versions
Named checkpoints over a live document — how a version is created, listed, restored into the running session and diffed, and why Yjs snapshots and Automerge's change graph answer the same API differently.
A CRDT gives you a document that always converges. It does not give you the thing users actually ask for: "take me back to how this looked before the review". That is a version — a named checkpoint you create deliberately, list, restore, and compare.
The API
Four calls, identical on every engine:
import collaboration from '@adonis-agora/collaboration/services/main'
const manager = collaboration.current
const version = await manager.createVersion({
docName: 'researches/42/writing',
createdBy: user.id,
label: 'before review',
})
// → { id, seq: 3, label: 'before review', createdBy, createdAt }
const history = await manager.listVersions({ docName })
const summary = await manager.diffVersions({ docName, aId: history[0].id, bId: version.id })
// → { added: 12, removed: 4 }
await manager.restoreVersion({ docName, versionId: version.id, restoredBy: user.id })A version carries a seq alongside its id — a friendly 1, 2, 3… computed from the versions that
already exist, so a UI can say "Version 3" without exposing a UUID. The id is the one you pass
back to restoreVersion and diffVersions.
From the client, the same operations are a hook away:
const { versions, loading, create, restore, refresh } = useVersions({ docName })
await create('before review')
await restore(versions[0].id)Restoring is a live edit, not a rollback
This is the part that surprises people. restoreVersion does not swap a row and ask everyone to
reload. It applies the old content to the live document inside a transaction — which means it
travels to every connected client through the same sync protocol as any other edit.
Three consequences worth planning for:
- Everyone sees it immediately. No refresh, no stale tab. Someone mid-sentence watches the paragraph change under their cursor.
- It is not destructive to the history. The restore is a new state on top of the existing document, so the versions you already created still exist and still restore — and the state the restore replaced becomes a version of its own. There is no "the future was deleted".
- It is not transactional against concurrent typing. An edit landing in the same instant merges with the restored content, because that is what the CRDT does. In practice: put a confirmation in front of the button, and tell the room it is happening.
The snapshot is taken for you
restoreVersion writes the state it is about to replace into the history first, as a new version
labelled restored from #<seq> and attributed to restoredBy — so a restore is itself undoable,
and the history records who ran it. (Before 0.10.0 restoredBy was discarded and nothing recorded
the replaced state, which made restore the one operation in a version history you could not undo.)
await manager.restoreVersion({ docName, versionId, restoredBy: user.id })
await manager.listVersions({ docName })
// → [ …, { seq: 7, label: 'restored from #3 (before review)', createdBy: user.id } ]An explicit createVersion beforehand is still worth it when you want your own label on the
checkpoint.
How each engine answers
The API is uniform; the mechanism underneath is not, and the difference shows up in storage cost.
Yjs has no built-in history, so the driver adds one: createVersion encodes the full document
state and stores those bytes as the snapshot. Restoring hydrates a throwaway document from the
snapshot and applies its content to the live one. Diffing materializes both snapshots, extracts the
text, and reports a line-level { added, removed } summary.
Cost: every version is roughly the size of the document. A 200 KB document with 50 versions is 10 MB. That is fine for deliberate, user-created checkpoints and ruinous for an automatic version every thirty seconds — see Production. Versioning is a feature you expose to users, not a background job.
Automerge has history natively: every change carries a hash, and the change graph is the
history. A version's id is therefore not a generated UUID but the document's heads at that
moment — the hashes that identify the state exactly. The driver still saves the document's bytes
alongside it, so restoring stays a single load rather than a replay of the graph, and Automerge's
compression keeps that cheaper than the equivalent Yjs snapshot.
| Yjs | Automerge | |
|---|---|---|
A version's id | a generated UUID | the document's heads at that moment |
| The stored snapshot | the full encoded state | the full saved document (compressed) |
| Restore | applies the snapshot's content into the live document | applies the snapshot's content as a compensating change |
diffVersions | line-level over the extracted text | line-level over the extracted text |
Both restores are non-destructive — they move the document forward to look like the old state, they do not rewind history.
Where they persist
Versions live in collab_versions (metadata) with their snapshot bytes, through the
storage backend — the same interface that persists the document
itself. They are ordinary rows: queryable, joinable, backup-able, and readable without waking the
document up.
Deleting a single version is not part of the manager API — retention is. pruneVersions({ keep })
keeps the N most recent versions of each document and returns how many it removed; the ace command
collaboration:prune --keep=20 is the same call from a cron entry. See
Production.
Authorization
The permission seam — why a WebSocket needs a token endpoint, how the same authorize callback guards both doors, what fail-closed means in practice, and which of the three permissions the library actually enforces.
Comments
Comments anchored to a place inside a document — the three anchor kinds, what a space is for, the create/resolve/remove lifecycle, and why comments are relational data rather than CRDT state.