@michaelthielemann/kestrel-revisions-default
v5.8.1
Published
revisions@1: a full snapshot per save, branching through a parent pointer, restore as a new save, retention with a prune step.
Readme
revisions/default
What. revisions@1 on persistence@1: an append-only history per collection, document and
locale. Every save records a full snapshot of the fields as they were stored, together with the
author, the status at that moment and a pointer to its parent revision.
Why. Editors want the state of last Tuesday back, and they want to see how a page got where it
is. A snapshot per save is the cheapest model that answers both; deltas only pay off once the space
hurts. The module records what the content steps already saved and never writes content itself, so
it works with any content@1 implementation and with no content module at all.
Branching. parentId is the whole mechanism. A normal save's parent is the head of that
(collection, document, locale); a save whose run restored revision R gets R as its parent, which
forks the line at R. Switching branches is therefore the same gesture as going back: restoring the
tip of a branch and saving again continues that branch. The head is kept per group in
revisions_heads; the document row itself stays the only live state. Recorded timestamps are
strictly increasing, so several saves within one millisecond still list in order.
No merges. Restoring is a new save, so the model is append-only and two branches never have to
be reconciled. Merging block trees (repeaters recursively, word diffs on text) would cost more than
the whole history view, for a conflict the system cannot even detect today — there is no optimistic
locking and no presence display, so in practice one editor works per document and locale with
last-write-wins. If conflicts ever matter, expectedUpdatedAt is the lever, not a merge.
Config. keep (50) newest revisions per document and locale, maxSnapshotBytes (1 MiB),
pruneOnWrite (true), statusField (status) and liveStatuses (["published"]) name the field
that decides whether a recorded state was live, maxLimit (100) caps a list page.
statusField/liveStatuses must match whatever actually publishes — delivery-static names the
same thing statusField/publishedValue, a workflow in the consumer may add further live values.
A value missing here is recorded as not live, and retention then drops a state that was online.
Retention. prune keeps, per document and locale: the newest keep, every revision that was
ever live, every labelled one, the head, every branch tip and every branch point. Everything else
goes, and the survivors are re-parented onto their nearest surviving ancestor, so a parentId chain
never breaks and the shape of the tree is preserved. revisions.prune is the cron step for the
whole store; with pruneOnWrite every save prunes its own group as well.
keep is therefore not an upper bound: ever-live, labelled and branch revisions accumulate without
one, and a page that is published on every save keeps a snapshot of every save. Pruning a group
loads that group's rows including their snapshots, so a history that grows into the thousands makes
every save of that document more expensive — cap it with a stricter liveStatuses, labels used
sparingly, or pruneOnWrite: false plus the cron.
No-op saves. A save whose parent is the current head and whose snapshot and status both
match that head exactly (deep, key-order-independent comparison) is not recorded; record still
succeeds and returns the head's summary. This is what keeps an editor's repeated saves of an
unchanged document from flooding the history and defeating retention. A restore is always
recorded, and so is the first save after one, since its parent is the restored revision, not the
head. A skipped (oversized) snapshot never counts as identical, on either side of the comparison.
Size guard. A snapshot beyond maxSnapshotBytes is recorded as skipped: true with no content
and a warn log line. The save itself never fails for it; only restoring such a revision does, with
409.
What a restore restores. The snapshot goes through the ordinary update as a patch, so it moves exactly the fields the model had when it was recorded. A snapshot holds the document as one locale reads it, so the fields that are not localized are in every locale's snapshot — restoring one locale moves them for the others too, which record no revision of their own for it.
When the model changed since. A history that becomes unusable the moment a field is renamed is
worth nothing, so revisions.restore compares the snapshot with the model instead of failing on it:
fields the model no longer has are left out of the body (dropped), fields the model gained after
the snapshot are simply not in the patch and keep their current value (missing). Both lists go on
the context as restoreReport, and revisions.reportRestore puts them into the result as
restore: { revisionId, dropped, missing } — put that step at the end of the restore pipeline, after
the step that shapes the response. revisions.read runs the same comparison and returns it next to
the snapshot, so a UI can warn before anyone restores anything.
The model comes from the optional content@1 dependency's model() and is read for two things
only: the default locale, and which fields a restore may still write; without it, or for a
collection the model does not describe, the snapshot goes over unchanged as before and no report is
written — an unknown field then fails the update chain with the 400 it always did.
What restore does not decide. Everything inside a field stays the validator's business:
validate.check:<c>.body sees the restored body as it sees a hand-typed one, and its 400 carries
its own problem list unchanged. A block type that no longer exists is therefore not something a
restore repairs — that is a content migration, which rewrites the stored documents (and, if it
matters, the snapshots) before anyone restores them.
The author reference. A revision keeps author: { id, name } as a snapshot of who saved it, so a
later rename does not rewrite history. That reference is the only personal datum in the store, and
reassignAuthor(fromId, to) is what a user deletion needs: it rewrites every revision of one author,
either to another user or, with null, to the anonymous author { id: null, name: null } — a
display name for that is the frontend's business. Content, snapshots, order and count stay exactly as
they were. The method is not part of revisions@1: the contract is published and therefore frozen
(docs/contracts.md), so the capability lives on this module's instance and is reachable through its
step, the same way authn/multi offers user administration beside authn@1.
The step revisions.reassignAuthor takes the former author from params.id or, in a pipeline
started by an event, from the payload's id, and the target from reassignTo — either directly in
the payload ({ "reassignTo": { "id", "name" } } or null) or, for a user.deleted emitted with
?with=result, from payload.result.reassignTo. It answers { from, to, revisions }, is idempotent
(a second run finds nothing left to move and reports revisions: 0) and can therefore be re-run by
hand after a failure, through an admin route of its own.
Steps. revisions.record:<collection> after content.create/content.update,
revisions.restore:<collection> before the ordinary update chain (it only fills the body, the
existing steps validate, sanitize, save, index and publish), revisions.reportRestore at its end,
plus list, read, label, prune, reassignAuthor, remove and removeTranslation.
Not included. No diff — a block-aware diff belongs in the admin UI. No merge, no conflict
detection, no undo stack (that is the editor's, per session). No pipelines and no routes: the
consumer wires those, docs/api.md shows the example instance's.
Generated from the manifest
@michaelthielemann/kestrel-revisions-default – module revisions/default: provides revisions@1; requires persistence@1; optional content@1.
| Config | Type | Required | Default |
|---|---|---|---|
| keep | integer | no | 50 |
| maxSnapshotBytes | integer | no | 1048576 |
| pruneOnWrite | boolean | no | true |
| statusField | string | no | "status" |
| liveStatuses | array | no | ["published"] |
| maxLimit | integer | no | 100 |
| Step | Summary | Reads | Writes | Input | Output | Errors |
|---|---|---|---|---|---|---|
| revisions.record:<arg> | Record the saved document from the result as a revision; its parent is the head, or the revision a restore put on the context | result | – | ?locale: string | – | 503 the revision could not be written |
| revisions.list:<arg> | Revisions of one document and locale, newest first and without snapshots | params.id | result | ?locale: string, limit: integer, offset: integer | { items: object[], total: number, head: string | null, … } | 400 missing id |
| revisions.read:<arg> | One revision of a document including its snapshot and, where the content model is known, the dry run of its restore under restore | params.id, params.revisionId | result | – | { id: string, collection: string, documentId: string, locale: string, parentId: string | null, createdAt: number, author: object, kind: "save" | "restore", label: string | null, status: string | null, live: boolean, bytes: number, skipped: boolean, snapshot: object | null, restore?: object, … } | 400 missing id or revisionId; 404 no such revision |
| revisions.restore:<arg> | Put a revision's snapshot into the body, so the steps behind it save it like any other write and branch the history at that revision; fields the model has dropped since are left out | params.id, params.revisionId | body, payload, revisionParent, restoreReport? | – | – | 400 missing id or revisionId; the restored fields fail validation; 404 no such revision; 409 the revision carries no snapshot |
| revisions.reportRestore | Add what the restore of this run left out to the result as restore; without a known content model the result stays as it is | – | result.restore? | – | { restore?: object, … } | – |
| revisions.label:<arg> | Name a revision, or clear its name with null; a labelled revision is never pruned | params.id, params.revisionId | result | { label: string | null } | { id: string, collection: string, documentId: string, locale: string, parentId: string | null, createdAt: number, author: object, kind: "save" | "restore", label: string | null, status: string | null, live: boolean, bytes: number, skipped: boolean, … } | 400 missing id or revisionId; 404 no such revision |
| revisions.reassignAuthor | Replace the author of every revision written by the user in params.id or the event payload's id: with reassignTo that user, without it the anonymous author { id: null, name: null } | params.id, payload | result | { reassignTo?: object | null, … } | { from: string, to: object | null, revisions: number, … } | 400 no user id, or a reassignTo that is neither null nor { id, name }, or the former author again |
| revisions.prune | Apply the retention rules to every recorded document and locale | – | result | – | { inspected: number, removed: number, … } | – |
| revisions.remove:<arg> | Drop every revision of a document, in every locale | params.id | – | – | – | 400 missing id |
| revisions.removeTranslation:<arg> | Drop the revisions of one locale of a document | params.id | – | ?locale: string | – | 400 missing id |
Pipelines in examples/minimal using these steps:
- createPage (POST /pages):
authn.requireUser→authz.require:pages.write→validate.check:pages.body→validate.sanitize:pages.body→validate.check:pages.body→references.check:pages→content.create:pages→revisions.record:pages→references.index:pages→links.extract:pages→delivery.publish:pages→delivery.exportLlms→events.emit:page.created - deletePage (DELETE /pages/:id):
authn.requireUser→authz.require:pages.delete→references.guard:pages→content.remove:pages→revisions.remove:pages→references.unindex:pages→links.unextract:pages→delivery.unpublish:pages→delivery.exportLlms→events.emit:page.deleted - deletePageTranslation (DELETE /pages/:id/translations/:locale):
authn.requireUser→authz.require:pages.write→content.removeTranslation:pages→revisions.removeTranslation:pages→references.index:pages→links.extract:pages→delivery.publish:pages→delivery.exportLlms→events.emit:page.translationRemoved - labelPageRevision (PATCH /admin/pages/:id/revisions/:revisionId):
authn.requireUser→authz.require:pages.write→revisions.label:pages - pageRevision (GET /admin/pages/:id/revisions/:revisionId):
authn.requireUser→authz.require:pages.manage→revisions.read:pages - pageRevisions (GET /admin/pages/:id/revisions):
authn.requireUser→authz.require:pages.manage→revisions.list:pages - pruneRevisions (cron 15 3 * * *):
revisions.prune - reassignRevisionAuthor (event user.deleted):
revisions.reassignAuthor - restorePageRevision (POST /admin/pages/:id/revisions/:revisionId/restore):
authn.requireUser→authz.require:pages.write→revisions.restore:pages→validate.check:pages.body→validate.sanitize:pages.body→validate.check:pages.body→references.check:pages→content.update:pages→revisions.record:pages→references.index:pages→links.extract:pages→delivery.publish:pages→delivery.exportLlms→revisions.reportRestore→events.emit:page.restored - retryReassignRevisionAuthor (POST /admin/users/:id/revisions/reassign):
authn.requireUser→authz.require:users.manage→revisions.reassignAuthor - updatePage (PATCH /pages/:id):
authn.requireUser→authz.require:pages.write→validate.check:pages.body→validate.sanitize:pages.body→validate.check:pages.body→references.check:pages→content.update:pages→revisions.record:pages→references.index:pages→links.extract:pages→delivery.publish:pages→delivery.exportLlms→events.emit:page.updated
