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,
});| Option | Default | Purpose |
|---|---|---|
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) |
maxContentBytes | 16 MiB | Cap on one client request body, see Ingest size. Infinity disables |
commitRetention | 1024 | Commits kept per doc. A client further behind gets a 412 and reloads from the snapshot, dropping unconfirmed steps. Infinity disables pruning |
docCache.maxBytes | 64 MiB | In-process cache of documents this instance wrote. 0 disables. Needs store.lockDoc |
serverPlugins | none | Plugins whose appendTransactions run on server edits |
events | none | Mention events |
onError | console only | Telemetry 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’screateInitialDoc). In"reject"mode a missing document returnsnull. 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 | nullreads without seeding in either mode (when the store implements it;postgresStoreandmemoryStoredo).
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 arefis a no-op. Only useful if you already have steps, e.g. replaying a commit log. ThrowsStaleVersionErrorfor a version older than the retained commits, andCommitRejectedErrorfor an invalid commit.backend.listenForCommit(docId, version): long-poll for commits afterversion, 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.
docIdshares one namespace with standalone documents, across all rooms. An existing docId throwsRoomDocExistsError.initialContentis ProseMirror JSON, e.g. a published template’s snapshot. It is checked against the schema (InvalidDocumentError) and againstmaxContentBytes(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 roomA 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:
| Method | Purpose |
|---|---|
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 / listenForPresence | Presence |
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-Lengthover the cap is refused up front, and a stream is cut off as soon as it passes the cap. Either way: 413. createRoomDocfrom server code measuresinitialContentand throwsContentTooLargeError.- 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):
| Codec | Bytes | Saving |
|---|---|---|
| none | 5,171,897 | 0% |
| gzip 6 | 512,596 | 90% |
| brotli 4 | 403,386 | 92% |
| brotli 5 | 374,373 | 93% |
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.
contentisbytea, so you can’t query into documents with SQL JSON paths. See Server for migrating an olderjsonbcolumn. - Switching codecs needs no rewrite: each row records the codec that wrote it. A custom codec
gives an
id(16–255), an optional HTTPcontentCoding, and asyncencode/decode;decodemust stop atmaxBytes. - 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 inX-Doc-Version/X-Doc-Updated-At. PasshydrationPassThrough: falsetocreateCollabRoutesif your host re-encodes or stripsContent-Encoding. maxStoredDocBytescaps a stored document, uncompressed: bigger saves throwStoredDocTooLargeError, and reads stop inflating past it. A document grows pastmaxContentBytesthrough normal commits, so keep this one larger.- Commit bodies of 64 KiB or more (pastes, imports) are gzipped by the client. The routes accept
gzipanddeflateand applymaxContentBytesto 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:
| Method | Without it |
|---|---|
lockDoc | The 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 |
oldestCommitAfter | The commit pre-flight reads up to commitRetention commits, steps included, per write |
pruneCommits | commitRetention does nothing: the commit log grows forever |
docExists | Existence checks (every request in "reject" mode) read the whole document. Implement it as select 1 …, never via content |
getThread | Mention events are disabled |
peekDoc | Non-seeding reads unavailable |
rooms | Room methods throw |
getInitialDocEncoded / peekDocEncoded | Hydration 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:
| Channel | Type | Carries |
|---|---|---|
collab | Commit long-poll wake-ups (an optimization: polls time out and re-read anyway) | |
presence | PresenceChannel | Presence storage and fan-out, with TTL and leave signals |
threads, rooms | RefSignalChannel | “Changed” signals for comment threads (per doc) and manifests (per room) |
proofread | ProofreadCacheChannel | The 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):
| Method | Path | |
|---|---|---|
GET | /:docId | Hydrate |
GET | /:docId/commits?version=N | Long-poll commits |
POST | /:docId/commits | Send a commit |
POST | /:docId/presence | Long-poll presence |
POST / DELETE | /:docId/presence/:clientId | Update / leave |
GET | /:docId/threads | All threads |
POST | /:docId/threads/listen | Long-poll threads |
PUT | /:docId/threads/:threadId | Save a thread |
GET / POST | /:docId/proofread/:field | Read / 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:
| Status | When |
|---|---|
| 400 | Malformed body, bad ?version, ids over 256 characters |
| 403 | authorize returned false |
| 404 | Unknown route; a thread saved for a doc with no row (seed mode) |
| 409 | Commit contention the server couldn’t resolve; the client retries |
| 410 | The doc was removed ("reject" mode) |
| 412 | The client’s version is older than the retained commits; it reloads |
| 413 | Body over maxContentBytes |
| 422 | Commit rejected (no steps, or an inline data: image) |
| 429 | Rate 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
buildif an edit should be reviewable. "reject"mode changes reads everywhere:getInitialDoccan returnnull, 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
- Server edits: the edit pipeline in depth.
- Rooms: route mounting, the client side, template publishing.
- Pitter Patter: setup and deployment.