Programmatic edits
applyServerEdit edits a document from server code. Per call it loads the document, runs your
build on a transaction, and submits the result through the same authority as client commits, so
it is rebased over concurrent edits and retried on contention. Nothing is kept in memory between
calls, and the doc table is never written directly.
const result = await backend.applyServerEdit(docId, ({ tr, schema }) => {
tr.doc.descendants((node, pos) => {
if (node.type.name !== "placeholder" || node.attrs.fieldId !== "company") return true;
if (node.textContent === value) return false; // already correct → noop
tr.replaceWith(pos + 1, pos + 1 + node.content.size, schema.text(value));
return false;
});
});
// { status: "applied", version, droppedSteps } | { status: "noop" } | { status: "not-found" }Server plugins
Plugins whose invariants must hold on server edits (numbering, endnote order, reference
bookkeeping) go in serverPlugins. Their appendTransactions land in the same commit as your
edit:
createCollabBackend({
schema,
store,
broadcast,
// State derived from the doc alone. No DOM, layout or UI plugins (pagination, decorations).
serverPlugins: (schema) => [...referencesPlugins()],
});Writing a build
build runs again on a fresh read for every contention retry, so:
- Find targets by walking the doc, using stable node attrs (e.g. a
placeholderwithattrs.fieldId). Never cache positions. - Keep it pure and idempotent: compare the current content first, as above. A re-run then
reports
"noop", so retries and double submits are safe.
Many documents
const { docs } = await backend.getRoomManifest(roomId);
const results = await backend.applyServerEditMany(
docs.map(({ docId }) => ({ docId, build: setPlaceholder(fieldId, value) }))
);Runs up to 3 documents at a time; edits to the same doc run in order. It never throws for one
document. It returns one result per edit, in input order, each with its docId:
for (const result of results) {
if (result.status === "error") retryQueue.push(result.docId);
else if (result.status === "applied" && result.droppedSteps > 0) {
// A concurrent edit made part of ours unmappable; it landed without those steps.
retryQueue.push(result.docId);
}
}There is no cross-document atomicity: each document has its own version, so a suite-wide edit is N independent commits. Design for partial success.
Check droppedSteps. Steps that can’t be mapped over concurrent commits are dropped from the
commit. Re-run the edit (it re-anchors on the new doc) or report it.
Debounce per field when edits come from a form. A commit per keystroke across N documents is N commits, each a database row and a rebase for anyone typing.
Behaviour
- Anonymous, not undoable. Server commits carry no user, and clients receive them as remote
commits, so a user’s undo won’t revert them. To make an edit reviewable, add track-changes marks
in
build. - Live for open clients. A document open in a browser receives the edit like any collaborator’s commit. Closed documents simply advance.
- Not size-capped.
buildis your code, somaxContentBytesdoesn’t apply. Client commits are capped, see Ingest size.
API reference
applyServerEdit(docId, build, opts?): Promise<ServerEditResult>
// opts.mustExist — "not-found" instead of seeding a missing doc (always the case in "reject" mode)
// opts.maxRetries — contention retries before ServerEditContentionError (default 5)
applyServerEditMany(edits: { docId, build, opts? }[]): Promise<ServerEditManyResult>
// in input order; each { docId } & (ServerEditResult | { status: "error"; error })For tests, memoryStore implements the store in memory. The package’s apply-server-edit.spec.ts
has examples, including rebases and dropped steps.
Next steps
- Backend API: the rest of the server-side surface.
- Rooms: manifests, lazy mounting, template publishing.
- Live: suite demo , where form inputs drive
applyServerEditMany.