Agora
Storage

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

  • saveDocument is 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.
  • loadDocument is called when a document is opened and nobody has it in memory.
  • saveVersion receives the document's bytes at that moment and must keep them. Returning them later from loadVersionSnapshot is what makes a restore restore anything; handing back the current state instead turns restore into a silent no-op.
  • pruneVersions is the retention seam: delete all but the keep most recent versions per document and return how many went. It is what node ace collaboration:prune calls, 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.comments and 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 toUse for
lucidStorage({ connection })your app's databaseproduction
FileSystemStoragefiles under .collab-data/working on the library itself
InMemoryCollaborationStorageprocess memorytests, 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.

config/collaboration.ts
import { defineConfig, lucidStorage } from '@adonis-agora/collaboration'

export default defineConfig({
  engine: 'yjs',
  storage: lucidStorage({ connection: 'primary' }),
  // ...
})

On this page