Versioning and time travel

Every change to every entity is versioned, restorable, and reconstructable at any point in time

Complete versioning is one of Cusp's load-bearing ideas. Every mutation of a versioned entity is captured as an immutable snapshot, so you can see who changed what and when, browse any earlier state, restore it, or roll the whole site back to one moment in time. History is never rewritten: even a restore is recorded as a new version on the trail.

Everything is versioned

Versioning is not opt-in per field or per content type. It is a property of the entity. Mutating any of the versioned entity types does three things atomically: it updates the live row, inserts a snapshot into the entity_versions table with an incremented version number, and spills that snapshot to R2 if it exceeds the row-size threshold. The three steps succeed or fail together.

The versioned entity types are:

  • Content and schema — document, collection, field_definition, block_definition
  • Identity and access — user, role, permission, user_role, api_token
  • Extensions and configuration — theme, plugin, plugin_config, setting
  • Media — media_metadata (the catalogue row, not the binary in R2)

Documents additionally have a finer-grained draft history through revisions and the live operation log inside the DocumentSession Durable Object, which is the source of truth while a document is open for editing. The entity_versions trail captures the committed milestones; revisions capture the autosaves in between.

Nothing silently loses history

Everything you do in the admin is captured before it takes effect — editing a document, changing a setting, switching a theme, updating a user. There is no path through the editor that changes a versioned entity without recording a snapshot, so history can never be silently lost and any change can be rolled back.

Browsing version history

Every entity detail view in the admin includes a version-history timeline: who made each change, when, and a short summary, newest first. Selecting a version materialises its full state — R2 spillover is rehydrated transparently — and you can compare it against the current state inline as a JSON diff.

The same surface is available over the admin API for the version-history component and any tooling you build against it:

  • GET /api/admin/versions/:entityType/:entityId — paginated list of versions for one entity, newest first
  • GET /api/admin/versions/:versionId — a single version including its materialised state
  • POST /api/admin/versions/:versionId/restore — restore the entity to that version

The list and single-version reads work for every versioned entity type. Restore is supported for the types that have a mutation surface in the admin — setting, collection, field_definition, block_definition, theme, plugin, api_token, user, user_role, document, and media_metadata. The three seeded-only types without an admin mutation path (role, permission, plugin_config) return a 422 and have no restore adapter yet.

Restoring creates a new version

Restore is a forward operation, never a rewrite. When you restore version 7 of a document that is currently at version 12, Cusp reads the state captured by version 7 and writes it back through the normal versioned-write path, minting version 13. Versions 8 through 12 stay on the trail. The restore itself shows up as a regular entry in the history, so it is auditable and itself reversible — you can restore the restore.

If the live row already matches the target state byte-for-byte, the restore is a no-op. That property is what makes a restore safe to replay: running it again changes nothing.

Site-wide time travel

The single-entity history has a site-wide companion. GET /api/admin/state-at?timestamp= reconstructs the whole site at one instant, fanning a state-at-timestamp query across every versioned entity type. The admin Time-travel browse screen, at /admin/time-travel, calls it and shows a scannable preview — per entity type a count plus a small human-readable sample (document titles and status, user display names, setting keys). It never returns full state blobs or sensitive fields such as a user's password hash. Timestamps are epoch seconds.

When you decide to act on what you see, restoring the site to that instant runs the RestoreVersion workflow. For each in-scope entity it reads the state at the target timestamp and, if it differs from the live row, mints a new version carrying that state — the same forward-mint discipline as a single-entity restore, applied across the whole site. Anything created after the target instant is then removed in a second pass, so the site ends up matching that moment exactly rather than layering the old state on top of newer entities. The work fans out one step per entity type, with a per-entity cursor so a failure resumes from where it stopped rather than starting over. Restore adapters ship for 11 of the 14 versioned types in v1; role, permission, and plugin_config have no write path yet and are skipped, landing in a v1.x release.

Two safeguards matter here:

  • Auth entities are skipped by default. The five audit-trail types — user, role, permission, user_role, api_token — are excluded unless you explicitly opt in. The opt-in is one all-or-nothing checkbox in the restore dialog: you either roll all five back or none of them, there is no per-type toggle. Restoring them is destructive — an API token revoked between the target time and now would be re-minted with its old hash — which is why it is off by default.
  • Site-wide restore is a singleton. A unique index on the active run blocks a second concurrent restore; the route returns 409 if one is already running. A partial restore that fails leaves a consistent partial state and a restore_runs row recording where it stopped, resumable on retry.

Pruning policies

Insert-only history grows without bound, and the D1 database has a ceiling. The PrunePolicies workflow keeps version history bounded by capping the number of versions retained per entity, deleting the oldest beyond the cap and cleaning up any R2 spillover blobs they referenced. It runs daily, one hour after the daily backup so the backup always captures pre-prune state. It is scheduled from a long-lived workflow instance using step.sleepUntil — there is no cron entry.

Default caps per entity type:

  • document — 200 versions per document
  • setting, plugin_config — 100 versions each
  • collection, field_definition, block_definition, theme, plugin, media_metadata — 50 versions each
  • user, role, permission, user_role, api_token — unlimited by default, because they are the audit trail a breach investigation depends on
  • document revisions (autosaves) — 1000 rows per document

Every cap is a typed setting, keyed pruning.{entity_type}.max_versions (and pruning.revisions.max_per_document for autosaves), where 0 means unlimited. There is no dedicated Settings-screen field for them in v1; you set them through the settings API. Changing a prune policy is itself a versioned setting change, so the history records when you adjusted it. If the unlimited audit-trail defaults ever grow uncomfortably on a noisy site, override the relevant cap to a finite value deliberately.

What history is not

Cusp does not use a CRDT. Real-time collaboration is serialised by the DocumentSession Durable Object acting as the single source of truth, not merged from divergent client copies. Versions are immutable snapshots, never edited in place; pruning hard-deletes whole rows past the cap rather than tombstoning them, because the cap is the policy and a pruned row's R2 blob is gone with it.

A headless version-history surface under /api/v1 — listing and reading document revisions, and a rate-limited site-wide state-at endpoint for external clients — is a documented future surface. The reconstruction primitive it builds on ships today; the admin equivalents under /api/admin are the supported surface now.

Next: Search