Skip to content

Record history

Per-collection append-only audit log of record writes, with point-in-time restore.

Vaultbase records every write to a history-enabled collection in an append-only vaultbase_record_history table. Each row captures the record’s post-write state (or pre-delete state on delete) plus the actor that triggered the change.

New collections keep history unless you turn it off (since 0.40.0; before, it was off unless turned on). That holds over the API, MCP and the admin. Views never keep history. Collections made before 0.40.0 keep the setting they had.

Turn it on or off when creating or updating the collection:

PATCH /api/v1/collections/<id>
{ "history_enabled": true }

…or use the Keep record history toggle in the admin schema editor.

When the flag is off, the records API skips the history write entirely — no storage cost, no overhead. Flipping it on starts capturing immediately but does not backfill prior writes.

CREATE TABLE vaultbase_record_history (
id TEXT PRIMARY KEY,
collection TEXT NOT NULL,
record_id TEXT NOT NULL,
op TEXT NOT NULL, -- "create" | "update" | "delete"
snapshot TEXT NOT NULL, -- JSON-encoded record state
actor_id TEXT, -- caller id, or NULL
actor_type TEXT, -- "user" | "admin" | NULL
at INTEGER NOT NULL -- unix seconds
);

snapshot is the post-write record on create / update and the pre-delete record on delete.

Fields marked encrypted in the collection’s schema are decrypted when history is read, as on record reads. Other fields come back as stored, even if their value looks like ciphertext.

Deleting a collection deletes its history, so a new collection with the same name starts with none.

GET /api/v1/<col>/<id>/history?page=1&perPage=50
Authorization: Bearer <token>

Gated by the parent record’s view_rule — if you can view the live record, you can read its history. Pages are ordered newest-first. For a non-admin, a collection whose list, view and create rules are all admin-only answers 404 here, as a missing one does.

Admins see every version. Anyone else sees a version only if the view rule passes against that version as it stood, so a record that changed hands doesn’t show the new owner what the previous one wrote; with a view rule, the newest 2,000 versions are checked. With no view rule every version is listed and counted (since 0.37.0; before, at most 2,000). actor_id is shown only on the caller’s own changes.

POST /api/v1/<col>/<id>/restore?entry=<history-id>
POST /api/v1/<col>/<id>/restore?at=<unix-seconds>
Authorization: Bearer <admin-token>

Admin-only. entry restores exactly that version (the id of a history row). at restores the most recent snapshot at or before that second; several writes in one second share an at, so at takes the newest of them. The live record’s updated_at advances and the restore is itself logged as a fresh update row in history, and in the audit log as record.restore.

When the record’s latest change is a delete, a restore brings it back under the same id, with the values of the version you pick (the delete row holds the record as it was just before the delete) and its original creation time. The history gets a create row. In the admin, open the record’s history (from its audit log entry, for instance) and click Undo delete.

  • Base collections only. A deleted auth user or a singleton answers 409.
  • Records a cascade deleted with it stay deleted; restore each one from its own history.
  • A relation to a record that no longer exists fails validation (422).

History rows accumulate forever by default. Drop ancient entries via a cron job:

// cron: 0 3 * * *
const days = 90;
const cutoff = Math.floor(Date.now() / 1000) - days * 24 * 3600;
const removed = await ctx.helpers.sql.exec(
"DELETE FROM vaultbase_record_history WHERE at < ?", [cutoff],
);
ctx.helpers.log(`pruned ${removed.changes} history rows older than ${days}d`);
  • File contents. Only the filename string in the snapshot. Restoring brings back the field value but the underlying file must still exist on disk / S3.
  • Schema changes. This is a record-level log; collection schema diffs live in the migrations subsystem.
  • Reads. Only writes are persisted.

Each enabled write does one extra INSERT (parameterised, transactional) plus serialising the snapshot to JSON. Indexes cover (collection, record_id, at) and (at) for prune sweeps, so the read endpoints are O(log n).