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.
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:
loadDocumentreturnsnullfor an unknown document, not an error. A document that has never been opened is normal — it is how every document starts.saveVersionmust 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 makesrestoreVersiona no-op anddiffVersionsalways report zero — with no error anywhere.saveCommentis an upsert. Resolving and reopening call it with an existingid.listCommentsfilters by space when given one, and returns every space when not. Sorting is handled above you. BothlistVersionsandlistCommentsaccept an optionalpage({ page?, size? }— a 1-based page number and a page size, the same shape@adonis-agora/filteruses): 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.getCommentis optional: implement it when your backend can look one comment up by id, andmanager.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 filteredlistComments, which is correct but O(n) in the thread.pruneVersionskeepskeepversions per document. Keeping the N newest rows across the whole store lets one busy document delete another one's history. Validatekeep(a negative or fractional value is a mistake,0is a legitimate "delete them all"), honourdryRunby 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
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.
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.
Codegen
Two ace commands and a generated registry that keeps document names, spaces and anchors typed on both sides of the wire — derived from the files on disk instead of a list you maintain.