Skip to content

History

Every content write records a revision. The admin renders a field-level diff of any two consecutive ones and can put a single field back.

Nothing, if its data goes through a form the engine already owns.

A plugin’s config settings, and the fields it contributes through a page tab, are written by the engine’s own save path, so they get revisions, diffs, the per-field clock and restore without a line of code.

A plugin that writes its own table gets none of it. That is the strongest reason to express what you can as fields.

interface Revision {
id: number;
batch: string; // one uuid per user action, however many rows it wrote
scope: 'setting' | 'section' | 'page' | 'media' | 'people';
key: string;
pageId: number;
action: 'create' | 'update' | 'delete';
data: string | null; // the whole value after the write, as JSON
position: number | null;
enabled: boolean | null;
source: 'admin' | 'ai' | 'import';
actor: string; // email
createdAt: string;
}

data is the full value after the write, not a patch. Diffs are computed by comparing a revision against the one before it, so a missing intermediate row would produce a wrong diff rather than a gap. That is why the write matters more than the read.

import { authorOf } from '@ouncepage/core/guard';
import { newBatch } from '@ouncepage/core/audit';
const author = authorOf(Astro.locals.editor, newBatch());
// { email: 'someone@example.com', source: 'admin', batch: '...' }

One newBatch() per user action, passed to every save it causes. Saving a page and its five sections is one batch, so the activity view shows one entry that expands into six, rather than six unrelated entries.

source is what separates a human’s change from a model’s. The history view tags ai in amber.

import { listRevisions, previousRevisions, revisionValue } from '@ouncepage/core/audit';
import { diffForm } from '@ouncepage/core/diff';
const revisions = await listRevisions({ scope: 'setting', key: 'site', limit: 60 });
const earlier = await previousRevisions(revisions);
for (const revision of revisions) {
const before = revisionValue(earlier.get(revision.id) ?? null);
const after = revisionValue(revision);
const changes = diffForm(form, before, after);
}

diffForm walks the form shape rather than the raw object, so a change is reported with the label an editor recognises rather than a dotted path. It descends into group, indexes list entries and names each by its titleField. Any scalar path the form does not cover is still compared, so a schema that has drifted from its form shows up rather than disappearing.

import { restoredValue } from '@ouncepage/core/restore';
const outcome = await restoredValue({
form,
schema,
current: await loadSetting('site'),
source: revisionValue(previous),
paths: ['site.brand'], // empty means every path in the diff
});
if (outcome.ok) await saveSetting('site', outcome.value, author);

Restore merges the chosen paths from the old value into the current one, then sanitises and validates the result. It is not a rollback: everything you did not select keeps its current value, and the restore itself is a new revision, so it is undoable in turn.

A restore that fails validation returns { ok: false, errors }. That happens when a schema has changed since the revision was written, which is the correct outcome: old content that no longer fits the current shape should not be written back.

Any field rendered with history on shows a clock that opens the dialog scoped to that one path. It needs nothing from a field type: the engine places the button beside your component in its own grid column.

media_uses records which content references which media URL. It is rebuilt in full on every save rather than patched, which costs a full scan and cannot drift. usesOf(url) answers “where is this used”, and pastUsesOf(url) scans revisions for “where was it used”, which is what makes deleting an image safe to reason about.