Row versioning
Full row history + time-travel. audit() records who/when; versioning records what-changed-to-what — a value snapshot of every row on every write, with as-of queries.
@voltro/plugin-versioning keeps a complete value history of selected tables. Where audit() records who changed a row and when, versioning records what — a full snapshot of the row on every insert / update / delete — and lets you read any row as of a past instant. It rides the framework's post-commit ChangeEvent tap, so it captures every write that goes through the store with no per-handler wiring.
Wiring
// app.config.ts
import { versioningPlugin } from '@voltro/plugin-versioning'
export default {
type: 'api' as const, name: 'api',
plugins: [versioningPlugin({ tables: ['posts', 'invoices'] })],
}Every committed change to a listed table appends a row to _voltro_row_history (tableName, rowId, monotonic version, op, the full data snapshot, changedBy, changedAt).
Querying the timeline
import { rowHistory, rowAsOf } from '@voltro/plugin-versioning'
// Every version of a row, oldest → newest — TENANT-SCOPED to the caller:
const history = await rowHistory(ctx.store, 'posts', postId, ctx.request.subject.tenantId)
// history[n] = { version, op, data, changedBy, changedAt, tenantId }
// The row's value as it was at a past moment (null if it didn't exist / was deleted then):
const lastMonth = await rowAsOf(ctx.store, 'posts', postId, ctx.request.subject.tenantId, new Date('2026-05-01'))Pass the caller's tenantId — reads are tenant-scoped: a row's value timeline is visible only to its own tenant (a null-tenant row from an untenanted source table stays visible to all; an anonymous caller sees only those). rowAsOf returns the latest version at or before the instant; a version that was a delete reads back as null (the row wasn't present then). Pure helpers (selectAsOf, nextVersionNumber, diffSnapshots) are exported + unit-tested. Effect handlers use the twins rowHistoryEffect / rowAsOfEffect (and restoreAsOfEffect / diffVersionsEffect) instead of hand-wrapping.
Restore & diff
import { restoreAsOf, diffVersions } from '@voltro/plugin-versioning'
// Roll the LIVE row back to its state at a past instant (tenant-scoped like
// rowAsOf — no visible state then ⇒ null, nothing written). The restore goes
// through the store, so it is recorded as a NEW version.
const restored = await restoreAsOf(ctx.store, 'posts', postId, ctx.request.subject.tenantId, when)
// Field-level delta between two versions ({ field: { from, to } }), or null
// when either version isn't visible to the caller's tenant (or was pruned).
const delta = await diffVersions(ctx.store, 'posts', postId, ctx.request.subject.tenantId, 1, 3)Retention
_voltro_row_history is append-only — one full-row-JSON version per write — so it grows with write volume. It is bounded by the framework's retention sweep: 365 days by default, tunable via VOLTRO_ROW_HISTORY_TTL_HOURS (raise for longer compliance windows, lower to cap storage; the resolved window is logged at boot). For hot rows, the maxVersionsPerRow option additionally caps the per-row COUNT — after each recorded change, versions older than the newest N are pruned. The TTL bounds age; the cap bounds depth.
Notes
- History is append-only and non-blocking — the recorder runs on the framework-supervised change tap (an
Effectthe runtime forks), so a slow recorder can't block writes, and a record failure surfaces on the tap's typed error channel (logged, not silently swallowed). A concurrent same-row write racing the version numbering is retried (bounded) instead of dropped. For a hard audit guarantee pair it withaudit()+ a transactional write. - It snapshots whatever goes through
ctx.store— out-of-band DB writes (rawpsql) are not seen.
Permissions
store:write (writes the history table) + store:changes:read (the ChangeEvent tap).