Row history
Full row history + time-travel. audit() records who/when; row-history records what-changed-to-what — a value snapshot of every row on every write, with as-of queries.
@voltro/plugin-row-history keeps a complete value history of selected tables. Where audit() records who changed a row and when, row-history 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 { rowHistoryPlugin } from '@voltro/plugin-row-history'
export default {
type: 'api' as const, name: 'api',
plugins: [rowHistoryPlugin({})],
}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, traceId, subjectId, procedure).
The history row's own id is derived from (tableName, rowId, version) and has a fixed width — it is a surrogate, and every part of it is already a column beside it, so do not parse or construct it. That width is the point: an id() column is VARCHAR(64) on mysql/mariadb and NVARCHAR(64) on mssql, so a key built by concatenating those parts grew with your table name and stopped fitting past 22 characters — which failed every write to that table, not merely an import.
What gets recorded — opt OUT, not in
rowHistoryPlugin({}) covers every table your app declares. There is no list to write and none to maintain.
rowHistoryPlugin({}) // every app table
rowHistoryPlugin({ exclude: [domainEvents] }) // opt one out
rowHistoryPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's tableBoth take table values, not names — a misspelling is a compile error at the call site, exactly as with reference(() => table).
That shape replaced a tables: string[] list, and both of its failure modes were silent: you listed six tables, forgot the seventh, and nothing ever told you its history was missing; and nothing cross-checked the strings, so 'invoces' recorded nothing forever while the plugin reported itself active at boot. Forgetting is now the safe direction.
Framework- and plugin-owned tables (_voltro_*, cluster_*) are OUT by default. There are 34 of them, and the busiest — the CDC log, the event log, the undo log, workflow events, webhook rate windows — are append-only. A full row snapshot per write there is the history of a history, at the highest write rate in the system. Name one in include to version it anyway; that also works for a plugin table your app never declares, which is the supported way to version one.
A table named in both include and exclude throws at construction — only you know which was the mistake.
Check the boot line once after upgrading. It prints the RESOLVED count, not the configured one:
row-history active · tables: 41 · historyTable: _voltro_row_history · retentionDays: 365If 41 surprises you, exclude is the knob. The retention sweep (VOLTRO_ROW_HISTORY_TTL_HOURS) still bounds age.
What this is NOT — the grain
Row history records row changes, not domain events. One entry per row per write, named by table. If your product has a user-facing audit feature whose entries are named after an aggregate root — one Team event for a call that writes teams + roles + userTeams + userTeamRoles — this is the layer underneath that, not a replacement for it.
The distinction is worth reading before you plan a migration onto it. A migration off hundreds of hand-written audit calls onto this tap runs into the same wall a few hours in: the grain is different. A table-keyed tap does not produce an aggregate-keyed trail with better coverage, it produces a different artifact. The two compose:
- row history answers "what did row R look like before, and after" — for every write, whether or not anyone remembered to record it;
- an aggregate trail (the audit sink, one row per mutation invocation) answers "what business operation happened, to which entity, and did it succeed";
traceIdjoins them, so one request reads as one story.
What the tap does remove is the silent-data-loss failure mode. A forgotten hand-written call used to mean the change was recorded nowhere; with the tap it costs the aggregate name, not the record.
procedure — which call, not just which trace
A row diff carries no intent. The same DELETE on a join table is a member being
removed, a team being deleted, or a membership expiring — and before/after
cannot separate them, because the difference is not in the data.
So the history row records the rpc tag of the call that caused it:
tableName userTeams
rowId ut_7f2…
op delete
procedure teams.removeSubTeamMember ← which call it WAS
traceId 4bf92f35… ← which call it was
subjectId usr_anna
traceId joins this row to everything else that happened in the same request;
procedure says what the request was trying to do. A UI rendering history for a
human needs the second one, and no diff can supply it.
Absent for a write with no procedure behind it — a seed, a *.startup.tsx, a
migration — with the same meaning as an absent traceId.
The correlation bridge — joining what changed to who called
ChangeEvent carries the calling traceId and subjectId, so a history row can be joined to the audit sink row for the same call. Before this, both trails existed and shipped and nothing connected them: row-history knew what changed, the audit sink knew who called and whether they were refused, and no key spanned the two.
import { historyByTrace, historyBySubject } from '@voltro/plugin-row-history'
// What did this call change? (`byTrace`)
const touched = await historyByTrace(ctx.store, traceId, ctx.request.subject.tenantId)
// What has this actor changed, most recent first? (`bySubject`, bounded)
const byActor = await historyBySubject(ctx.store, actorId, ctx.request.subject.tenantId, 100)Both are tenant-scoped like rowHistory — pass the caller's tenantId, and undefined skips the filter for system/admin paths only. historyBySubject takes a limit (default 100) because an actor's history is unbounded, and an entry point that returns all of it is one you call once in production and never again. Effect handlers use historyByTraceEffect / historyBySubjectEffect.
Both questions were previously unanswerable at any speed — byRow is the only other index, and entering through it means already knowing which row you are asking about, which is the wrong way round during an incident.
subjectId is the CALLER, and it is not the same claim as changedBy. changedBy falls back to the row's own audit() stamp, which is a proxy with three failure modes: it only exists on tables carrying audit(); it names the actor but never the call, so two writes by one person a second apart are indistinguishable; and it is null for every write through AuthStrategyInput.store / PluginHttpRouteRequest.store, since audit stamping is part of the Subject-dependent half the boot store omits. A login route writes through exactly that seam. subjectId has none of the three, so it is preferred and the stamp is the fallback.
Absent means the write had no request behind it — a seed, a *.startup.tsx, a schedule, a workflow step — or that it arrived from another replica, where stamping the local ambient trace would attribute a remote write to a local call. Treat "neither field" as system, unambiguously.
timing — when the history row is written
rowHistoryPlugin({ timing: 'in-transaction' })'post-commit' (default) |
'in-transaction' |
|
|---|---|---|
| When | after the domain write commits | inside the same transaction |
| Can lose an entry | yes — a crash in the window between COMMIT and the write | no |
| Can fail your mutation | no | yes, if the history insert fails |
| Cost | none on the write path | two round-trips + longer lock hold, per covered write |
Two round-trips, not one. Each covered write reads MAX(version) for the row and then appends. The read shipped as an omission — the recorder wrote a constant version: 0 and a design note argued that ordering could come from changedAt. It could not: changedAt is millisecond-resolution, so two writes to one row inside one transaction tie routinely, and rowAsOf / diffVersions read the NUMBER. A trail that is merely late can be reasoned about; a mis-ordered one cannot. Budget accordingly — a mutation writing 3 covered rows pays ~6 round-trips of history overhead, and a bulk update over N rows pays 2N, as lock-hold time.
Version numbers are 1-based in both timings, so switching timing does not shift them.
What 'in-transaction' promises: if the change committed, the entry is there. Post-commit cannot promise that — between COMMIT and the forked write there is a window, and a process that dies inside it leaves the change permanent and the trail silent. You then cannot tell "no entry because nothing happened" from "no entry because we crashed", which is what makes a lossy trail useless as evidence. Retrying does not help: the process that would retry is the one that died.
The price is not optional. That guarantee is only obtainable by being willing to REFUSE. When the history insert fails — disk, lock timeout, constraint, dropped connection — a transaction offers exactly two outcomes: the mutation fails with it, or the error is swallowed and the change commits without its entry, which is post-commit's hole with the cost already paid. There is no third option, so a rare, explained rejection is the shape of the guarantee rather than a defect.
The refusal takes the row with it. When the trail's insert fails, the write it covers is rolled back — including a write made OUTSIDE any transaction of your own. That has not always been true: the row's statement committed on its own and the trail ran as a second statement afterwards, so a failing trail left a committed row behind a write that reported failure. Anything that retried that write then met its own row and reported a duplicate key for a row nobody wrote twice. A table with recorders is written inside a transaction now, on every SQL dialect, so "the mutation fails with it" means what it says.
Post-commit records once fleet-wide, not once per replica. The change tap it
rides is delivered to EVERY replica — that is what makes a changeScope: 'fleet'
store (postgres LISTEN/NOTIFY, mysql binlog) cross-instance in the first place —
and it used to record on each of them. It did not surface as a conflict either:
versions are numbered MAX(version) + 1, so two replicas both computed version 1,
one won the primary key, and the loser's retry re-read MAX, got 2, and appended a
second entry. Three replicas produced versions 1, 2, 3 for one change — not
merely doubled, mis-ordered, and selectAsOf reads version. Each change is now
claimed fleet-wide before it is recorded, keyed on the change rather than the row.
Nothing to configure; 'in-transaction' never had this.
Why the default is still 'post-commit'. In-transaction makes _voltro_row_history a hard dependency of every write path it covers: its availability becomes your write path's availability, and every covered write holds its locks longer. Post-commit loses at worst one entry; in-transaction can, at worst, stop writes to the covered tables entirely. For a compliance trail the second trade is the right one — for the undo / time-travel use this plugin also serves, it is not.
Under CDC, and inside a transaction
Both work, and both took a fix. The event a subscriber receives under
changeStrategy: 'cdc' (the default on postgres and mariadb) is rebuilt from a
NOTIFY payload or a binlog row image, neither of which can carry a request
context — the write path now hands its identity across that boundary explicitly.
A write made on ANOTHER replica has no local identity to hand over and arrives
with neither field, which is the correct answer: absent means no request
context on this replica, not nobody knows.
Inside a transaction the identity used to be lost outright, which mattered more than it sounds: framework mutations are auto-transactional, so that was every handler write.
Ordering, when you join to the audit sink
With 'in-transaction' the version rows commit before the rpc interceptor records the call's outcome — the audit row is what says whether the call succeeded, so it can only be written once that is known. A reader joining on traceId may therefore briefly see version rows with no audit-sink row.
That is the correct order, not a race to engineer around: the change is durable, and the verdict on it arrives a moment later. Read the audit row as the authority on outcome, never as proof that a change happened.
Two limits that hold in BOTH timings
store.raw()is absent from the trail. The framework does not parse hand-written SQL, so a raw write produces no change event and no history row. Enabling'in-transaction'does not make coverage total.- A write made outside a transaction is recorded immediately after, not atomically. A bare
ctx.store.updateMany(...)is not in a transaction; framework mutations are auto-transactional, so a handler's writes do get the guarantee.
'in-transaction' requires a SQL store and refuses the in-memory one at boot rather than silently doing nothing — memory is the default dev store, and an option that appears to work where it is cheapest to try and stops working where it matters is worse than one that says so.
What a snapshot contains — .encrypted() vs .serverOnly()
.encrypted() columns are kept, and they are ciphertext. A snapshot stores exactly what the source column stores:
{ "id": "sess_1", "secret": "enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:3AtMwP" }So keeping history for a table with encrypted columns does not widen exposure — the history is exactly as readable as the row it came from. This is worth stating because "full row snapshot" reads alarming next to .encrypted(), and the cautious reader excludes the table. One did, and only found out by measuring.
.serverOnly() columns ARE withheld, and for a sharper reason than "a second copy": crud.* strips those columns from every row it returns, and a snapshot would smuggle the value back past that stripping inside a json() blob, where no column-level rule applies. A marker meaning never serialize this to a client cannot survive being re-exported through a different column's contents.
The withheld names are listed under data._omitted, so a reader can tell "this column was withheld" from "this column did not exist then":
{ "id": "sess_1", "label": "mac", "_omitted": ["tokenHash"] }.sensitive() is not involved either way — it is an export-masking marker for values that are legitimately readable in the app.
Querying the timeline
import { rowHistory, rowAsOf } from '@voltro/plugin-row-history'
// 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-row-history'
// 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).