npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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