Agora
Storage

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.

lucidStorage persists documents, versions and comments in your app's own database, through the Lucid connection you name.

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

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

The connection is resolved inside the library, lazily, at first use — there is no import db in your config file and no database access during boot. That matters more than it looks: a config file that touches the container at module scope is a config file that breaks in ace commands and tests.

Omitting the connection uses the app's default

connection is optional: leave it out and the storage uses whatever db.connection() resolves to — the same connection an unqualified Lucid query uses. Name it when collaboration data lives somewhere other than your primary database.

The tables

TableKeyHolds
collab_documentsdoc_namethe encoded document state, plus free-form meta
collab_versions(doc_name, id)version metadata and the snapshot bytes for that version
collab_commentsidanchored comments, indexed by (doc_name, space)

Document state and version snapshots are stored as binary. Comment anchors are jsonb, so a future anchor kind is a code change rather than a migration.

Migrations, and the tables that create themselves

node ace collaboration:init publishes a migration per table. Run them like any other:

node ace migration:run

The storage also issues CREATE TABLE IF NOT EXISTS before its first query, so a fresh clone works before anyone has migrated. Both paths create the same schema — but ship the migrations anyway. They are what makes the schema reviewable in a pull request, versioned with the code, and reversible. The lazy creation is there so development does not stall, not so you can skip it.

Reading the data yourself

These are ordinary tables. Nothing stops you from querying them, and some things are much easier there than through the manager:

// Every document a user has commented on, without opening any of them
const docs = await db
  .from('collab_comments')
  .where('user_id', user.id)
  .distinct('doc_name')

Housekeeping on the version history has its own API — collaboration:prune --dry-run reports what a retention run would delete, and pruneVersions({ keep }) does it from code. Do not write the DELETE yourself.

Reading collab_documents.state is the one exception. Those bytes are a CRDT encoding, not your content — decoding them by hand means reimplementing Yjs. Ask the manager instead, which also gets you the live version when someone has the document open:

const text = await collaboration.current.getDocumentText({ docName })
const bytes = await collaboration.current.getDocumentState({ docName })

Backups

collab_documents is the one table whose loss is unrecoverable — it is the content. Versions are recoverable in the sense that the document survives without them; comments are ordinary rows.

Two properties worth designing around:

  • Row size grows with the document. A long manuscript is a large binary column, rewritten on every debounce window. Watch the write amplification before you lower debounce on a table that is already large.
  • Versions multiply that. Each one stores its own snapshot. Deliberate, user-created checkpoints are fine; an automatic version every minute is a storage plan, not a feature. See Production.

On this page