# 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.



---

<!-- source: en/plugins/row-history.md -->
## 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()`](/docs/plugins/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

```ts
// 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.

```ts
rowHistoryPlugin({})                              // every app table
rowHistoryPlugin({ exclude: [domainEvents] })     // opt one out
rowHistoryPlugin({ include: [aiFlowsTable] })     // add a PLUGIN's table
```

Both 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:

```txt
row-history active · tables: 41 · historyTable: _voltro_row_history · retentionDays: 365
```

If 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](/docs/plugins/audit), one row per mutation invocation) answers "what business operation happened, to which entity, and did it succeed";
- `traceId` joins 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](/docs/plugins/audit) 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.

```ts
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](/docs/authentication/strategies) 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

```ts
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.

**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:

```json
{ "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"*:

```json
{ "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

```ts
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

```ts
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 `Effect` the 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 with `audit()` + a transactional write.
- It snapshots whatever goes through `ctx.store` — out-of-band DB writes (raw `psql`) are not seen.

## Permissions

`store:write` (writes the history table) + `store:changes:read` (the ChangeEvent tap).
