PRO

Convex Exactly-Once Action

The most expensive mistake on Convex: mutations are serializable OCC transactions, but actions are not transactions at all and the docs are explicit that a scheduled action executes at most once and is never retried, so the ordinary shape of calling Stripe from an action and writing the result afterwards charges the customer twice whenever the process dies in between. This splits the work the only way that survives it: a mutation reserves an attempt row and schedules the action in the same transaction (ctx.scheduler.runAfter inside a mutation commits with the write or not at all, which is a transactional outbox for free), the action is the sole place a fetch may happen, and a fenced settling mutation commits the outcome exactly once. The provider idempotency key is minted from the attempt document id that Convex assigns transactionally, never from Math.random(), which returns a new value on every OCC re-execution and is frozen at deploy time when called at module scope; every retry therefore sends the provider the same key. Includes attempt-number fencing so a stalled action from a superseded attempt cannot overwrite a terminal outcome, permanent-versus-transient classification so a declined card is not retried forever, and a cron sweeper that retires exhausted rows instead of rescanning them until its index range is all garbage. All decision logic is pure and ships with an eight-property demo that runs under bun with no deployment. Pinned to [email protected].

Install
npx shadcn@latest add https://ui.aryank.space/r/convex-exactly-once-action.json

Installs from ui.aryank.space. To add it by hand, copy the files in Files below, or register the @compronents namespace via the docs.

Visualization
reservation
action run 1
string
key sent to Stripe
none
manual retry
"Idempotency-Key": crypto.randomUUID() // minted per action run

The key is minted inside the action. Convex schedules an action at most once and never retries it, so a lost process means you retry by hand, and the second run mints a fresh key. Stripe sees two different keys, so it has nothing to dedupe on and creates a second charge. Watch the cell flash red: that is a real $49.99 the customer did not authorise.

Files

Dependencies