Documents
How a document name becomes an engine, an authorization rule and a row — pattern matching with typed params, engineFor, the resolution order, and why names never travel as path segments.
A document is identified by a name: a path-like string such as researches/42/writing or
whiteboards/8. It is not an id in a table, and there is nothing to create before you use it —
the first client that opens a name brings the document into existence, empty (or holding whatever
its load hook gives it).
That one string is doing four jobs at once:
- it selects the engine that syncs the document;
- it selects the authorization rule that guards it;
- it is the primary key its state, versions and comments persist under;
- it is the room key — everyone connected to the same name sees each other's edits.
Names contain slashes, so they never ride in the path
researches/42/writing cannot be a route parameter without either escaping it or inventing a
wildcard segment, and both are how bugs get in. Every endpoint in this library takes the document
name in the query string or the body instead:
GET /collaboration/token?doc=researches/42/writing
GET /collaboration/comments?doc=researches/42/writing&space=text
POST /collaboration/versions { "docName": "researches/42/writing", "label": "before review" }The one place a path parameter appears is the comment id (PATCH /collaboration/comments/:id) —
an opaque id with no slashes in it.
Declaring document types
When each kind of document has its own rule, declare the types and let the library match. The
common shape — many documents named <prefix>/<id>, one engine, one rule — is a collection:
import { defineCollection, defineConfig } from '@adonis-agora/collaboration'
documents: {
...defineCollection('whiteboards', {
engine: 'automerge',
async authorize(ctx, { params }) {
// params.id is a string, extracted from the incoming name
const member = await Whiteboard.isMember(params.id, ctx.userId)
return { canRead: member, canWrite: member, canComment: member }
},
}),
}defineCollection('whiteboards', …) declares the pattern whiteboards/:id: it matches
whiteboards/42, whiteboards/abc, any whiteboards/<one segment>. Spread it into documents
(it returns the entry, keyed by the pattern it built). Omit the declaration when the globals apply —
...defineCollection('announcements') routes announcements/<id> through engine and authorize.
A shape that is not <prefix>/<id> is written out as a pattern key — the collection above is
literally 'whiteboards/:id': { … }:
documents: {
'researches/:id/writing': {
async authorize(ctx, { docName, params }) {
const research = await Research.findVisibleTo(ctx.userId, params.id)
if (!research) return { canRead: false, canWrite: false, canComment: false }
return { canRead: true, canWrite: true, canComment: true }
},
},
'workspaces/:workspaceId/boards/:boardId': { engine: 'automerge' },
}A pattern without a `:segment` matches exactly one document
The matcher is deliberately small: a pattern and a name are split on /, they must have the
same number of segments, a :name segment captures anything, and every other segment must be
equal. There is no *, no prefix match, no optional segment, no regex.
So 'whiteboards' is not "everything under whiteboards/" — it is the one document named
whiteboards, and whiteboards/42 falls through to the globals. A collection needs the
:segment: 'whiteboards/:id', or defineCollection('whiteboards'), which cannot be written
without it. The manager logs a warning at boot for every declared pattern that has no :segment,
naming the fix; a genuine singleton (one shared announcements document) can ignore it.
researches/:id/writing matches researches/42/writing but not
researches/42/writing/draft — a different shape is a different document type, and gets its own
entry. The bluntness is the point: you can read a pattern and know exactly what it accepts.
params is typed from the key. defineConfig reads the :segments out of each pattern, so
'whiteboards/:boardId' hands its authorize a params: { boardId: string } and params.id there
is a compile error, not an undefined at runtime. A pattern with no :segments gets params: {}.
There is no type parameter to pass and nothing to keep in sync with the string.
The same shape is available on its own as DocumentParams<'researches/:id/writing'> when you need
it outside the config, and defineDocument() remains for declarations that live away from their
key (a shared module, a CollaborationManager built by hand in a test) — there it takes the params
type explicitly, and attaching it to a key that disagrees is rejected at the config.
Order matters, first match wins
Patterns are tried in the order they appear in the object. If two patterns can match the same name, put the more specific one first.
The first content of a new document
Some documents are not born empty. The text already exists — in a column, in a file someone
imported, in the row the wizard just created — and the document is a new view of it. Declare a
load and the library asks for that content the first time the document is opened:
import { TiptapTransformer } from '@hocuspocus/transformer'
documents: {
'researches/:id/writing': {
async authorize(ctx, { params }) { /* … */ },
async load({ params }) {
const research = await Research.find(params.id)
if (!research?.body) return null // nothing to seed: opens empty
return TiptapTransformer.toYdoc(research.body, 'default')
},
},
}It runs only when the storage has nothing under that name, and what it returns is written to storage before the document is served. So it is once per document, not once per connection: the second client — and every client after a restart — is loaded from storage and never reaches the hook. A document that already exists is never re-seeded, whatever the hook would say today.
Return a Uint8Array (the state in the engine's own binary format:
Y.encodeStateAsUpdate(doc), A.save(doc)), or a Y.Doc when the engine is Yjs and you already
have one, or null/undefined for "there is nothing here" — which leaves the document empty and
lets the hook try again next time. params is typed from the pattern key, exactly like
authorize's.
This is the seam that stops an empty editor from erasing a real text
Without it, seeding is the app's problem, and the only lever the app has is a request it believes
runs before the WebSocket does. When that belief is wrong the editor mounts against an empty CRDT
document — the CRDT is what the editor shows — and the first autosave writes that emptiness over
the text in the database. Not a blank screen: a deletion. Declaring load moves the ordering
inside the library, where the document is not served until the seed is in it.
A load that throws is reported through the observability
hooks and the document opens empty. It is deliberately not fatal: a failed handshake is
indistinguishable from a denial to the client, which reconnects and runs the failing query again,
forever. Two clients opening the same new document at once seed it once — the driver serialises
the hook per document name, because two CRDT updates carrying the same paragraph merge into two
paragraphs.
The resolution order
For any incoming document name, the library answers three questions independently.
Which engine?
config.engineFor(docName)— if you provided the function, it wins outright, for every document.- The
engineon the matcheddocumentsentry. config.engine(the global default;'yjs'when unset).
Which authorization?
- The
authorizeon the matcheddocumentsentry. config.authorize(the global callback).- Deny —
{ canRead: false, canWrite: false, canComment: false }.
Which initial content? (only for a name the storage has never stored)
- The
loadon the matcheddocumentsentry. - Empty. There is no global
load: what a new document should contain is a property of that document type, and a fallback for every type at once would be a fallback for none of them.
Note the asymmetry: engineFor overrides the per-document engine, but a per-document authorize
overrides the global one. That is intentional. Engine routing is usually a broad, mechanical rule
("everything under whiteboards/ is Automerge") best expressed once; authorization is
document-specific by nature and should live next to the pattern it belongs to.
// A single sweeping rule, when patterns would be repetitive:
engineFor: (docName) => (docName.startsWith('whiteboards/') ? 'automerge' : 'yjs'),Naming documents well
The name is a public identifier that shows up in URLs, logs and the presence roster, and it is the thing your patterns match on. A few habits pay off:
- Put the owning resource first:
researches/42/writing, notwriting/researches/42. Patterns read naturally and prefix-based rules (engineFor) become possible. - Keep the segment count fixed per type. The matcher is segment-count-exact, so a type that sometimes has a trailing segment needs two patterns.
- Never embed anything secret. The name is not a capability —
authorizeis. Someone who guessesresearches/43/writinggets a403, and that is the only reason they get one. - Make it stable. Renaming a document orphans its state, versions and comments, all of which are keyed by the old name.
Typing the names themselves
node ace make:collab-document researches/writing generates the shared types for a document type,
and a codegen hook turns every one it finds into a union in .adonisjs/:
import type { CollabDocumentName } from '.adonisjs/collaboration/documents.js'
function openDocument(name: CollabDocumentName) { /* autocompleted, exhaustive */ }That generator reads app/collaboration/documents/, so it produces nothing for documents declared
inline in config/collaboration.ts. For those, derive the union from the config itself —
defineConfig is generic, so the literal keys of documents survive:
import type { InferCollabDocumentNames } from '@adonis-agora/collaboration'
import collaborationConfig from '#config/collaboration'
type CollabDocumentName = InferCollabDocumentNames<typeof collaborationConfig>
// → 'researches/:id/writing' | 'whiteboards/:id'See Codegen.
CRDTs
Why a conflict-free replicated data type removes the conflict instead of resolving it, what Yjs and Automerge each model, and the three consequences that show up in your application code.
Authorization
The permission seam — why a WebSocket needs a token endpoint, how the same authorize callback guards both doors, what fail-closed means in practice, and which of the three permissions the library actually enforces.