Version History
Nextly keeps a version history of each document, so you can see what changed, compare two versions of the same language, and put an earlier one back.
Version history is opt-in per collection or single. Turn it on and Nextly keeps a version history: by default the newest 50 per document, plus the versions retention always keeps, so "who changed this, and when" has an answer. Every save that changes the content records a version, including a save to a working draft that is not yet published (see Working drafts).
import { defineCollection, text } from "nextly/config";
export const Posts = defineCollection({
slug: "posts",
versions: true,
fields: [text({ name: "title" })],
});status: true also switches history on by itself: an entity with the draft/publish lifecycle
captures history even when versions is omitted.
Working drafts need both halves — drafts-enabled versioning and the lifecycle. Either one alone gives you history and edits that write the live row:
versions | status | History | Working drafts |
|---|---|---|---|
| omitted | false | No | No |
| omitted | true | Yes | No |
true | false | Yes | No — the lifecycle is disabled |
true | true | Yes | Yes |
{ drafts: false } | either | Yes | No |
One further gate: a collection with a reachable password field never gets working drafts, whatever the flags say. A snapshot strips passwords, so a draft could neither carry a password change nor preserve one made while it was open.
An entity with neither flag is unversioned and captures nothing — and turning versioning on later does not recover saves made before. Decide before you need it, not after.
Availability
| Built in | No plugin required |
| Admin | A Versions panel on the edit screen |
| Permission | Reading history requires read access to the document; restoring requires update access |
How a version is recorded
A version is written inside the same database transaction as the content change, using the caller's transaction handle. So a version and the content it describes commit together or not at all — there is no window where a document is saved and its history is not.
The stored snapshot has the same shape the read path returns, which is what makes a restored version equal to a normal read rather than an approximation of one.
Comparing two versions
The diff compares two snapshots against the current field definitions, not against the schema as it was. That matters in practice: a field deleted since a snapshot was taken is reported as no longer part of the document, rather than silently reappearing in the comparison. Both versions must be of the same language: comparing two languages is refused.
The comparison understands the shapes your content actually uses, including rich text and block documents, so a diff of a page shows which blocks moved rather than one opaque change.
A comparison can name two versions (?from=4&to=6), or a version and a baseline the
server works out (?to=6&baseline=previous). The baselines are previous (the version
before it in the same language), published (what that language serves now: its latest
published version, while it is published) and draft (that language's current draft).
With no from and no baseline, the comparison opens on the current draft against what is
published when the language is published and has a draft, and otherwise on a version
against the one before it (?locale= picks the language when no version is named).
Some comparisons have nothing to start from, and are refused with NO_BASELINE:
- a language's first version has nothing before it;
- a language that has been unpublished, or a translation never published, has nothing published to compare with, even though its history records an earlier publish.
The response says which baseline it used. Baselines are experimental.
Secret fields are masked in diffs. A password field's history never shows its values.
Field read rules in history
Every field-level access.read rule applies to history as it applies to reading the
document: a version, a comparison and a restore show a reader only what the rules let
them read. That includes rules on fields inside groups and repeaters, and rules declared
on a component's own fields, which are judged on each instance the version holds, at any
depth. An API key is judged on its own grants, not its owner's.
- A version read lists what it held back in
withheld, by field name, or by a dotted path below the top level (banner.secret). - A comparison shows a held-back field as
hiddenin its place, with neither value. - A restore never writes back a field the restorer may not read; it is reported in
droppedFields.
History fails closed. A rule that throws hides its own field. If the rules cannot be evaluated at all, a version read withholds every field that carries one, and a restore is refused.
Restoring
Restore writes through the same update path a human edit uses. Validation runs, hooks run, component and relationship writes happen, events fire and the outbox is written — exactly as if a person had made the change.
Two consequences worth knowing:
- Restoring destroys nothing. Because a restore is an ordinary update, the state you are restoring over is itself captured as a version first. You can undo an undo.
- A restore cannot resurrect a field the schema no longer has. The payload is filtered by the current schema, so restoring an old snapshot into a changed collection puts back what still exists and reports the rest.
System columns — creation timestamps, the creating user — are never carried back by a restore, so history cannot be used to forge them.
Working drafts
A working draft holds edits in progress separately from the published document, so someone can work on a change over several days without it going live and without blocking anyone else from seeing the current published version.
Each save of a working draft is also kept in the history as a draft version, credited to the person who saved it, so earlier saves and who made them are not lost when the next save replaces the draft. A save that changes nothing adds no version, and autosaves never appear in the history. Restoring one of these versions puts its content back as the working draft; it does not publish it.
Retention
History grows. Old versions are trimmed when a document is saved, keeping the newest 50
per document by default (versions.maxPerDoc; false keeps everything), so the table does
not grow without limit. Autosaves don't count toward that limit.
Some versions are kept whatever the limit, even when they are older than the versions it keeps:
- the newest version;
- the latest published version of each language, and the latest published version with no language (a document without languages, or a publish of every language at once);
- every named version, including the "Before restore" version a restore records;
- every version that records a restore, and the version it was restored from;
- every version a feature has pinned (see Pinning a version).
So a run of saves in one language never removes another language's published version, and "restored from" always points at a version that still exists.
Pinning a version
A feature that refers to a version, such as the version an approval names, pins it so retention keeps it for as long as the feature needs it, whatever the limit.
A feature registers itself once, as it loads, under a kebab-case name and a label a reader would recognise, and keeps the handle registration returns: it is the only way to pin under that name, so no other code can move or release the slots the feature relies on. It then points named slots at a document's versions. A slot holds one version: pointing it at another frees the one it left, so a feature never has to remember to release the old one. A version stays while any slot of any feature points at it, so one feature releasing never frees what another still holds.
import {
registerVersionPinHolder,
type TransactionContext,
type VersionRef,
} from "nextly";
const legal = registerVersionPinHolder({
name: "legal-review",
label: "Legal review",
});
// Record the approval and pin the version it names in one transaction.
export async function approve(
tx: TransactionContext,
ref: VersionRef,
versionNo: number
) {
await legal.pin(tx, { ref, slot: "approved", versionNo });
}
// The document no longer needs review: free every slot this feature holds on it.
export async function clearReview(tx: TransactionContext, ref: VersionRef) {
await legal.releaseAll(tx, ref);
}pin,releaseandreleaseAllrun on the transaction you pass, so a pin commits or rolls back with the change it records.pinrefuses a version the document does not have, and waits for a save of the same document already under way.registerVersionPinHolderrefuses a name another feature registered. Registering the same name and label again, as a module reloaded in development does, returns the same handle.versionPins.list(db, ref)reads every slot on a document, of every feature.- Deleting a document, a collection or a Single removes its pins, and a pin waiting on one being deleted is refused.
A plugin's ctx.db.transaction hands it a handle to its own tables, not the
TransactionContext above, so a plugin cannot pin through it.
Related
- Draft and published status
- Content releases
- Audit log — who did what, across the system