Exactly-once idempotency key ledger for Convex mutations: deduplicates retries, replays typed results, and self-heals crashed inflight claims via split TTLs.
npm install @vllnt/convex-idempotency@vllnt/convex-idempotency helps your backend recognize repeated attempts at the same operation. A key identifies the work, an active claim coordinates attempts, and a stored result lets retries return the previous outcome.
begin returns a fresh claim, an inflight state, or a completed result. complete stores a typed outcome and reports lost or expired claims. Configure separate TTLs for active claims and completed results; scopes namespace keys by tenant or operation.
Your application authorizes the operation and must coordinate business writes with the claim lifecycle. This ledger does not make arbitrary external side effects exactly-once: use provider idempotency and recovery logic where needed. Once retention expires, a key may be used again.
Maintained by [bntvllnt](https://bntvllnt.com) at [VLLNT](https://vllnt.com). Find the author on [GitHub](https://github.com/bntvllnt) and [X](https://x.com/bntvllnt).
The @vllnt/convex-idempotency component provides a begin/complete ledger inside Convex mutations. Call idem.begin(ctx, requestId) at the start of a charge mutation: if the key already completed, it returns the prior result immediately without re-running doCharge. If a concurrent retry arrives while the first is inflight, begin returns an inflight state with a retryAfterMs backoff hint.
@vllnt/convex-idempotency lets you record a delivery key when a webhook mutation fires, so a re-delivered event short-circuits before any state mutation runs. Pass the webhook provider's delivery ID as the key, call idem.begin, and return claim.result early if state is done. The done grace TTL defaults to 24 hours, covering typical provider retry windows.
Use @vllnt/convex-idempotency inside a Convex mutation that processes queue messages by passing the message ID as the idempotency key. The component mints an inflight claim within the mutation transaction, so a concurrent retry from the same message ID is blocked until the lease expires or the claim completes. A 60-second inflight lease means a crashed worker's slot self-heals without manual intervention.
Generate a client-side requestId when a form is submitted and pass it to a Convex mutation that calls idem.begin(ctx, requestId). If the user submits twice before the first response returns, the second mutation call hits the inflight state and receives a retryAfterMs hint rather than executing the write again. Once complete is called, any further replay returns the typed original result.
@vllnt/convex-idempotency is designed for use inside Convex mutations. The begin and complete methods are mutation-level calls that participate in the surrounding transaction, which is what makes the inflight claim atomic. The get method is a query. There is no React or client-side entry point; it is a pure backend infrastructure component.
When idem.begin is called, the component records an inflight claim with an expiry derived from the server clock using a configurable inflight TTL that defaults to 60 seconds. If the worker crashes before calling idem.complete, the claim expires and a subsequent call to idem.begin for the same key mints a fresh claim rather than returning inflight. This self-healing behavior requires no manual intervention.
idem.complete returns a discriminated union: either { recorded: true } on success, or { recorded: false, reason: 'missing' | 'expired' | 'already_done' } when the row could not be updated. This lets the host know the work executed but the idempotency record was not written, so it can log a warning or opt into upsertOnMissing to write the outcome anyway.
No. @vllnt/convex-idempotency reads expiry from the Convex server clock internally. Callers cannot pass a now argument, so a client with a skewed or adversarial clock cannot manipulate whether a claim appears inflight, expired, or done. This is described in the component documentation as server-sourced expiry.
@vllnt/convex-idempotency uses Convex component sandboxed tables, meaning the ledger tables are only accessible through the exported component functions and are not visible to the host app's raw database queries. Keys are global by default but can be namespaced with an explicit scope string to separate tenants or operation types without collisions.