Skip to Content

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
snapshots.every64Versions between automatic version history checkpoints. 0: named versions and restores only. Needs store.snapshots
serverPluginsnonePlugins whose appendTransactions run on server edits
eventsnoneMention events, thread events, commit events, room events
onErrorconsole onlyTelemetry for failures the engine recovers from

Several schemas

One backend can serve documents of different schemas. Pass them by name instead of schema (and their server plugins instead of serverPlugins):

createCollabBackend({ schemas: { docs: { schema: docsSchema }, forms: { schema: formsSchema, serverPlugins: (schema) => [] }, }, defaultSchema: "docs", store, broadcast, });

A document keeps the schema it was created under, for good: createRoomDoc(roomId, { ..., schemaName: "forms" }), or the default for documents seeded on first access and every document stored before names existed. The store records it in collab_doc.schema_name (null means the default). backend.getDocSchema(docId) says which one serves a document; hydrate answers carry it in X-Doc-Schema, room manifests list it per document, and a schema refusal (426) names it. Your editor still picks the schema it builds (useCollabEditor’s schema): read the manifest’s schemaName to choose.

Renaming defaultSchema re-points every document created without a name; a document created with the default’s name spelled out keeps that name. Removing a schema that documents use makes them unreadable (the server reports UnknownSchemaError). Creating a room document over a docId that already has a row under another schema (one seeded on first access has the default) is refused like a duplicate (RoomDocExistsError, 409).

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).
  • backend.getDocVersion(docId) → number | null: the current version without the content, and without seeding (null when there is no row). The starting point for following store.getCommitLog(docId, afterVersion), or for a staleness check. One indexed read with store.getDocVersion; a full document read without it.

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, without meta. Call createRoom first to give it some. Fires room events for both.
  • 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.

Room meta

await backend.createRoom(roomId, { kind: "deal", tenantId }); const room = await backend.getRoom(roomId); // → { id, meta, createdAt } (epoch ms), or null

meta is opaque JSON for your own use: the room’s kind, tenant or subject. Create the room with it before the first createRoomDoc, which creates rooms without meta. Creating an existing room is a no-op, so the first meta is kept; there is no update.

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
listSnapshots / getSnapshot / saveNamedSnapshot / restoreSnapshot / reconstructVersionVersion history
evictDocCache(docId?)Drop cached documents. Needed only after changing a stored doc outside the engine without bumping its version

Comment threads

Server code (a bot, an import job, a moderation tool) manages threads without the editor through these methods. They need store.getThread, and a hard delete also store.deleteThread (postgresStore and memoryStore have both; a store without them throws a clear error).

MethodPurpose
getThread(docId, threadId)One thread record, or null
createThread(docId, input)Saves a new thread and anchors it in the document
updateThread(docId, threadId, update)Read, change and write back one thread (edit a comment, add a reply, change metadata). Returns the saved thread, or null when there is none
resolveThread(docId, threadId, resolved?)Marks a thread resolved (false reopens it)
deleteThread(docId, threadId, { hard? })Soft delete by default (sets deletedAt, anchors stay, the UI can undo it). hard: true removes the row and its anchors
const { threadId, edit, anchorError } = await backend.createThread(docId, { anchor: { type: "range", blockId: "p-42", start: 0, end: 12 }, comment: { body: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Check this clause." }] }] }, author: { id: "bot", name: "Review bot" }, }, }); if (anchorError) console.warn("not anchored:", anchorError); await backend.updateThread(docId, threadId, (thread) => ({ ...thread, comments: [ ...thread.comments, { id: crypto.randomUUID(), threadId, createdAt: new Date().toISOString(), author: { id: "bot", name: "Review bot" }, body: reply, }, ], })); await backend.resolveThread(docId, threadId); await backend.deleteThread(docId, threadId, { hard: true });

Anchors. anchor is a text range inside one block, located by the block’s id ({ type: "range", blockId, start, end }; offsets count characters of its text, inline atoms count zero), or a node comment ({ type: "node", nodeId }, on node types that take node comments). Header, footer and note content can’t hold threads. When the anchor can’t be placed, createThread writes nothing and returns edit: { status: "noop" } with anchorError: target-not-found, invalid-range, unsupported-node, excluded-subtree or no-comment-schema. If the anchor edit fails after the thread was saved, the thread is removed again.

Other inputs. threadId defaults to a random UUID (ThreadExistsError when taken). editorId defaults to "default" (DEFAULT_THREAD_EDITOR_ID), the root editor id of useCollabEditor; it must match your editor’s id, or the client hides the thread and removes its anchors. metadata is stored as is, with editorId set. actor (also on deleteThread) is stored with the anchor commit.

Writes go through saveThread, so the per-document thread cap, poll wake-ups and mention events apply. Like client writes, they are last write wins: updateThread isn’t transactional against a client saving the same thread. A stored blob that isn’t a valid thread makes it throw InvalidThreadDataError.

Hard delete removes the row first, then the anchors, so a client loading in between only sees anchors of an unknown thread, which it cleans up itself. It runs the anchor cleanup even when the row was already gone, so a retry finishes the job. deleted in the result says whether the row existed.

These methods have no HTTP routes: expose the ones you need from your own server, behind your authorization.

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.

Thread events

For an inbox or a webhook on comment activity. The client saves whole threads, so the backend diffs the stored thread against the new one and names what changed:

export const backend = createCollabBackend({ schema, store, broadcast, events: { onThreadChanged: async ({ docId, threadId, author, changes }) => { for (const change of changes) { if (change.type === "comment-added") await notifyParticipants(docId, threadId, author); if (change.type === "resolved") await notifyThreadAuthor(docId, threadId); } }, }, });
type ThreadChangedEvent = { docId: string; threadId: string; editorId: string; threadMetadata: Record<string, unknown>; // client-owned author: { id: string; name?: string; avatar?: string } | null; // from resolveAuthor changes: ThreadChange[]; // never empty }; type ThreadChange = | { type: "created" | "resolved" | "unresolved" | "restored" } | { type: "deleted"; hard: boolean } | { type: "comment-added" | "comment-edited" | "comment-deleted"; commentId: string };
  • A new thread is created alone: its first comment is part of it.
  • deleted with hard: false is a soft delete (deletedAt set, which an undo reverts: restored); hard: true is deleteThread(docId, threadId, { hard: true }).
  • comment-edited is a changed body; reactions and metadata changes fire nothing.
  • author is the authenticated writer from resolveAuthor, null for backend calls (updateThread, resolveThread, createThread, deleteThread). The comment authors inside the thread are client-written.
  • Not fired by clearThreads or removeRoomDoc, which drop every thread of a document.
  • Same guarantees as mention events: not awaited (errors go to onError with scope: "server.onThreadChanged"), needs store.getThread, and two concurrent saves of one thread can rarely double-fire or miss a change.

diffThreadChanges(prev, next) is exported for custom pipelines.

Commit events

For state derived from documents (a search index, an effects runner) without polling:

export const backend = createCollabBackend({ schema, store, broadcast, events: { onCommit: ({ docId, version, actor, source }) => { if (source === "client") queueEffects(docId, version, actor); }, }, });
type CommitEvent = { docId: string; ref: string; version: number; // the version the commit produced actor: CommitActor | null; // as stored on the commit row source: "client" | "server"; // receiveCommit, or applyServerEdit / applyServerEditMany };
  • Fired once per persisted commit, after its transaction and the broadcast wake-up.
  • Not fired for a rejected commit, a resent ref, a noop server edit, or createRoomDoc (version 0 is not a commit).
  • Not awaited: a throwing or rejecting listener goes to onError (scope: "server.onCommit") and never fails the commit.
  • Per process. With several server instances, only the one that applied the commit sees the event. Keep store.getCommitLog(docId, afterVersion) to catch up on commits other instances applied, and after a restart. backend.getDocVersion(docId) gives the version to start from without reading the document.

Room events

events: { onRoomChanged: (event) => { if (event.type === "doc-created") indexDoc(event.roomId, event.docId); }, },
type RoomChangedEvent = | { roomId: string; type: "created" } | { roomId: string; type: "doc-created" | "doc-removed"; docId: string };

Fired after createRoom or createRoomDoc creates a room that didn’t exist, after createRoomDoc adds a document, and after removeRoomDoc removes one. Never for a no-op (an existing room, a doc not in the room). Not awaited; errors go to onError. Per process, like commit events.

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 version and incarnation, and serves the doc from cache. incarnation must be new for every created row and kept by saves, and getDoc must return it too, or the cache never hits
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 and thread events are disabled
getDocVersionbackend.getDocVersion reads the whole document. Implement it as select version …
peekDocNon-seeding reads unavailable
snapshotsVersion history is off: the history methods throw and the history routes answer 404
roomsRoom methods throw. rooms.createRoom and rooms.removeRoomDoc resolve whether they created or removed a row, which room events rely on
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
POST/:docId/schema-checkCompare the client’s schema structure (see below)
GET / POST/:docId/history…Version history

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.

Schema compatibility

The editor and the backend must agree on the schema: the server parses every step and compacts stored documents against its own. Hydrate and commit requests carry X-Collab-Schema, a fingerprint of the editor’s schema structure (schemaFingerprint in @stesura/core/schema). When it differs from the backend’s, the server answers 426 with { kind: "schema", client, server }. The editor then posts its structure to /:docId/schema-check once and continues if the server accepts it.

The server accepts a client whose schema it only extends: it added nodes or marks, and every one the client knows is identical. So deploy the server before the clients:

ChangeDuring the rollout
New node or markOld clients keep working; they stop with docStatus: "schema-mismatch" only when a new client inserts the new node
Anything else (new attr on an existing node, changed content, removed or renamed node or mark)Old clients are refused ("schema-mismatch"); roll the clients out first, or accept that open tabs must reload

A client ahead of its server is always refused. The server only compares the posted structure; it never builds a schema from it. Requests without the header are accepted, so older clients keep working.

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