# Idempotency Exactly-once idempotency key ledger for retried operations, as a Bijection component - Package: @bijectionhq/idempotency - Category: Durable Functions - Status: Reference - License: MIT - Bijection source (repository access required): https://github.com/bijectionhq/idempotency/tree/e2c5bb07c1b11ecd39c7156015b1934e8c789944 - Upstream: https://github.com/vllnt/convex-idempotency/tree/7be2643b52fc390eebcedfbed85f3915b548b37b - Documentation: https://docs.bijection.com/components/using - Directory: https://bijection.com/components/idempotency ## Get the source Bijection repository access is required. Request access at https://bijection.com/contact. ```sh git clone https://github.com/bijectionhq/idempotency.git cd idempotency git checkout e2c5bb07c1b11ecd39c7156015b1934e8c789944 ``` ## Before using this component Read the package README at the recorded revision for setup, dependencies, and API examples. Upstream documentation describes the original package and may differ from the Bijection adaptation. This is a source candidate for evaluation. Catalog inclusion does not establish npm availability or runtime qualification. Check compatibility with your SDK and deployment before adoption. External providers run their own compute. ## Upstream package documentation Recorded 2026-10-23. This describes @vllnt/convex-idempotency; check the Bijection adaptation separately. @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). ### Use cases - Webhook handlers: recognize repeated provider delivery IDs before applying a change. - Mobile apps: replay a saved response when a client retries after losing its connection. - Forms and checkout flows: protect host-side mutations from duplicate submissions. - Agent tools and integrations: reuse an operation key when a caller retries an uncertain request. ### How it works 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. ### Upstream README # @vllnt/convex-idempotency Exactly-once idempotency key ledger for retried operations, as a Convex component. ```ts 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 time ``` Record 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. ## Features - **Exactly-once** per `(scope, key)` — `begin` mints an inflight claim that rides the mutation transaction; a concurrent retry sees `inflight` with a `retryAfterMs` backoff hint. - **Replay** — once `complete` records an outcome, a later `begin` returns `{ 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** — `complete` returns `{ recorded: true } | { recorded: false, reason }` so a host knows when its work finished but the row was gone. Opt into `upsertOnMissing`. - **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_TTL` before any write. - **Typed result** — `Idempotency` types the stored outcome; a `resultValidator` narrows 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. ## Installation ```bash pnpm add @vllnt/convex-idempotency ``` Peer dependency: `convex@^1.41.0`. ## Usage ```ts // 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; ``` ```ts // 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; }, }); ``` ## API Reference | 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](docs/API.md). ## React Backend-only — no `./react` entry. Pure infra dedup with no user-facing reactive surface. ## Security - 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` / `result` stay opaque. See [docs/API.md](docs/API.md). ## Testing ```bash pnpm test # single run pnpm test:coverage # enforced 100% on covered files ``` Tests run against the real component runtime via `convex-test` (`@edge-runtime/vm`), not mocks. ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). ## Author Built by [bntvllnt](https://github.com/bntvllnt) · [bntvllnt.com](https://bntvllnt.com) · [X @bntvllnt](https://x.com/bntvllnt) Part of the [@vllnt](https://github.com/vllnt) Convex component fleet — [vllnt.com](https://vllnt.com) If this is useful, [sponsor the work](https://github.com/sponsors/bntvllnt). ## License MIT — see [LICENSE](LICENSE).