Versioning and time travel

Every mutation is recorded, every snapshot is restorable, and the whole site can be reconstructed at any past instant

Versioning is a load-bearing principle in Cusp, not a feature you opt into. Every mutation of a versioned entity records a snapshot, every snapshot is restorable, and the whole site can be reconstructed as it stood at any past moment. This page explains how that is enforced and what it costs.

The versioned-write helper is the only mutation path

There is one sanctioned way to mutate a versioned entity: the versionedWrite() helper in src/shared/db/versioned-write.ts, reached through the per-entity repository modules in src/shared/db/repos/. Application code never issues an UPDATE against a versioned table directly. Doing so silently and permanently loses history for that change, so the rule has teeth rather than relying on review alone.

A single call does three things atomically in one D1 batch: it runs the live-row insert, update, or delete; it inserts a snapshot row into entity_versions with an incremented version_number; and it makes the R2 spillover decision for the snapshot. Either all of it lands or none of it does.

Two mechanisms keep the boundary honest, because review fatigue is real:

  • Lint rule (static) — the custom ESLint rule cusp/no-direct-versioned-write flags any insert, update, or delete against a versioned table outside the repository layer, sourcing its table list from the canonical VERSIONED_TABLE_NAMES constant. It runs in pnpm lint and gates CI.
  • Runtime guard (dynamic) — versionedWrite() accepts a private REPO_CONTEXT symbol that only the repository modules can reach. A caller outside the repo layer cannot construct the right token, so the call throws. This catches what static analysis cannot see: dynamically imported code, generated code, and tests that try to poke through.

The versioned entity types are: user, role, permission, user_role, setting, collection, field_definition, document, api_token, block_definition, theme, plugin, plugin_config, and media_metadata. The list lives in src/shared/db/versioned-tables.ts and is the single source of truth the lint rule reads. Operational state such as sessions, migration_runs, restore_runs, and theme_installations is deliberately not versioned — those rows expire or are recreated and carry no history value.

The one narrow exception is api_tokens.last_used_at, a high-frequency telemetry column written on every authenticated request. A per-request snapshot would be wasteful and noisy, so the lint rule allow-lists it by table-and-column scope: an UPDATE on api_tokens is permitted only when the SET column list is exactly last_used_at (optionally with updated_at). Any other column, and every other mutation of api_tokens, still goes through the helper.

entity_versions: the snapshot trail

Each versioned write appends a row to entity_versions. A row records the entity_type, the entity_id, a monotonically increasing version_number, the actor in changed_by (a users.id for admin actions, or null for unattended system actions such as a scheduled publish firing), an optional change_summary, the changed_at timestamp, and the full materialised state captured at that moment. History is append-only: restoring an entity never rewrites an existing row, it mints a new one.

Documents have an additional live editing path

Documents are versioned like every other entity, but they also have a real-time editing surface. While editors are connected, the DocumentSession Durable Object is the source of truth for the primary block field; D1 is the flush target, not the live store. The DO coalesces edits in memory and flushes through documentsRepo on a two-second idle alarm or after fifty operations — and that flush is the only way the DO writes to D1. Awareness state (cursors, selections) is broadcast to other editors but never persisted.

Every document mutation, whatever its origin, routes through documentsRepo: the admin REST API, the headless REST API, the DO flush, the scheduled-publish workflow, the field-migration workflow, and the example-site seed all call the same repository methods. The repository is the only place that decides R2 spillover for document payloads, and every one of its mutation methods calls versionedWrite internally.

R2 spillover for large snapshots

D1 rows have a size limit, so large payloads do not live inline. The repository makes the spillover decision; call sites never do — they pass the raw blocks and data, and the repository splits them.

  • Documents — if the serialised blocks or data exceeds 200 KB, the repository writes the payload to R2 under documents/{id}/blocks.json or documents/{id}/data.json and stores only the R2 key in D1.
  • Entity-version snapshots — if a snapshot's state exceeds the same threshold, it spills to R2 and entity_versions stores the spillover key in state_r2_key instead of the inline blob.

A version snapshot carries its own spillover key, independent of the live row's. That self-containment matters: when a document is re-published it gets a fresh R2 key, but older versions still resolve their own historical payloads, so re-publishing never invalidates the version trail. Reads rehydrate from R2 transparently — callers of the version-history endpoints never see the difference between an inline and a spilled snapshot.

Reading and restoring versions

The admin version-history component, reused on every entity detail view, reads from three endpoints under /api/admin/versions/.

  • GET /api/admin/versions/:entityType/:entityId — paginated list of versions, newest first, with version number, editor, summary, and timestamp.
  • GET /api/admin/versions/:versionId — a single version including its materialised state, with R2 spillover rehydrated.
  • POST /api/admin/versions/:versionId/restore — restore the entity to that captured state. Administrator-only. Restore is itself a versioned write, so it mints a new version and stays reversible.

The read endpoints work for every versioned type. The restore write is dispatched on entity_type through restoreEntityToState; an entity type with no v1 admin mutation surface (for example a seeded role or permission) returns 422 RESTORE_NOT_SUPPORTED rather than pretending to restore something it has no path to write.

Site-wide time travel

Single-entity history has a whole-site companion. GET /api/admin/state-at?timestamp=<epoch-seconds> reconstructs the entire site as it stood at one instant. It exposes the getStateAt reconstruction primitive in src/shared/db/repos/entity-versions.ts, which fans a point-in-time query across every versioned entity type, rehydrating R2 spillover as it goes.

The admin route is administrator-only and returns a deliberately modest browse preview — per entity type a count plus a small human-scannable sample (document title and status, user display name, setting key). It never returns full state blobs and never sensitive fields such as a user's password hash or an internal secret setting. The full reconstruction is only materialised server-side, by the RestoreVersion workflow, when an admin actually restores. Timestamps are epoch seconds, matching the restore contract so the browse screen and the restore it kicks off reconstruct the same instant.

Why it is built this way

A pure code-review rule was rejected: the cost of one missed direct UPDATE is permanent history loss. A database trigger was rejected because SQLite triggers fire per statement, not at batch boundaries, so they cannot enforce a cross-statement invariant cleanly. The lint rule plus the runtime guard hits the goal with two cheap, loud layers and no type-system gymnastics. IDs are ULIDs and timestamps are Unix epoch seconds — together they make point-in-time queries and monotonic ordering trivial.

The decisions behind this design: versioned writes as the only mutation path and ULID IDs with epoch timestamps.

Read the data layer and schema