Skip to Content
PackagesCollaborationVersion History

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

CheckpointWhen
AutomaticEvery snapshots.every versions (default 64). It is written in the commit’s own transaction, so a commit that rolls back leaves no checkpoint
Version 0The 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
NamedWhen someone names the current version (“Name current version” in the panel)
RestoreWhen 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 } | null

restoreSnapshot 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:

MethodPath
GET/:docId/history?before=&limit={ snapshots }, newest first (limit up to 200, default 50)
GET/:docId/history/:versionOne version with its document; 404 when there is none
POST/:docId/historyBody { label }: name the current version; answers { version }
POST/:docId/history/:version/restoreRestore; answers { version }, the new head
GET/:docId/history/:version/exactThe 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.

Last updated on