Version history
Collaborative documents keep a list of checkpoints: automatic ones while people type, and named ones on demand. Any checkpoint can be previewed and restored. A restore is an ordinary edit, so collaborators see it live and it appears in the history itself.
History is on whenever the store implements snapshots. postgresStore, memoryStore and the
local backend all do.
What gets saved
| Checkpoint | When |
|---|---|
| Automatic | Every snapshots.every versions (default 64). It is written in the commit’s own transaction, so a commit that rolls back leaves no checkpoint |
| Version 0 | The first time a document at version 0 is written to (its first commit, or naming it before any edit): the document as it was created |
| Named | When someone names the current version (“Name current version” in the panel) |
| Restore | When a version is restored. The checkpoint records restoredFrom. The content it replaced is saved in the same transaction, so a restore can be undone from the panel |
createCollabBackend({
schema,
store,
broadcast,
snapshots: { every: 64 }, // 0: named versions and restores only
});Each checkpoint is a whole document, compressed like collab_doc.content. At the default cadence,
64 commits is roughly 15 to 30 seconds of active typing.
Database
Run the collab_snapshot statement from schema.sql (or form_snapshot from
form-schema.sql) on existing databases. apps/collab-server applies it as migration
0005-doc-snapshots. Rows are deleted together with their document.
Pass tableNames.snapshot if you namespace your tables. Once the store has history, every
document’s first write writes a checkpoint, so the table must exist before you deploy.
The panel
createVersionHistoryUiExtension adds a Version history button to the review tab. The button
opens a right panel that lists versions newest first, by name (“Autosave” for automatic ones),
date and author. Selecting a version opens it read-only. Restore this version asks for
confirmation, then restores it.
import { createVersionHistoryUiExtension } from "@stesura/collab-adapter-react";
// Module scope: uiExtensions must be a stable array.
const uiExtensions = [createVersionHistoryUiExtension({ getAuthorName: (id) => users[id]?.name ?? id })];Under a CollabProvider, the panel reads the server’s history routes. For the local backend,
pass a source:
const { backend, ...editor } = useLocalCollabEditor({ docId });
const source = useMemo(() => createLocalHistorySource({ backend, docId, authorId }), [backend]);
<DocHistoryProvider source={source}>
<StesuraEditor {...editor} uiExtensions={uiExtensions} />
</DocHistoryProvider>The preview is pageless: pagination lives in its own package, which the panel does not load.
To build your own UI, use useDocHistory(source) (the list plus saveVersion, restore,
loadMore and refresh) and useSnapshot(source, version) (one version’s document).
useDocHistorySource() returns the same source the panel uses.
Server API
await backend.listSnapshots(docId, { limit: 50, before: 128 }); // newest first, no content
await backend.getSnapshot(docId, 64); // { version, label, authorId, restoredFrom, createdAt, docJSON } | null
await backend.saveNamedSnapshot(docId, "Sent for review", userId); // { version }
await backend.restoreSnapshot(docId, 64, userId); // { version }: the new head
await backend.reconstructVersion(docId, 70); // { docJSON, version } | nullrestoreSnapshot goes through applyServerEdit, so the edit is rebased over concurrent commits
and stored with the actor { kind: "user", id: userId }. When the content already matches, it
writes nothing and resolves the current head.
reconstructVersion rebuilds any version exactly: it takes the newest checkpoint at or below the
version and replays the commits after it. Only the last commitRetention commits (default 1024)
are kept, so older versions resolve to null. Raise commitRetention if you need exact
reconstruction further back.
Routes
Under the doc mount, behind authorize like every other route:
| Method | Path | |
|---|---|---|
GET | /:docId/history?before=&limit= | { snapshots }, newest first (limit up to 200, default 50) |
GET | /:docId/history/:version | One version with its document; 404 when there is none |
POST | /:docId/history | Body { label }: name the current version; answers { version } |
POST | /:docId/history/:version/restore | Restore; answers { version }, the new head |
GET | /:docId/history/:version/exact | The doc exactly at that version; 412 once its commits are pruned |
The author of a named version or a restore comes from resolveAuthor, never from the body. The
two POSTs are throttled, and they answer 404 when the store keeps no history.
Who can restore
Naming and restoring change the document. If authorize lets read-only members in, refuse them
the history writes:
createCollabRoutes(backend, {
authorize: async (request, { docId, path }) => {
const role = await roleOf(request, docId);
if (!role) return false;
const writesHistory = path[0] === "history" && request.method === "POST";
return role !== "viewer" || !writesHistory;
},
});Retention
The engine never deletes a checkpoint. Thin them with store.snapshots.prune from a scheduled
job. For example, keep every named version and version 0, plus one automatic checkpoint per
hour:
import type { SnapshotMeta } from "@stesura/collab-adapter-server";
// The predicate sees versions newest first, so it keeps the newest checkpoint of each hour.
const keepHourly = () => {
const hours = new Set<number>();
return (meta: SnapshotMeta) => {
if (meta.label || meta.version === 0 || meta.restoredFrom !== null) return true;
const hour = Math.floor(meta.createdAt / 3_600_000);
if (hours.has(hour)) return false;
hours.add(hour);
return true;
};
};
await store.snapshots!.prune!(docId, keepHourly());Pruning version 0 loses exact reconstruction below the next checkpoint.