Skip to Content
CollaborationBackend API

Backend API (server-side)

The object createCollabBackend returns is what the HTTP routes call, so your own server code can do anything a client can: in server actions, API routes, cron jobs, workers or migration scripts.

import { backend } from "@/app/api/docs/_lib/backend"; // your instance const snapshot = await backend.getInitialDoc(docId); await backend.applyServerEdit(docId, ({ tr, schema }) => { /* … */ }); await backend.createRoomDoc(roomId, { docId, title, position: 0 });

Never write the collab_doc / collab_commit tables directly. Changes must go through the backend (applyServerEdit or receiveCommit) to get a version, a commit row and a broadcast. A direct UPDATE desyncs every connected client.

Backend options

createCollabBackend({ schema, store, // postgresStore(...) or a custom CollabStore broadcast, // redisBroadcast(...) or a custom BroadcastAdapter missingDocBehavior: "seed", maxContentBytes: 16 * 1024 * 1024, commitRetention: 1024, docCache: { maxBytes: 64 * 1024 * 1024 }, serverPlugins: (schema) => [], events: { onThreadMentionsCreated }, onError: reportToSentry, });
OptionDefaultPurpose
missingDocBehavior"seed""seed": reading a missing doc creates it. "reject": missing docs answer 410 and nothing is seeded. Use "reject" when documents are deleted (rooms)
maxContentBytes16 MiBCap on one client request body, see Ingest size. Infinity disables
commitRetention1024Commits kept per doc. A client further behind gets a 412 and reloads from the snapshot, dropping unconfirmed steps. Infinity disables pruning
docCache.maxBytes64 MiBIn-process cache of documents this instance wrote. 0 disables. Needs store.lockDoc
serverPluginsnonePlugins whose appendTransactions run on server edits
eventsnoneMention events
onErrorconsole onlyTelemetry for failures the engine recovers from

Documents

Reading

const snapshot = await backend.getInitialDoc(docId); // → { docJSON, version, lastUpdatedTimestamp }
  • In "seed" mode a first read creates the document (the store’s createInitialDoc). In "reject" mode a missing document returns null.
  • backend.getDocStatus(docId)"ok" | "gone": a cheap existence check, e.g. before queueing work for a doc that may have been removed.
  • store.peekDoc(docId)DocSnapshot | null reads without seeding in either mode (when the store implements it; postgresStore and memoryStore do).

Writing

const result = await backend.applyServerEdit(docId, ({ state, tr, schema }) => { // Find targets by walking tr.doc, then change tr. }); // { status: "applied", version, droppedSteps } | { status: "noop" } | { status: "not-found" }

The only supported way to change a document from server code. It runs your edit through the same authority as client commits, rebasing over concurrent edits and retrying on contention. applyServerEditMany(edits) does the same for many documents. Options, anchoring and partial failure are covered in Server edits.

Commits

  • backend.receiveCommit(docId, commitJSON): submit a pre-built commit ({ ref, version, steps }), as the client POST route does. Resending a ref is a no-op. Only useful if you already have steps, e.g. replaying a commit log. Throws StaleVersionError for a version older than the retained commits, and CommitRejectedError for an invalid commit.
  • backend.listenForCommit(docId, version): long-poll for commits after version, e.g. a worker re-indexing a document after each change.

Rooms

Room methods need a store with rooms support (postgresStore, memoryStore) and throw otherwise. Route setup and the client side are on Rooms.

const { ref, docs } = await backend.getRoomManifest(roomId); // docs: { docId, title, position }[], ordered by position const poll = await backend.listenForRoomManifest(roomId, ref); // → { changed: false } on timeout, or { changed: true, ref, docs }

ref is an opaque change token: long-poll with the last one you saw, and refetch when it changes.

Creating documents

await backend.createRoomDoc(roomId, { docId: crypto.randomUUID(), title: "Contract — Acme Corp", position: 0, initialContent: snapshot.docJSON, // optional; omit for the store's blank doc });
  • Creates the room if it doesn’t exist; there is no separate call for that.
  • Seeds the document and adds the manifest row in one transaction, then notifies open manifests.
  • docId shares one namespace with standalone documents, across all rooms. An existing docId throws RoomDocExistsError.
  • initialContent is ProseMirror JSON, e.g. a published template’s snapshot. It is checked against the schema (InvalidDocumentError) and against maxContentBytes (ContentTooLargeError).

Removing documents

await backend.removeRoomDoc(roomId, docId);

Removes the manifest row and deletes the document: doc row, commits and comment threads. It only deletes a doc that belongs to roomId. Take a snapshot first if you need undo. In "reject" mode, clients with the doc open get a 410 and docStatus turns "gone".

ACL lookup

const roomId = await backend.getRoomOfDoc(docId); // null if not in a room

A single indexed read, for doc-route authorize callbacks: resolve the room, then check membership.

Other methods

Mostly used by the HTTP routes, but callable directly:

MethodPurpose
getThreads(docId)All comment threads + a change ref
saveThread(docId, thread)Upsert one thread (whole blob, last write wins). Throws DocNotFoundError if the doc has no row
listenForThreads(docId, knownRef)Long-poll for thread changes
updatePresence / removePresence / listenForPresencePresence
getProofreadCache(docId, field) / patchProofreadCache(...)The shared proofread cache
evictDocCache(docId?)Drop cached documents. Needed only after changing a stored doc outside the engine without bumping its version

A server-side comment (e.g. from a bot) goes through saveThread.

Mention events

Mentions in comments are client UI; notifying the mentioned user is up to your server:

export const backend = createCollabBackend({ schema, store, broadcast, events: { onThreadMentionsCreated: async (event) => { for (const { author, mentionedUserIds } of event.mentions) { await sendEmails(mentionedUserIds, author); } }, }, });

It fires after a thread save that adds (comment, mentioned user) pairs the stored thread didn’t have, so edits and re-saves don’t notify twice. It isn’t awaited: errors go to onError and never fail the save. It needs store.getThread (postgresStore and memoryStore have it); without it, the backend warns and disables the events.

type ThreadMentionsCreatedEvent = { docId: string; threadId: string; editorId: string; // the editor the thread belongs to (main doc, footnote, …) threadMetadata: Record<string, unknown>; // client-owned mentions: { commentId: string; author: { id: string; name?: string; avatar?: string }; mentionedUserIds: string[]; // deduped, the comment's author excluded }[]; };

author comes from the thread data, which the client writes. Pass resolveAuthor to createCollabRoutes to replace it with the authenticated user.

extractThreadMentions(threadData) and diffThreadMentions(prev, next) are exported for custom pipelines; both return an empty result on malformed data.

Two concurrent saves of one thread can, rarely, send a mention twice or miss it. The demo’s hook (apps/demo/src/app/api/docs/_lib/mention-notifications.ts) only logs.

Ingest size

maxContentBytes (default 16 MiB) caps what one client request can make the server parse: commit bodies and room initialContent. A 1000-page, heavily styled document is about 7.8 MB, and parsing a request costs about 4× its size in memory, so the default allows ~2× the largest realistic document for ~70 MB of transient heap.

  • Routes check the raw body: a Content-Length over the cap is refused up front, and a stream is cut off as soon as it passes the cap. Either way: 413.
  • createRoomDoc from server code measures initialContent and throws ContentTooLargeError.
  • Server edits are not capped: they are your code.

It bounds one request, not volume: rate-limit at your edge. Transport caps (a host’s body limit, a proxy’s client_max_body_size) apply first where they exist.

Storage compression

postgresStore compresses collab_doc.content, the whole document rewritten on every commit:

postgresStore({ url, schema, compression: "brotli", // default. "brotli" | "gzip" | "none" | a DocCodec compressionLevel: 4, // brotli 0–11 (default 4), gzip 1–9 (default 6) maxStoredDocBytes: 64 * 1024 * 1024, // default });

On a 4,749-paragraph document (5.17 MB after compactDocJson):

CodecBytesSaving
none5,171,8970%
gzip 6512,59690%
brotli 4403,38692%
brotli 5374,37393%

Brotli 4 costs ~12 ms per commit to encode (off the main thread) and ~5 ms per read. Levels above ~7 get slow on large documents (level 11 takes seconds).

  • Column type. content is bytea, so you can’t query into documents with SQL JSON paths. See Server for migrating an older jsonb column.
  • Switching codecs needs no rewrite: each row records the codec that wrote it. A custom codec gives an id (16–255), an optional HTTP contentCoding, and async encode / decode; decode must stop at maxBytes.
  • Hydration sends the stored bytes as-is, with a matching Content-Encoding, when the browser accepts that coding; the browser inflates them. Version and timestamp travel in X-Doc-Version / X-Doc-Updated-At. Pass hydrationPassThrough: false to createCollabRoutes if your host re-encodes or strips Content-Encoding.
  • maxStoredDocBytes caps a stored document, uncompressed: bigger saves throw StoredDocTooLargeError, and reads stop inflating past it. A document grows past maxContentBytes through normal commits, so keep this one larger.
  • Commit bodies of 64 KiB or more (pastes, imports) are gzipped by the client. The routes accept gzip and deflate and apply maxContentBytes to the decompressed size.

memoryStore doesn’t compress.

Custom stores

A store implements CollabStore. Its JSDoc states the correctness contract: atomic transactions, a row lock in getDoc, UNIQUE(doc_id, version) and UNIQUE(doc_id, ref). Without them, concurrent commits lose or duplicate data. The store takes and returns plain ProseMirror JSON; compression is its own business.

Required: runWithTransaction, getDoc, getCommit, getCommits, saveDoc, saveCommit, getInitialDoc, getThreadsRevision, getThreads, upsertThread.

The optional methods matter for cost:

MethodWithout it
lockDocThe document is read in full on every commit, and docCache is off. With it, a commit locks the row, compares versions, and serves the doc from cache
oldestCommitAfterThe commit pre-flight reads up to commitRetention commits, steps included, per write
pruneCommitscommitRetention does nothing: the commit log grows forever
docExistsExistence checks (every request in "reject" mode) read the whole document. Implement it as select 1 …, never via content
getThreadMention events are disabled
peekDocNon-seeding reads unavailable
roomsRoom methods throw
getInitialDocEncoded / peekDocEncodedHydration decodes and sends JSON instead of the stored bytes

memoryStore implements everything in memory (one mutex as the transaction), for tests. The backend warns at startup when lockDoc, pruneCommits or getThread is missing.

The broadcast adapter

broadcast carries everything ephemeral; durable data stays in the store. redisBroadcast implements it:

ChannelTypeCarries
collabCommit long-poll wake-ups (an optimization: polls time out and re-read anyway)
presencePresenceChannelPresence storage and fan-out, with TTL and leave signals
threads, roomsRefSignalChannel“Changed” signals for comment threads (per doc) and manifests (per room)
proofreadProofreadCacheChannelThe shared proofread cache

Plus connect() (retried after a failure, so it must survive a partial connect) and an optional close(). redisBroadcast options: url, keyPrefix, timeout (long-poll hold, default 5 s), presenceTTLSeconds (30), proofreadTTLSeconds (30 days), onError.

HTTP routes

For proxies, firewalls and custom clients. Paths are under the doc mount (/api/docs):

MethodPath
GET/:docIdHydrate
GET/:docId/commits?version=NLong-poll commits
POST/:docId/commitsSend a commit
POST/:docId/presenceLong-poll presence
POST / DELETE/:docId/presence/:clientIdUpdate / leave
GET/:docId/threadsAll threads
POST/:docId/threads/listenLong-poll threads
PUT/:docId/threads/:threadIdSave a thread
GET / POST/:docId/proofread/:fieldRead / patch the proofread cache

Room routes (under /api/rooms): GET /:roomId, POST /:roomId/listen, POST /:roomId/docs, DELETE /:roomId/docs/:docId.

Every client request sends X-Collab-Protocol; a server that doesn’t support it answers 426 and the client stops (docStatus: "error"). Upgrade the server first. Other statuses:

StatusWhen
400Malformed body, bad ?version, ids over 256 characters
403authorize returned false
404Unknown route; a thread saved for a doc with no row (seed mode)
409Commit contention the server couldn’t resolve; the client retries
410The doc was removed ("reject" mode)
412The client’s version is older than the retained commits; it reloads
413Body over maxContentBytes
422Commit rejected (no steps, or an inline data: image)
429Rate limit or long-poll cap; honours Retry-After

Cautions

  • No cross-document atomicity. Each document has its own version; a multi-doc change is N independent commits. Make edits idempotent and retry per doc.
  • Server commits are anonymous and can’t be undone by clients. Use track-changes marks in build if an edit should be reviewable.
  • "reject" mode changes reads everywhere: getInitialDoc can return null, and server edits never seed.
  • Long-poll methods hold for up to the broadcast timeout (default 5 s). Pass the last ref/version you saw; don’t call them in a tight loop.

Next steps

Last updated on