useUndoLog

Universal end-user undo + redo over mutations, server-persisted via synthesized inverses.

End-user Ctrl-Z over mutations, server-backed. When undo capture is on, the framework records each mutation's change-set (the CDC {old,new} per row) into _voltro_undo_log — inside the mutation's own transaction, so a rolled-back write leaves no entry. Three built-in rpc procedures expose it, typed end-to-end via codegen:

  • __voltro.undo.log — a reactive, subject-scoped feed of your recent undoable actions (newest first).
  • __voltro.undo.apply / __voltro.undo.redo — undo / redo one invocation by id.

useUndoLog(api) is the client controller:

const undo = useUndoLog('app')
// → { entries, loading, canUndo, canRedo, undo, redo, undoLast, redoLast }

<button disabled={!undo.canUndo} onClick={() => undo.undoLast()}>Undo</button>
<button disabled={!undo.canRedo} onClick={() => undo.redoLast()}>Redo</button>

Undo applies the synthesized inverse as a normal store write — it traverses the same tenant/RBAC guards, is reactive (the affected rows reappear / vanish live, even across tabs), and is audited. It is per-actor (you can't undo another subject's action), refuses on a concurrent-change conflict or past an action boundary (an external side effect), and is idempotent. Because the stack lives on the server, it survives a page reload.

Enabling — VOLTRO_UNDO

Capture has a per-mutation cost (a read-before-write on update/delete + a log-row insert), so it's on outside production, off in production by default; set VOLTRO_UNDO=on|off to override (the same environment-aware default the durable trace persistence uses). When off, the table isn't created, nothing is captured, and the built-ins aren't served or codegen'd.

Boundaries

Captures writes made through ctx.store (the same scope CDC covers) — raw unsafe() SQL writes and the bulk helpers (updateMany / deleteMany / upsert) aren't captured. Undo of an action-crossing invocation is refused (the engine's "can't undo past an external effect" wall).