Rooms & suites
A room groups related documents into a suite, e.g. every document generated from one form. The
server keeps a manifest (docId, title, position per document). Clients long-poll it and mount
only the document on screen, with its own CollabProvider + useCollabEditor.
RoomProvider (manifest long-poll)
├─ sidebar: useRoomManifest() → { docs, addDoc, removeDoc }
└─ main area: ONE CollabProvider + editor for the selected docDon’t mount every document of a suite at once: each open document runs its own commit, presence and thread long-polls.
Server setup
Rooms use the collab_room and collab_room_doc tables from schema.sql (re-running it adds
missing tables). Mount the room routes next to the doc routes:
// app/api/rooms/[roomId]/[[...path]]/route.ts
import { createCollabRoomRoutes } from "@stesura/collab-adapter-server/next";
import { backend } from "../../docs/_lib/backend";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const { GET, POST, DELETE } = createCollabRoomRoutes(backend, {
authorize: async (req, { roomId }) => {
const session = await getSession(req);
return session ? isMember(session.userId, roomId) : false;
},
});Like the doc routes, authorize is required (or allowUnauthenticated: true), and rateLimit
throttles adding and removing documents.
Set the backend to reject missing documents, so a deleted doc answers 410 instead of being re-created by the next client that polls it:
export const backend = createCollabBackend({
schema,
store: postgresStore({ url, schema }),
broadcast: redisBroadcast({ url: REDIS_URL }),
missingDocBehavior: "reject",
});Authorization
Room membership lives in your app, not in the library’s tables. Both route factories check it:
- Room routes:
authorizegets theroomIddirectly. - Doc routes: look up the room with
backend.getRoomOfDoc(docId)(one indexed read), then check the same membership:
export const { GET, POST, PUT, DELETE } = createCollabRoutes(backend, {
authorize: async (req, { docId }) => {
const session = await getSession(req);
if (!session) return false;
const roomId = await backend.getRoomOfDoc(docId);
return roomId ? isMember(session.userId, roomId) : false;
},
});Backend operations
| Method | Effect |
|---|---|
getRoomManifest(roomId) | { ref, docs }: the manifest and its change token |
listenForRoomManifest(roomId, knownRef) | Long-poll; answers as soon as the ref differs |
createRoomDoc(roomId, { docId, title, position, initialContent? }) | Creates the room if absent, seeds the doc and adds it to the manifest |
removeRoomDoc(roomId, docId) | Removes it from the manifest and deletes the document (doc, commits, threads) |
getRoomOfDoc(docId) | Doc → room, for authorize |
Details and errors are on Backend API → Rooms.
Client
"use client";
import {
CollabProvider,
RoomProvider,
useCollabEditor,
useRoomManifest,
} from "@stesura/collab-adapter-react";
function Sidebar({ onSelect }: { onSelect: (docId: string) => void }) {
const { docs, loading, failed, addDoc } = useRoomManifest();
if (loading) return <Spinner />;
return (
<nav>
{failed && <p>This list is out of date. Reload the page.</p>}
{docs.map((d) => (
<button key={d.docId} onClick={() => onSelect(d.docId)}>{d.title}</button>
))}
<button
onClick={() => addDoc({ docId: crypto.randomUUID(), title: "New", position: docs.length })}
>
Add
</button>
</nav>
);
}
function SuiteEditor() {
const { editorState, dispatch, pluginFactory, schema, docStatus } = useCollabEditor();
if (docStatus === "gone") return <p>This document was removed from the suite.</p>;
return <StesuraEditor state={editorState} dispatchTransaction={dispatch} /* … */ />;
}
export default function SuitePage({ roomId }: { roomId: string }) {
const [selected, setSelected] = useState<string | null>(null);
return (
<RoomProvider roomId={roomId}>
<Sidebar onSelect={setSelected} />
{/* key remounts the editor per document: only the selected one is connected */}
{selected && (
<CollabProvider key={selected} docId={selected}>
<SuiteEditor />
</CollabProvider>
)}
</RoomProvider>
);
}RoomProvidertakesroomId,apiBasePath,headers,credentialsandonError. They apply to room requests only: pass them to eachCollabProvidertoo.useRoomManifest()returns{ docs, loading, failed, addDoc, removeDoc }.docsupdates within a poll cycle when anyone adds or removes a document, your own changes included.failedistrueonce polling has stopped for good (e.g. an auth failure).addDoc({ docId, title, position, initialContent? })andremoveDoc(docId)return promises and throw on failure. The list updates through the poll, not optimistically.useRoom()returns{ roomId, apiBasePath }.- When a document is removed while open, the server answers 410 and
docStatusturns"gone".
Template publishing
Suites are usually generated from a published snapshot of a template, not from the draft its author is still editing. Published versions are your app’s data, e.g.:
create table template_version (
template_doc_id text not null,
version_label text not null,
doc_json jsonb not null,
doc_version integer not null,
created_at timestamptz not null default now(),
primary key (template_doc_id, version_label)
);Publishing reads the live template:
const snapshot = await backend.getInitialDoc(templateDocId);
await db.insert("template_version", {
template_doc_id: templateDocId,
version_label: label,
doc_json: snapshot.docJSON,
doc_version: snapshot.version,
});Generating a suite seeds each document from the snapshot, then fills in fields with server edits:
export async function generateSuite(roomId: string, templateId: string, fields: Fields) {
const { doc_json } = await getPublishedVersion(templateId);
for (const [i, title] of ["Contract", "Annex A", "Cover letter"].entries()) {
await backend.createRoomDoc(roomId, {
docId: crypto.randomUUID(),
title,
position: i,
initialContent: doc_json,
});
}
const { docs } = await backend.getRoomManifest(roomId);
return backend.applyServerEditMany(
docs.map(({ docId }) => ({
docId,
build: setPlaceholders(fields), // pure, anchored by placeholder attrs
opts: { mustExist: true },
}))
); // index-aligned; retry "error" and droppedSteps > 0 entries
}initialContent is ProseMirror JSON. Don’t build it from HTML on the server:
createInitialDoc(schema, html) needs the DOM and throws in route handlers and server actions. For
simple seeds use createDocContent(schema, blocks) from @stesura/core/helpers.
Scope
- Presence and comment threads are per document.
- Commits aren’t multiplexed across a room: with one open document there is nothing to multiplex.
Next steps
- Backend API: room and document operations from server code.
- Server edits:
applyServerEdit, anchoring, fan-out, partial failure. - Pitter Patter: the base backend.
- Live: suite demo .