History (time-travel)
Read project state at any past version with ctx.history.
// valet/order-audit.ts
import { defineAction, v } from './_generated/valet/api'
export const orderAtHistory = defineAction({
args: { orderId: v.string(), atVersion: v.number() },
handler: async (ctx, args) => {
const past = ctx.history.at({ atVersion: args.atVersion })
return past.orders.get(args.orderId)
},
})ctx.history exposes read-only views of project state at any past
database version. The substrate is the per-mutation row payloads stored
in _valet_history, written transactionally by the same trigger that
records each change. Time-travel reads walk backward from current state,
so reading a row at version V is a single index seek.
API
ctx.history.currentVersion()
Returns the current db_version. Useful for capturing "now" before
doing further work, then time-travelling back to that exact moment
later.
const before = ctx.history.currentVersion()
await ctx.db.patch(orderId, { status: 'shipped' })
const past = ctx.history.at({ atVersion: before })
const wasOpen = past.orders.get(orderId).status === 'open'ctx.history.versionAtTimestamp(at)
Translates an epoch-millisecond timestamp (or a Date) to the most
recent version whose history entry is at or before that moment. Returns
null if nothing was written at or before the timestamp.
const yesterday = ctx.history.versionAtTimestamp(Date.now() - 86400000)
if (yesterday !== null) {
const past = ctx.history.at({ atVersion: yesterday })
// ...
}ctx.history.at(opts)
Returns a read-only handle over project state at the requested version.
Accepts either { atVersion } or { atTimestamp } (resolved through
versionAtTimestamp internally). Reading a row that did not exist at
that version, or had been deleted by then, returns null.
const past = ctx.history.at({ atVersion: 12345 })
// Single row:
const order = past.orders.get('orders_42')
// Whole table snapshot:
const allOrders = past.orders.collect()The handle exposes one method per accessor:
.<table>.get(id)— returns the row's state, ornull..<table>.collect()— returns every row in the table at that version as an array (in row-id order).
The handle is read-only. Writes through it throw ReadOnlyHistoryView.
Examples
History scrubber
export const orderHistory = defineAction({
args: { orderId: v.string(), versions: v.array(v.number()) },
handler: async (ctx, args) => {
return args.versions.map((version) => ({
version,
row: ctx.history.at({ atVersion: version }).orders.get(args.orderId),
}))
},
})"What changed in the last hour?"
export const recentChanges = defineAction({
args: {},
handler: async (ctx) => {
const oneHourAgo = ctx.history.versionAtTimestamp(Date.now() - 3600 * 1000)
if (oneHourAgo === null) return { rows: [] }
const before = ctx.history.at({ atVersion: oneHourAgo })
const now = ctx.history.at({ atVersion: ctx.history.currentVersion() })
const beforeIds = new Set(before.orders.collect().map((r) => r._id))
const nowIds = new Set(now.orders.collect().map((r) => r._id))
const added = [...nowIds].filter((id) => !beforeIds.has(id))
const removed = [...beforeIds].filter((id) => !nowIds.has(id))
return { added, removed }
},
})Customer support: "what did this order look like at 3 PM yesterday?"
const yesterdayAt3 = new Date()
yesterdayAt3.setDate(yesterdayAt3.getDate() - 1)
yesterdayAt3.setHours(15, 0, 0, 0)
const past = ctx.history.at({ atTimestamp: yesterdayAt3 })
const order = past.orders.get(orderId)Constraints
- Retention. History rows live in
_valet_historyand grow with every mutation. The retention floor is 7 days by default; older entries can be pruned viacleanup_history. Asking for a version whose history has been pruned returnsnullrather than an error — usehistoryEarliestVersionto detect the boundary if it matters. - Cost. Every mutation now writes a full row JSON snapshot into
_valet_history, roughly doubling the per-mutation write cost vs. pre-LLP-0043 (which only wrote the_valet_changesrow-level marker). For most workloads this is invisible; for write-heavy multi-tenant deployments, plan retention accordingly. collect()is unbounded.at(...).table.collect()materializes every row in the table at the requested version and parses each one's JSON in memory. Safe for small tables; expensive for large historical projects. Use the paginated form (materialize_table_at_version_limitedon the Rust side) or pre-filter with explicitget(id)calls when the table is large.- Schema migrations.
ctx.history.at(V)returns the row as it existed at versionV, including the columns present at that version. Columns added later returnundefinedfor older rows. - Auth and RLS. Time-travelled views apply current auth/RLS to historical data. That's the safer default. Compliance scenarios that need "the policies that were in force then" are not covered.
- Sync rules do not apply. Server-side handler reads through
ctx.history.at(...)are not filtered by per-table sync rules the way client subscriptions are. Treatctx.historylikectx.db— any handler that exposes its return value to a client effectively exposes the underlying data. Apply per-handler authorization checks accordingly. - Deleted data is still readable. Time-travel exposes rows that
were deleted within the retention window. If you've committed to a
hard delete contract (e.g., GDPR right-to-erasure), pair the
delete with
cleanup_historyfor the affected(table, row_id)span, or shorten retention for the relevant tables. - Encrypted columns (LLP 0045, when shipped) return ciphertext from the time of the original write; decryptability depends on whether the reader still holds the relevant keys.
- Blobs. BLOB columns are stored hex-encoded in the history JSON.
Reading them back through
ctx.history.at(...).table.get(id)yields a hex string; decode to bytes if needed.
Mutation dry-run with ctx.simulate.run
Action and job handlers also expose ctx.simulate.run(fn), which runs
a callback inside a transaction, captures the per-row deltas it would
have applied, and rolls back. Useful for preview UIs ("here's what will
happen if you click Confirm"), tests that don't need fixture databases,
and undo flows.
export const previewBulkClose = defineAction({
args: { todoIds: v.array(v.string()) },
handler: async (ctx, args) => {
const preview = await ctx.simulate.run(async (db) => {
for (const id of args.todoIds) {
await db.patch(id, { completed: true })
}
})
return {
wouldUpdate: preview.deltas.length,
sample: preview.deltas.slice(0, 3),
}
},
})Each delta has shape { dbVersion, table, rowId, operation, before, after }
where operation is 'insert' | 'update' | 'delete'. Inserts have
before: null; deletes have after: null.
If the callback throws, simulate.run rolls back, captures whatever
deltas were applied before the throw, and returns the error message
under result.error. The transaction is always rolled back; deltas
are captured before the rollback while the transaction's state is
still observable through the history table.
Side effects that escape the database — ctx.fetch, ctx.email.enqueue,
ctx.push.enqueue — are also rolled back on the transactional outbox.
The simulation will not deliver email, push, or HTTP requests.
Client mutation simulation
client.simulate(api.mutations.x, args) uses the server rollback path when
the client is connected. If the client is offline and was configured with a
SyncEngine plus generated local handlers, the SDK runs the same local handler
path used by offline mutations inside a rolled-back SQLite transaction.
The offline preview returns the same { committed, deltas, result, error }
shape and does not queue a pending mutation. Local preview deltas use
dbVersion: 0 because the local replica does not own the authoritative
mutation-log version. If the client is offline without a SyncEngine,
client.simulate still rejects with the existing "not connected" error.
Audit caveat.
simulate.runrolls back the transaction, so_valet_historywill not record anything the callback did. If your compliance posture depends on auditing every privileged operation (including dry-runs), log the call tosimulate.runitself from your handler — the rollback by design leaves no_valet_historytrail.
Sequence-counter drift.
simulate.runrolls back the database transaction but not in-memory handler state. If your callback callsctx.email.enqueue(orctx.push.enqueue) without an explicitdeliveryKey, the auto-generated key advances the handler's internal sequence counter, so a realctx.email.enqueuecall aftersimulate.runreturns gets a different sequence number than it would have without the simulation. Pass an explicitdeliveryKeyin any handler that mixes simulation with real outbox writes if you need deterministic keys.
Nesting limit.
simulate.runopens a transaction internally, and SQLite does not allow nestedBEGINs. You cannot callsimulate.runinsidectx.db.transaction, and the callback passed tosimulate.runcannot itself callctx.db.transaction. Both nestings throw "Nested transactions are not supported" loudly.
Related
- LLP 0043: Time-travel and mutation dry-run (the design).
- The by-name mutation simulation forms
(
ctx.simulate(api.mutations.x, args)andclient.simulate(api.mutations.x, args)) run an existing registered mutation rather than an inline callback.