Agora
Concepts

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:

config/collaboration.ts
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': { … }:

config/collaboration.ts
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:

config/collaboration.ts
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?

  1. config.engineFor(docName) — if you provided the function, it wins outright, for every document.
  2. The engine on the matched documents entry.
  3. config.engine (the global default; 'yjs' when unset).

Which authorization?

  1. The authorize on the matched documents entry.
  2. config.authorize (the global callback).
  3. Deny{ canRead: false, canWrite: false, canComment: false }.

Which initial content? (only for a name the storage has never stored)

  1. The load on the matched documents entry.
  2. 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.

config/collaboration.ts
// 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, not writing/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 — authorize is. Someone who guesses researches/43/writing gets a 403, 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.

On this page