Skip to content

Entity History

Entity history records a snapshot of entity values on every create, update, and delete. This gives you a full audit trail with diffs.

Enable history in initializeRebaseBackend:

await initializeRebaseBackend({
// ...
history: true
});

Or with a custom retention period:

history: {
retention: 30 // Days. Entries older than this are pruned (default: 90)
}

Mark which collections should track history:

import { defineCollection } from "@rebasepro/cms-types";
const ordersCollection = defineCollection({
slug: "orders",
name: "Orders",
table: "orders",
history: true, // Enable for this collection
properties: { /* ... */ }
});
  1. The backend creates a rebase.entity_history table automatically.
  2. On every create, update, or delete, an entry is recorded with:
    • Entity ID, and the collection slug (in table_name)
    • The entity’s values after the change (values; for a delete, the entity that was deleted)
    • On an update, what the changed fields held before it (previous_values)
    • Timestamp and user ID
    • The action (create, update, delete)
    • An array of changed_fields showing which columns were modified

The values are the row as the REST API serves it: its columns, dates as timestamps, a belongsTo as its foreign key. That is what a revert writes back.

An entry never holds a related row’s values. Reading a row’s history needs only read access to that row, not to the rows it points at, so a relation is kept as a reference — { "__type": "relation", "id", "path" } — and nothing more. Entries that older versions recorded with the related row embedded (a delete used to store the admin panel’s view of the row) are served the same way: the history endpoint drops the embedded values before it answers.

On a collection with soft delete, a delete is recorded as delete and a restore (the update setting the soft-delete field back to null) as update. A row’s history stays readable while the row is in the trash, and reverting it to a version from before the delete restores it.

Since 0.25 An update entry keeps, in previous_values, only the fields listed in changed_fields. Every other field held the same value before and after the change, so it is in values already; storing the whole row twice cost a one-field change to a 20-field row 1,269 bytes, where it now costs 794.

Entries recorded before 0.25 keep the whole previous row. The entry’s format says which it is:

format previous_values on an update
1 (entries from before 0.25) The whole row before the change
2 The changed_fields only, each as the whole row had it

The admin’s history view and a revert read both alike: the view shows each changed field’s previous value, and a revert writes the chosen entry’s values, which are whole in both formats. To rebuild the row as it was before a format: 2 change, take values and put back each field previous_values holds.

To avoid recording redundant logs where fields are saved but no values change, the HistoryService performs a structural deep equality comparison on the top-level keys of the old and new values:

  • It ignores system metadata properties starting with __.
  • If differences are found, the names of the modified properties are saved in the changed_fields (text[]) column.
  • If the deep equality check detects zero changes, the history insertion is entirely skipped.

Retention is enforced in two places:

  • After each write, for the entity written: right after it is saved or deleted, the server prunes that entity’s entries beyond the newest 200, or older than the retention period. It does not hold up the write.
  • Since 0.25 Once an hour, for every entity: entries older than the retention period are deleted whatever they belong to — including entities that are never written again, such as deleted ones. See Pruning lifecycle.
GET /api/data/:slug/:entityId/history

Returns a list of history entries for a specific entity, ordered by most recent first:

{
"data": [
{
"id": "5b0e7c2e-…",
"table_name": "orders",
"entity_id": "123",
"action": "update",
"changed_fields": ["status"],
"values": { "status": "shipped", "total": 99.99 },
"previous_values": { "status": "pending" },
"updated_by": "admin-user-id",
"updated_at": "2025-01-15T10:30:00Z",
"format": 2
}
],
"meta": { "total": 1, "limit": 20, "offset": 0, "hasMore": false }
}
POST /api/data/:slug/:entityId/history/:historyId/revert

Writes the entry’s values back to the row through the normal save path, so the revert is itself recorded as an update. It answers the saved row under data and meta.reverted_from, the entry’s id.

Entries written by older versions can hold keys the row has no column for: a field an afterRead computed (before 0.24, a save recorded the row as afterRead showed it), or a relation stored as the admin panel’s view of it. A revert writes the row’s columns and leaves those keys out, an owning relation going back as its foreign key, and names what it left out in meta.dropped_fields. A value an old afterRead masked (********) has a column, so it is written back as it was recorded.

Setting Default Description
retention 90 Entries older than this many days are deleted.

Each entity also keeps at most its newest 200 entries. That cap is fixed; it has no setting.

After each change the server records, the entity that changed is pruned: its entries older than the retention period go, then everything beyond its newest 200. This runs asynchronously and does not hold up the write.

A write made through openRebase or rebase console records its history entry but prunes nothing, because a script cannot know the retention your server is configured with. The server prunes that entity on its next write to it, and in the hourly sweep below.

Since 0.25 Once an hour, the backend also deletes every entry older than the retention period, of every entity. Before this, the history of an entity that was never written again — every deleted one — was not pruned at all. The sweep:

  • runs on one instance of the fleet per hour, on the processes that run the scheduled jobs (it claims its hour in rebase.cron_claims, as a cron run does), first when the backend starts;
  • deletes in batches of 5,000 entries, one statement each, until a batch comes back short, skipping entries another sweep holds — so a first sweep over a large backlog is many short deletes, not one long one;
  • reads an index on updated_at, idx_history_updated_at. On a database created before 0.25 it is built in the background after the first boot, CREATE INDEX CONCURRENTLY, without blocking reads or writes — about a second per 2 million entries.

The same boot drops idx_history_entity where it exists: idx_history_time leads with the same columns and serves every read it did, so it only slowed each recorded change. The drop waits at most a second at a time for the table’s lock, and a table something holds throughout keeps the index until the next boot.

The cap of 200 entries per entity is applied only after a write to that entity.