Storage
The one persistence seam every driver shares — what CollaborationStorage is responsible for, the three built-in implementations, and how to pick between them.
Three things outlive a WebSocket connection: the document's bytes, its version snapshots, and its
comments. All three go through one interface, CollaborationStorage, and every driver uses the
same instance — so a document keeps its history and its threads whether it syncs through Yjs,
Automerge or an edge worker.
interface CollaborationStorage {
loadDocument(docName): Promise<{ state: Uint8Array } | null>
saveDocument(docName, state, meta?): Promise<void>
listVersions(docName, page?): Promise<CollabVersion[]>
saveVersion(docName, version, snapshot): Promise<void>
loadVersionSnapshot(docName, versionId): Promise<Uint8Array | null>
pruneVersions({ keep, docName?, dryRun? }): Promise<number>
listComments(docName, space?, page?): Promise<CollabComment[]>
saveComment(docName, comment): Promise<void>
deleteComment(docName, commentId): Promise<void>
}Ten methods, no transactions, no query language. That narrowness is deliberate: it is what makes a Postgres backend, an S3 backend and a test double interchangeable.
The optional page on the two list reads is { page?: number; size?: number } — a 1-based
page number (default 1) and a page size (default 50, capped at 200). It intentionally mirrors
@adonis-agora/filter's pagination shape so every @adonis-agora/* package pages the same way; the
0-based SQL offset is derived inside the implementation as (page - 1) * size and never appears on
the interface. Omit the argument entirely and you get every row — internal callers computing the
next version seq or resolving a restore target depend on that, and only the HTTP routes always
supply a page.
What calls what
saveDocumentis called by the driver on its debounce, again when the last client leaves a document (before the in-memory copy is dropped), again on graceful shutdown, and once more at the end of a restore. It receives the full encoded state, not a delta — the storage never needs to understand a CRDT.loadDocumentis called when a document is opened and nobody has it in memory.saveVersionreceives the document's bytes at that moment and must keep them. Returning them later fromloadVersionSnapshotis what makes a restore restore anything; handing back the current state instead turns restore into a silent no-op.pruneVersionsis the retention seam: delete all but thekeepmost recent versions per document and return how many went. It is whatnode ace collaboration:prunecalls, and the reason no one has to write a DELETE against the version table by hand.- The comment methods are ordinary CRUD, called from
manager.commentsand the REST routes.
saveComment is an upsert
Resolving a comment calls saveComment with an existing id. An implementation that blindly inserts
will violate its primary key on the second resolve — upsert on id.
A rejected save is reported, not swallowed
The save paths run inside Hocuspocus hooks, which discard what they throw. The driver therefore
catches, reports through
onCollaborationError with scope: 'storage', and rethrows.
So a storage that rejects is visible — but only if you are listening, or the provider could resolve
the Adonis logger. Subscribe before you need it; the failure mode this replaced was an empty
collab_documents table with a perfectly healthy-looking WebSocket.
The built-ins
| Persists to | Use for | |
|---|---|---|
lucidStorage({ connection }) | your app's database | production |
FileSystemStorage | files under .collab-data/ | working on the library itself |
InMemoryCollaborationStorage | process memory | tests, and the unconfigured default |
Omitting storage entirely falls back to the in-memory implementation. That is a development
convenience with a sharp edge: everything works, nothing survives a restart, and there is no
warning. It is also why persistDocument (the edge write path) throws rather than accepting a
worker snapshot into memory that would evaporate.
import { defineConfig, lucidStorage } from '@adonis-agora/collaboration'
export default defineConfig({
engine: 'yjs',
storage: lucidStorage({ connection: 'primary' }),
// ...
})Binary state
The two machine-to-machine endpoints that carry a document's raw bytes — who calls them, why the write path is secret-authenticated rather than session-authenticated, and what to use them for.
Lucid storage
The production backend — how it resolves the connection, the three tables and their columns, what the published migrations do, and what actually needs backing up.