Agora
Storage

Custom storage

Implement CollaborationStorage yourself — the ten methods, the contracts that are easy to get subtly wrong, and a worked S3-backed example.

CollaborationStorage is a plain interface with no base class, no decorator and no registration step. Implement it, pass the instance as storage, and every driver uses it.

config/collaboration.ts
import { defineConfig } from '@adonis-agora/collaboration'
import { S3CollabStorage } from '#services/collaboration/s3_storage'

export default defineConfig({
  engine: 'yjs',
  storage: new S3CollabStorage({ bucket: env.get('COLLAB_BUCKET') }),
  // ...
})

Reasons this comes up: documents large enough that object storage beats a bytea column; encryption at rest with your own keys; a different ORM; a tenant-per-database layout; or a read-through cache in front of the real store.

The five contracts to get right

The interface is easy to implement and easy to implement subtly wrong. These five are the ones that fail quietly rather than loudly:

  1. loadDocument returns null for an unknown document, not an error. A document that has never been opened is normal — it is how every document starts.
  2. saveVersion must keep the bytes it is given. They are the document at that moment. loadVersionSnapshot(docName, versionId) has to return those exact bytes for that id. Returning the current document instead makes restoreVersion a no-op and diffVersions always report zero — with no error anywhere.
  3. saveComment is an upsert. Resolving and reopening call it with an existing id.
  4. listComments filters by space when given one, and returns every space when not. Sorting is handled above you. Both listVersions and listComments accept an optional page ({ page?, size? } — a 1-based page number and a page size, the same shape @adonis-agora/filter uses): omit it and return every row — the HTTP routes always supply one, so honour it or a long history comes back as one ever-growing array. getComment is optional: implement it when your backend can look one comment up by id, and manager.comments.get — which the route guard calls on every comment PATCH and DELETE — stops loading a document's whole thread to authorize one row. Leave it out and that call falls back to a filtered listComments, which is correct but O(n) in the thread.
  5. pruneVersions keeps keep versions per document. Keeping the N newest rows across the whole store lets one busy document delete another one's history. Validate keep (a negative or fractional value is a mistake, 0 is a legitimate "delete them all"), honour dryRun by counting without deleting, and return the number removed — that count is what the ace command prints.

Throw — do not swallow

When a method cannot do its job, reject. The driver wraps every save, reports it through onCollaborationError (scope: 'storage', with the operation and the document name) and rethrows, so a real failure reaches your logger and your alerting. A backend that catches its own errors and returns quietly reinstates exactly the bug that seam exists to close: an application whose documents never persist and whose sockets look perfectly healthy.

No transactions, deliberately

The interface has no transaction boundary because the operations do not need one: a document save is a single row, and a version is written after the bytes it snapshots already exist. Keep your implementation's methods independently safe to retry.

A worked example

app/services/collaboration/s3_storage.ts
import type {
  CollabComment,
  CollabVersion,
  CollaborationStorage,
  ListPageOptions,
  PruneVersionsOptions,
} from '@adonis-agora/collaboration'
import db from '@adonisjs/lucid/services/db'

/**
 * Document bytes and version snapshots live in S3; the metadata that has to be
 * queryable — the version list, the comments — stays in Postgres.
 */
export class S3CollabStorage implements CollaborationStorage {
  constructor(private readonly options: { bucket: string }) {}

  #docKey = (docName: string) => `documents/${encodeURIComponent(docName)}`
  #versionKey = (docName: string, id: string) =>
    `versions/${encodeURIComponent(docName)}/${id}`

  async loadDocument(docName: string) {
    const bytes = await s3.getBytes(this.options.bucket, this.#docKey(docName))
    return bytes ? { state: bytes } : null
  }

  async saveDocument(docName: string, state: Uint8Array) {
    await s3.putBytes(this.options.bucket, this.#docKey(docName), state)
  }

  async listVersions(docName: string, page?: ListPageOptions): Promise<CollabVersion[]> {
    const query = db.from('collab_versions').where('doc_name', docName).orderBy('seq', 'asc')
    // `page` is optional: omit it and return every row (what version-numbering
    // and restore need internally); the HTTP route always supplies one.
    // `page.page` is 1-based — derive the 0-based SQL offset yourself.
    if (page) {
      const size = Math.min(Math.max(page.size ?? 100, 1), 500)
      query.limit(size).offset((Math.max(page.page ?? 1, 1) - 1) * size)
    }
    const rows = await query
    return rows.map((row) => ({
      id: row.id,
      seq: row.seq,
      label: row.label,
      createdBy: row.created_by,
      createdAt: row.created_at.toISOString(),
    }))
  }

  async saveVersion(docName: string, version: CollabVersion, snapshot: Uint8Array) {
    // The bytes first: a metadata row pointing at a missing object is worse
    // than an object nothing points at yet.
    await s3.putBytes(this.options.bucket, this.#versionKey(docName, version.id), snapshot)
    await db.table('collab_versions').insert({
      doc_name: docName,
      id: version.id,
      seq: version.seq,
      label: version.label,
      created_by: version.createdBy,
      created_at: new Date(version.createdAt),
    })
  }

  async loadVersionSnapshot(docName: string, versionId: string) {
    return s3.getBytes(this.options.bucket, this.#versionKey(docName, versionId))
  }

  async pruneVersions({ keep, docName, dryRun }: PruneVersionsOptions) {
    if (!Number.isInteger(keep) || keep < 0) {
      throw new Error(`pruneVersions: keep must be a non-negative integer, got ${keep}`)
    }

    const query = db.from('collab_versions').select('doc_name', 'id', 'seq')
    if (docName) query.where('doc_name', docName)

    // Per document, and only then "everything past the newest `keep`".
    const perDocument = new Map<string, { id: string; seq: number }[]>()
    for (const row of await query) {
      const group = perDocument.get(row.doc_name) ?? []
      group.push({ id: row.id, seq: row.seq })
      perDocument.set(row.doc_name, group)
    }

    let removed = 0
    for (const [name, versions] of perDocument) {
      const doomed = versions.sort((a, b) => b.seq - a.seq).slice(keep)
      removed += doomed.length
      if (dryRun || doomed.length === 0) continue
      for (const version of doomed) {
        await s3.delete(this.options.bucket, this.#versionKey(name, version.id))
      }
      await db
        .from('collab_versions')
        .where('doc_name', name)
        .whereIn(
          'id',
          doomed.map((version) => version.id),
        )
        .delete()
    }
    return removed
  }

  async listComments(docName: string, space?: string, page?: ListPageOptions): Promise<CollabComment[]> {
    const query = db.from('collab_comments').where('doc_name', docName)
    if (space) query.andWhere('space', space)
    if (page) {
      const size = Math.min(Math.max(page.size ?? 100, 1), 500)
      query.limit(size).offset((Math.max(page.page ?? 1, 1) - 1) * size)
    }
    return (await query).map(toComment)
  }

  async saveComment(_docName: string, comment: CollabComment) {
    await db
      .table('collab_comments')
      .insert(toRow(comment))
      .onConflict('id')
      .merge(['body', 'resolved_at', 'updated_at'])
  }

  async deleteComment(_docName: string, commentId: string) {
    await db.from('collab_comments').where('id', commentId).delete()
  }
}

Note the ordering in saveVersion: the snapshot is written before the row that references it. If the process dies in between, you have an orphaned object — cheap to garbage-collect. The other order gives you a version that lists fine and fails to restore.

Testing it

Ten methods with no framework coupling means the fastest test is the interface itself — write the bytes, read them back, and prove the version snapshot is the version's:

const storage = new S3CollabStorage({ bucket: 'test' })

await storage.saveDocument('doc/1', new Uint8Array([1, 2, 3]))
const version = { id: 'v1', seq: 1, label: null, createdBy: null, createdAt: new Date().toISOString() }
await storage.saveVersion('doc/1', version, new Uint8Array([1, 2, 3]))

// the document moves on…
await storage.saveDocument('doc/1', new Uint8Array([9, 9, 9]))

// …and the snapshot must not
expect(await storage.loadVersionSnapshot('doc/1', 'v1')).toEqual(new Uint8Array([1, 2, 3]))

That last assertion is the one that catches the mistake this page warned about. See Testing.

On this page