@stesura/collab-adapter-server
The server side of Pitter Patter collaboration: the backend engine, the Postgres store, the Redis broadcast adapter and the HTTP routes. Node only. What the backend can do from your own server code is in Backend API.
| Entry | Contents |
|---|---|
@stesura/collab-adapter-server | createCollabBackend, stores, codecs, broadcast, errors |
@stesura/collab-adapter-server/next | Next.js App Router route factories |
@stesura/collab-adapter-server/node | A node:http handler and its request/response conversions |
@stesura/collab-adapter-server/route-kit | The plumbing the routes are built from, for routes of your own |
@stesura/collab-adapter-server/testing | An in-memory broadcast adapter for tests |
@stesura/collab-adapter-server/schema.sql, /form-schema.sql | The tables postgresStore expects |
Backend
createCollabBackend
The framework-agnostic collab backend: the commit authority over a CollabStore and a BroadcastAdapter, plus presence, threads, rooms and server
edits, exposing what a transport (the route factories) needs.
| Name | Type | Default |
|---|---|---|
__0 | CreateCollabBackendConfig<Tx> |
CreateCollabBackendConfig
Options for createCollabBackend: one schema, or several by name under schemas.
CollabSchemaEntry
One schema the backend serves.
CollabBackend
What createCollabBackend returns: the engine the route factories serve, and the API for
reading and writing documents, threads and rooms from server code.
Its methods’ option and result types are exported too: ReceiveCommitOptions, ThreadsSnapshot,
ThreadsPollResult, RoomManifest, RoomManifestPollResult, PresencePollResult,
RoomPresencePollResult and DocTransportResult (how a hydration answer is sent: the stored
bytes as-is, or decoded JSON).
Constants
| Export | Value | Description |
|---|---|---|
DEFAULT_SCHEMA_NAME | "default" | The name a single schema is served under. |
SCHEMA_NAME_PATTERN | RegExp | What a name in schemas may look like: alphanumeric, _ or -, at most 64 characters. |
DEFAULT_MAX_CONTENT_BYTES | 16 MiB | Default maxContentBytes: the cap on one client request body. |
DEFAULT_MAX_THREAD_BYTES | 256 KiB | Default cap on one thread’s request body. |
DEFAULT_MAX_THREADS_PER_DOC | 2000 | Default cap on comment threads per document; past it a save throws ThreadLimitError. |
DEFAULT_SNAPSHOT_EVERY | 64 | Default snapshots.every: versions between automatic history checkpoints. |
DEFAULT_MAX_STORED_DOC_BYTES | 64 MiB | Default postgresStore maxStoredDocBytes: the cap on one stored document, uncompressed. |
Events
Passed as createCollabBackend({ events }). See mention,
thread,
commit and
room events.
CollabBackendEvents
createCollabBackend({ events }): hooks fired after writes, for notifications and indexing.
ThreadMentionsCreatedEvent
A thread save added mentions that weren’t in the previously stored blob.
ThreadChangedEvent
A thread save (or hard delete) changed the thread’s lifecycle; see ThreadChange.
CommitEvent
A persisted commit; see events.onCommit.
RoomChangedEvent
A room or its manifest changed through this backend; see events.onRoomChanged.
| Name | Type | Default |
|---|---|---|
roomId | string | |
type | "created" | "doc-created" | "doc-removed" |
Diff helpers
What the events are computed with, for pipelines of your own.
commentId → mentioned user ids. Soft-deleted comments and threads yield nothing.
| Name | Type | Default |
|---|---|---|
threadData | UnknownRecord |
Mentions in next that prev lacks (prev = null → all are new), keyed by
(commentId, userId): re-saves don’t re-notify, but removing and re-adding a mention across two
saves does.
All of it is unverified client input: author is whatever the blob says, and mentionedUserIds
can name any user, including ones without access to the document. Pass authenticatedAuthor
(the routes’ resolveAuthor) to replace the blob’s author, both in the result and in the
self-mention exclusion; targets must still be checked against the document’s ACL before
notifying.
| Name | Type | Default |
|---|---|---|
prev | UnknownRecord | |
next | UnknownRecord | |
authenticatedAuthor | { id: string; name?: string; avatar?: string; } |
The users one comment newly mentions; see diffThreadMentions.
What next changed over prev: a new thread is created alone (its first comments are part of
it), otherwise one entry per transition. Reactions and metadata changes yield nothing. A comment
soft-deleted or dropped from the blob is comment-deleted; an edit is a changed body.
| Name | Type | Default |
|---|---|---|
prev | UnknownRecord | |
next | UnknownRecord |
One thing a thread save changed.
| Name | Type | Default |
|---|---|---|
type | "deleted" | "resolved" | "created" | "unresolved" | "restored" | "comment-added" | "comment-edited" | "comment-deleted" |
Server edits
backend.applyServerEdit and applyServerEditMany. See Programmatic edits.
ServerEditBuildFn
Builds one server edit. MUST be pure: it re-runs against a fresh read on every contention retry, so locate targets by walking the doc (node attrs, ids), never by cached positions or state from a previous run.
| Name | Type | Default |
|---|---|---|
ctx | ServerEditBuildContext |
ServerEditBuildContext
Passed to a server-edit build callback. Mutate tr; the engine applies it with
applyTransaction, so server plugins’ appendTransactions run.
| Name | Type | Default |
|---|---|---|
state | EditorState | |
tr | Transaction | |
schema | Schema<any, any> |
ApplyServerEditOptions
Options of applyServerEdit (and each applyServerEditMany entry).
ServerEditResult
Outcome of applyServerEdit.
| Name | Type | Default |
|---|---|---|
status | "applied" | "noop" | "not-found" |
ServerEditSpec
One edit for applyServerEditMany.
| Name | Type | Default |
|---|---|---|
docId | string | |
build | ServerEditBuildFn | |
opts | ApplyServerEditOptions |
ServerEditManyResult
export type ServerEditManyResult = Array<
{ docId: string } & (ServerEditResult | { status: "error"; error: unknown })
>;Per-edit outcomes, aligned with the input array by index (not keyed by docId, so two edits to one document both report).
Comment threads
backend.createThread and deleteThread. See Comment threads.
New threads default to DEFAULT_THREAD_EDITOR_ID ("default"), the root editor id of useCollabEditor.
CreateThreadInput
What backend.createThread takes.
CreateThreadResult
What backend.createThread resolves to.
CommentAnchorTarget
Where a server-created thread’s anchor goes, located by node id: a text range inside a
textblock (blockId, start, end offsets into its text) or a whole node (nodeId).
| Name | Type | Default |
|---|---|---|
type | "node" | "range" |
CommentAnchorError is the reason an anchor couldn’t be placed: "target-not-found",
"invalid-range", "unsupported-node", "excluded-subtree" or "no-comment-schema".
DeleteThreadOptions
Options of backend.deleteThread.
| Name | Type | Default |
|---|---|---|
hard | booleanRemove the row and the thread’s anchors instead of setting | |
actor | CommitActorStored with the anchor-removal commit ( |
DeleteThreadResult
What backend.deleteThread resolves to.
| Name | Type | Default |
|---|---|---|
deleted | booleanWhether the thread existed. | |
edit | ServerEditResult | nullThe anchor-removal edit; null for a soft delete. |
Stores
postgresStore
Default CollabStore, on Postgres via the postgres driver. Meets the correctness
contract through sql.begin, SELECT ... FOR UPDATE, and the UNIQUE(doc_id, version) and
UNIQUE(doc_id, ref) constraints in the shipped schema.sql, which must be run once first.
| Name | Type | Default |
|---|---|---|
config | PostgresStoreConfig |
PostgresStoreConfig
Options for postgresStore.
memoryStore
In-memory CollabStore for tests and prototyping; process-local, so never behind more
than one server instance.
A single mutex is the transaction: callbacks run one at a time, writes roll back on a throw,
and saveCommit throws on a taken version or ref like the UNIQUE constraints. Pool reads
see committed state only (see preTx). The seeding reads, getDoc(null, …) and
getInitialDoc, take the mutex, so calling them inside a transaction deadlocks — pass the
tx instead.
| Name | Type | Default |
|---|---|---|
config | MemoryStoreConfig |
MemoryStoreConfig
Options for memoryStore.
CollabStore
The contract a custom store implements.
Storage contract for the collab engine. postgresStore is the default implementation.
Correctness contract
Per commit the CollabAuthority runs getCommit → getDoc → getCommits → saveCommit → saveDoc
inside runWithTransaction, retrying the whole callback on any error. Concurrent edits are
only safe if:
runWithTransactionis a real atomic transaction.getDoc(andlockDoc) take a row lock on the document (SELECT ... FOR UPDATE), so concurrent commits to one doc serialize.UNIQUE(doc_id, version)makes a losing concurrent commit fail, so the authority retries and rebases.UNIQUE(doc_id, ref)makes a duplicate resend fail. ThegetCommitidempotency check runs before the lock, so two copies of one commit can both pass it.
Without these, concurrent commits silently lose or duplicate data.
Store records
| Type | Description |
|---|---|
DocSnapshot | A document with its version and timestamp, as getInitialDoc returns it. |
EncodedDocSnapshot | A stored document left undecoded, so hydration can send its bytes as-is. |
DocLock | What lockDoc reads: version, timestamp and incarnation, never the content. |
CommitLogEntry | One commit as getCommitLog lists it: who and when, without the steps. |
CommitActor | Who made a commit: { kind, id?, causedBy? }, stored on its row. |
ThreadRecord | One stored thread: { id, editorId, data }, data being the client’s blob. |
RoomRecord | A room: { id, meta, createdAt }. |
RoomDocEntry | One document in a room manifest: { docId, title, position, schemaName? }. |
MemoryTx | memoryStore’s transaction handle. |
SnapshotMeta | One history checkpoint without its document: { version, label, authorId, restoredFrom, createdAt }. Re-exported from @stesura/collab-adapter/protocol. |
Snapshot | A SnapshotMeta with its docJSON, as getSnapshot returns it. |
SnapshotWriteMeta | What a checkpoint write records beside the content: optional label, authorId and restoredFrom. |
Compression
postgresStore({ compression }) takes "brotli" (the default), "gzip", "none" or a DocCodec.
See Storage compression.
Brotli at level (0–11). Hydration can send its rows to the browser as-is (Content-Encoding: br).
| Name | Type | Default |
|---|---|---|
level | number |
Gzip at level (1–9). Hydration can send its rows to the browser as-is (Content-Encoding: gzip).
| Name | Type | Default |
|---|---|---|
level | number |
How a stored document body is compressed. The codec id is written into each row’s envelope, so reads always use the codec that wrote the row.
export type DocCompression = "brotli" | "gzip" | "none" | DocCodec;compression option of postgresStore.
| Export | Value | Description |
|---|---|---|
identityCodec | DocCodec | No compression: the envelope header, then the raw UTF-8 JSON. |
DEFAULT_BROTLI_LEVEL | 4 | Smaller output than any gzip level, at less CPU than gzip 4. |
DEFAULT_GZIP_LEVEL | 6 | zlib’s default. |
DOC_CODEC_IDS | { none: 0, gzip: 1, brotli: 2 } | The built-in codec ids written into each row. A custom codec takes an id from 16 to 255. |
Broadcast
redisBroadcast
Default BroadcastAdapter, on Redis/Valkey. A commit publishes its version on the doc’s
channel (as Pitter Patter’s RedisBroadcastManager did); presence indicators live in a hash
with per-field TTLs, and changes are published as { ref, clientId }.
| Name | Type | Default |
|---|---|---|
config | RedisBroadcastConfig |
RedisBroadcastConfig
Options for redisBroadcast.
BroadcastAdapter
What a replacement for Redis implements. Its channels are PresenceChannel, RoomPresenceChannel,
IndicatorChannel, RefSignalChannel, ThreadsSignalChannel and ProofreadCacheChannel; a
long-poll waiter is a WakeListener. See The broadcast adapter.
Ephemeral data for the collab engine: pub/sub fan-out, presence, and the proofread cache.
Durable data lives in the CollabStore. redisBroadcast is the default.
Fan-out is a latency optimization (a lost wake costs one poll timeout); presence, however, lives only here.
Errors
The routes map these to the status shown; from server code, catch them by name.
| Error | Thrown when | Status |
|---|---|---|
CommitRejectedError | A commit fails a content gate (no steps, an inline data: image) | 422 |
StaleVersionError | The client’s version predates the retained commits; it must reload | 412 |
InvalidDocumentError | Client-supplied document JSON doesn’t parse under the schema | 400 |
ContentTooLargeError | Client JSON over maxContentBytes | 413 |
UnknownSchemaError | A schema name the backend doesn’t serve | 400 |
DocSchemaChangedError | A document was re-created under another schema mid-commit; the commit is resent | 409 |
DocNotFoundError | A thread write for a document with no row | 404 / 410 |
ThreadLimitError | A new thread would pass maxThreadsPerDoc | 422 |
SnapshotNotFoundError | A history version has no snapshot | 404 |
ThreadExistsError | createThread with a threadId already taken | |
InvalidThreadDataError | A stored thread blob isn’t a valid thread | |
ServerEditContentionError | A server edit is still contended after its retries | |
RoomDocExistsError | createRoomDoc with a docId that already exists, in any room | 409 |
RoomNotFoundError | A room-doc write names a room with no row | |
StoredDocTooLargeError | A save or read passes the store’s maxStoredDocBytes | |
CorruptStoredDocError | A stored row can’t be decoded |
Next.js routes (/next)
App Router handlers. Mounting and options are covered in
Server and Rooms.
The option and context types below come from the main entry and serve /node too; here the hooks
receive a NextRequest.
createCollabRoutes
App Router GET/POST/PUT/DELETE handlers for the doc API. Re-export them from one
catch-all route file:
// app/api/docs/[docId]/[[...path]]/route.ts
import { createCollabRoutes } from "@stesura/collab-adapter-server/next";
export const { GET, POST, PUT, DELETE } = createCollabRoutes(backend, { authorize });
export const runtime = "nodejs";
export const dynamic = "force-dynamic";Hooks receive a NextRequest, so request.cookies etc. are available in authorize.
| Name | Type | Default |
|---|---|---|
backend | CollabBackend | |
options | CreateCollabRoutesOptions<NextRequest> |
{ GET: (request: NextRequest, ctx: RouteParams) => Promise<Response>; POST: (request: NextRequest, ctx: RouteParams) => Promise<...>; PUT: (request: NextRequest, ctx: RouteParams) => Promise<...>; DELETE: (request: NextRequest, ctx: RouteParams) => Promise<...>; }CreateCollabRoutesOptions
Generic over the request type so an adapter can hand hooks something richer than Request
(/next passes NextRequest). The core only uses the standard surface.
createCollabRoomRoutes
App Router handlers for the room-manifest API. Same contract as
createCollabRoutes, mounted at
app/api/rooms/[roomId]/[[...path]]/route.ts.
| Name | Type | Default |
|---|---|---|
backend | CollabBackend | |
options | CreateCollabRoomRoutesOptions<NextRequest> |
{ GET: (request: NextRequest, ctx: RoomRouteParams) => Promise<Response>; POST: (request: NextRequest, ctx: RoomRouteParams) => Promise<...>; DELETE: (request: NextRequest, ctx: RoomRouteParams) => Promise<...>; }CreateCollabRoomRoutesOptions
Room-side counterpart of CreateCollabRoutesOptions.
CollabRouteContext
What the doc routes pass authorize, identify and the other per-request callbacks.
CollabRoomRouteContext
What the room routes pass authorize and the other per-request callbacks.
| Name | Type | Default |
|---|---|---|
roomId | stringAlready percent-decoded. | |
path | string[]Path segments after the roomId, e.g. |
CollabRateLimitOptions
Per-actor token-bucket throttle. In-process and per instance: a multi-instance deployment still wants a shared limiter at the edge — this is the floor, not the ceiling.
Node server (/node)
The same routes on node:http, with CORS and a health check. See
Standalone Node server. It takes
the option and context types above, from the main entry.
createCollabNodeHandler
A http.createServer listener serving the doc and room routes. Unmatched paths answer 404,
unsupported methods 405, and OPTIONS 204 on any path.
| Name | Type | Default |
|---|---|---|
options | CollabNodeHandlerOptions |
CollabNodeHandlerOptions
Options for createCollabNodeHandler.
toWebRequest
A web Request for a Node request, for hosts dispatching their own routes beside
createCollabNodeHandler. The scheme honours X-Forwarded-Proto only within
trustedProxyHops. The body is streamed rather than buffered, so a route’s byte cap applies
while reading.
| Name | Type | Default |
|---|---|---|
req | IncomingMessage | |
__1 | { trustedProxyHops: number; } |
sendWebResponse
Writes a web Response to res and ends it, streaming the body with backpressure. Iterated
directly rather than via Readable.fromWeb, which would need the same stream-type cast as
toWebRequest.
| Name | Type | Default |
|---|---|---|
res | ServerResponse<IncomingMessage> | |
response | Response |
clientAddressOf
The client address: the X-Forwarded-For entry trustedProxyHops from the right, or the socket
peer when that is 0 or the header is shorter than the trusted chain.
| Name | Type | Default |
|---|---|---|
req | IncomingMessage | |
trustedProxyHops | number |
AllowedOrigins
export type AllowedOrigins = readonly string[] | ((origin: string) => boolean);Browser origins allowed to call a route cross-origin. Strings match exactly, except
scheme://*.host[:port], which matches one subdomain label of host (scheme and port exactly):
https://*.example.com allows https://acme.example.com, not https://example.com nor
https://a.b.example.com. Or a predicate, for anything else. From the main entry.
Route kit (/route-kit)
The plumbing the collab routes are built from, for routes of your own that should behave the
same: body caps, the protocol and schema checks, client string bounds, throttling and long-poll
caps. Web Request/Response only, so it works under any framework.
Guards
A 426 when the request’s protocol is outside SUPPORTED_PROTOCOLS (400 when the header is
not an integer), else null. Runs before authorize, so a skewed client learns why on hydration
rather than mid-edit; the range is public per version anyway. No header means protocol 1.
| Name | Type | Default |
|---|---|---|
request | Request |
The schema handshake for one served schema (D6 in docs/executed/custom-schema-plan.md), named in its
refusals so a host that built the wrong one learns which the document uses:
checkreadsx-collab-schema: null (pass) for no header, the server’s own fingerprint or one already accepted; a 426SchemaMismatchBodyfor any other; 400 when malformed.verifyanswers the schema-check route: the client’s structure is accepted when the server only added nodes or marks (schemaAccepts), and its fingerprint (computed here, never taken from the client) passescheckfrom then on. The structure is only compared, never built.
In-process: each instance learns accepted fingerprints on its own, so a client may post its structure once per instance.
| Name | Type | Default |
|---|---|---|
schema | Schema<any, any> | |
schemaName | string |
The matcher AllowedOrigins describes, for hosts answering CORS on routes of their own as
createCollabNodeHandler does.
| Name | Type | Default |
|---|---|---|
allowed | AllowedOrigins |
Runs after authorize, so an unauthorized caller can never spend an authorized actor’s budget.
Buckets are also keyed by the first path segment, so each route family has its own budget.
| Name | Type | Default |
|---|---|---|
rateLimit | CollabRateLimitOptions<Context, Req> | |
defaultActor | (context: Context) => string |
Concurrency caps on held long-polls: per actor (identify, else the client address, else
none) and per target (the doc or room). Each call resolves a release function, or null when a
cap refuses and the poll should answer 429.
| Name | Type | Default |
|---|---|---|
identify | (request: Req, context: Context) => string | Promise<string> | |
maxPerActor | number | |
maxPerTarget | number |
(request: Req, context: Context, clientAddress: string | undefined, target: string) => Promise<(() => void) | null>Whether this request’s Accept-Encoding allows answering in coding. Judged per request, not
per browser: a proxy or non-browser client may accept less, and a body in an unrequested coding
is undecodable. RFC 9110 §12.5.3: an explicit token beats *, and q=0 is a refusal.
| Name | Type | Default |
|---|---|---|
header | string | |
coding | string |
Reading requests
Reads and parses a request body under a byte cap. Yields TOO_LARGE over the cap and null
for a missing, undecodable or malformed body; never throws. The cap is enforced while reading
(declared Content-Length first, then a running total), so an oversized body is never fully
buffered.
| Name | Type | Default |
|---|---|---|
request | Request | |
maxBytes | number |
?version=: absent is 0, anything but a safe non-negative integer is null.
| Name | Type | Default |
|---|---|---|
raw | string |
A non-null, non-array object: the shape check before reading a parsed body’s fields.
| Name | Type | Default |
|---|---|---|
v | unknown |
A string within MAX_CLIENT_STRING_LENGTH. May be empty.
| Name | Type | Default |
|---|---|---|
value | unknown |
A non-empty string within MAX_CLIENT_STRING_LENGTH.
| Name | Type | Default |
|---|---|---|
value | unknown |
| Export | Value | Description |
|---|---|---|
TOO_LARGE | symbol | What readJson yields for a body over its cap; answer with tooLarge. |
MAX_CLIENT_STRING_LENGTH | 256 | Cap on client-chosen strings that become keys (ids, path segments, refs). Reject, never truncate. |
SUPPORTED_PROTOCOLS | { min, max } | The client protocol versions these routes serve, inclusive. |
Responses
The body is the document alone, with version and timestamp in headers, so stored compressed bytes can be sent as-is for the browser to inflate — an envelope would force a decode here.
| Name | Type | Default |
|---|---|---|
body | BodyInit | |
__1 | DocResponseOptions |
Headers for docResponse.
The status helpers answer the way the collab routes do, so clients handle them the same:
| Helper | Status | Body |
|---|---|---|
noContent() | 204 | none |
badRequest(error) | 400 | { error } |
forbidden() | 403 | none |
notFound() | 404 | none |
conflict(error) | 409 | { error }. Answer the same whoever owns the resource, so it’s no existence oracle |
gone() | 410 | none; clients stop polling |
staleVersion(error) | 412 | { error } from a StaleVersionError; the client reloads |
tooLarge(limit, name?) | 413 | { error } naming the limit |
tooManyRequests() | 429 | { error }, with Retry-After: 1 |
Testing (/testing)
In-memory BroadcastAdapter. Commit, thread and room wakes behave like redisBroadcast,
and so does room presence (TTLs included); doc presence is stored without TTLs and its polls
never wake; proofread is an inert stub. timeout is the long-poll timeout in ms.
| Name | Type | Default |
|---|---|---|
__0 | { timeout?: number; roomPresenceTTLSeconds?: number; } |
What fakeBroadcast returns: the adapter plus a record of what it broadcast.