Exactly-once idempotency key ledger for retried operations, as a Convex component.
const idem = new Idempotency(components.idempotency);
const claim = await idem.begin(ctx, requestId); // mints a claim, or short-circuits a replay
if (claim.state === "done") return claim.result; // replayed — skip the work
// ... run the work ...
await idem.complete(ctx, requestId, result); // record the outcome for next timeRecord an idempotency key with a grace TTL; on a replay short-circuit and return the prior outcome
instead of re-running the work. Domain-neutral: payment intents, webhook deliveries, queue consumers,
double-submit guards — any operation that must run at most once per key.
- Exactly-once per
(scope, key)—beginmints an inflight claim that rides the mutation transaction; a concurrent retry seesinflightwith aretryAfterMsbackoff hint. - Replay — once
completerecords an outcome, a laterbeginreturns{ state: "done", result }for a short-circuit. - Split TTLs — a short inflight lease (default 60s) so a crashed worker's claim self-heals, and a longer done grace (default 24h) after which a key may be re-minted.
- Lost-claim detection —
completereturns{ recorded: true } | { recorded: false, reason }so a host knows when its work finished but the row was gone. Opt intoupsertOnMissing. - Server-sourced time — expiry is read from the server clock; a caller can't supply
now, so an adversarial clock can't force a key to look live or expired. - TTL validation — non-positive or infinite TTLs throw
INVALID_TTLbefore any write. - Typed result —
Idempotency<TResult>types the stored outcome; aresultValidatornarrows it at the boundary. - Scopes — global by default, or namespace per tenant / operation type.
- Bounded purge + cron — a daily cron sweeps expired keys in batches and self-reschedules until clean.
pnpm add @vllnt/convex-idempotencyPeer dependency: convex@^1.41.0.
// convex/convex.config.ts
import { defineApp } from "convex/server";
import idempotency from "@vllnt/convex-idempotency/convex.config";
const app = defineApp();
app.use(idempotency);
export default app;// convex/charge.ts — host owns auth; pass an opaque idempotency key in.
import { components } from "./_generated/api";
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { Idempotency } from "@vllnt/convex-idempotency";
const idem = new Idempotency<{ chargeId: string }>(components.idempotency, {
resultValidator: v.object({ chargeId: v.string() }).parse, // narrow at the boundary
});
export const charge = mutation({
args: { requestId: v.string(), amount: v.number() },
handler: async (ctx, { requestId, amount }) => {
const claim = await idem.begin(ctx, requestId);
if (claim.state === "done") return claim.result; // typed replay
if (claim.state === "inflight")
throw new Error(`retry in ${claim.retryAfterMs}ms`); // backoff hint
const result = { chargeId: await doCharge(amount) }; // state === "fresh"
const done = await idem.complete(ctx, requestId, result);
if (!done.recorded) console.warn("claim lost:", done.reason); // work ran, row gone
return result;
},
});| Method | Kind | Result |
|---|---|---|
begin(ctx, key, opts?) |
mutation | { state: "fresh" } | { state: "inflight"; expiresAt; retryAfterMs } | { state: "done"; result? } |
complete(ctx, key, result?, opts?) |
mutation | { recorded: true } | { recorded: false; reason: "missing" | "expired" | "already_done" } |
get(ctx, key, scope?) |
query | { status, result?, expiresAt } | null |
purge(ctx, opts?) |
mutation | number (keys removed in the first bounded pass) |
Full reference: docs/API.md.
Backend-only — no ./react entry. Pure infra dedup with no user-facing reactive surface.
- Auth-agnostic — the host resolves identity and decides who may run an operation.
- Tables sandboxed — reached only through the exported functions.
- Server-sourced expiry — a skewed client clock can't hijack a replay or bypass dedup;
key/scope/resultstay opaque.
See docs/API.md.
pnpm test # single run
pnpm test:coverage # enforced 100% on covered filesTests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.
See CONTRIBUTING.md.
Built by bntvllnt · bntvllnt.com · X @bntvllnt
Part of the @vllnt Convex component fleet — vllnt.com
If this is useful, sponsor the work.
MIT — see LICENSE.