# 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](#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](#working-drafts)).

```ts
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.

> [!WARNING]
> 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 `hidden` in 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:

1. **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.
2. **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](#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

> [!WARNING]
> Experimental: the names and shapes below may change before they are stable.

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.

```ts
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`, `release` and `releaseAll` run on the transaction you pass, so a pin commits or
  rolls back with the change it records. `pin` refuses a version the document does not have,
  and waits for a save of the same document already under way.
* `registerVersionPinHolder` refuses 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](https://nextlyhq.com/docs/guides/draft-published-status.md)
* [Content releases](https://nextlyhq.com/docs/releases.md)
* [Audit log](https://nextlyhq.com/docs/audit-log.md) — who did what, across the system
