{"id":51317901,"url":"https://github.com/vllnt/convex-idempotency","last_synced_at":"2026-07-01T09:31:18.023Z","repository":{"id":364821469,"uuid":"1269288412","full_name":"vllnt/convex-idempotency","owner":"vllnt","description":"Exactly-once idempotency key ledger for retried operations, as a Convex component","archived":false,"fork":false,"pushed_at":"2026-06-14T15:46:59.000Z","size":114,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-14T17:23:35.612Z","etag":null,"topics":["convex","convex-component","idempotency","vllnt"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/@vllnt/convex-idempotency","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/vllnt.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null},"funding":{"github":"bntvllnt"}},"created_at":"2026-06-14T14:28:52.000Z","updated_at":"2026-06-14T15:55:21.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/vllnt/convex-idempotency","commit_stats":null,"previous_names":["vllnt/convex-idempotency"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/vllnt/convex-idempotency","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-idempotency","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-idempotency/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-idempotency/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-idempotency/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vllnt","download_url":"https://codeload.github.com/vllnt/convex-idempotency/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vllnt%2Fconvex-idempotency/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35001648,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-01T02:00:05.325Z","response_time":130,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["convex","convex-component","idempotency","vllnt"],"created_at":"2026-07-01T09:31:15.213Z","updated_at":"2026-07-01T09:31:18.013Z","avatar_url":"https://github.com/vllnt.png","language":"TypeScript","funding_links":["https://github.com/sponsors/bntvllnt"],"categories":[],"sub_categories":[],"readme":"\u003c!-- Badges --\u003e\n[![convex-component](https://img.shields.io/badge/convex-component-EE342F.svg)](https://www.convex.dev/components)\n[![npm](https://img.shields.io/npm/v/@vllnt/convex-idempotency.svg)](https://www.npmjs.com/package/@vllnt/convex-idempotency)\n[![CI](https://github.com/vllnt/convex-idempotency/actions/workflows/ci.yml/badge.svg)](https://github.com/vllnt/convex-idempotency/actions/workflows/ci.yml)\n[![license](https://img.shields.io/npm/l/@vllnt/convex-idempotency.svg)](./LICENSE)\n\n# @vllnt/convex-idempotency\n\nExactly-once idempotency key ledger for retried operations, as a Convex component.\n\n```ts\nconst idem = new Idempotency(components.idempotency);\nconst claim = await idem.begin(ctx, requestId);   // mints a claim, or short-circuits a replay\nif (claim.state === \"done\") return claim.result;  // replayed — skip the work\n// ... run the work ...\nawait idem.complete(ctx, requestId, result);      // record the outcome for next time\n```\n\nRecord an idempotency `key` with a grace TTL; on a replay short-circuit and return the prior outcome\ninstead of re-running the work. Domain-neutral: payment intents, webhook deliveries, queue consumers,\ndouble-submit guards — any operation that must run **at most once** per key.\n\n## Features\n\n- **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.\n- **Replay** — once `complete` records an outcome, a later `begin` returns `{ state: \"done\", result }` for a short-circuit.\n- **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.\n- **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`.\n- **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.\n- **TTL validation** — non-positive or infinite TTLs throw `INVALID_TTL` before any write.\n- **Typed result** — `Idempotency\u003cTResult\u003e` types the stored outcome; a `resultValidator` narrows it at the boundary.\n- **Scopes** — global by default, or namespace per tenant / operation type.\n- **Bounded purge + cron** — a daily cron sweeps expired keys in batches and self-reschedules until clean.\n\n## Installation\n\n```bash\npnpm add @vllnt/convex-idempotency\n```\n\nPeer dependency: `convex@^1.41.0`.\n\n## Usage\n\n```ts\n// convex/convex.config.ts\nimport { defineApp } from \"convex/server\";\nimport idempotency from \"@vllnt/convex-idempotency/convex.config\";\n\nconst app = defineApp();\napp.use(idempotency);\nexport default app;\n```\n\n```ts\n// convex/charge.ts — host owns auth; pass an opaque idempotency key in.\nimport { components } from \"./_generated/api\";\nimport { mutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\nimport { Idempotency } from \"@vllnt/convex-idempotency\";\n\nconst idem = new Idempotency\u003c{ chargeId: string }\u003e(components.idempotency, {\n  resultValidator: v.object({ chargeId: v.string() }).parse, // narrow at the boundary\n});\n\nexport const charge = mutation({\n  args: { requestId: v.string(), amount: v.number() },\n  handler: async (ctx, { requestId, amount }) =\u003e {\n    const claim = await idem.begin(ctx, requestId);\n    if (claim.state === \"done\") return claim.result;          // typed replay\n    if (claim.state === \"inflight\")\n      throw new Error(`retry in ${claim.retryAfterMs}ms`);    // backoff hint\n    const result = { chargeId: await doCharge(amount) };      // state === \"fresh\"\n    const done = await idem.complete(ctx, requestId, result);\n    if (!done.recorded) console.warn(\"claim lost:\", done.reason); // work ran, row gone\n    return result;\n  },\n});\n```\n\n## API Reference\n\n| Method | Kind | Result |\n|--------|------|--------|\n| `begin(ctx, key, opts?)` | mutation | `{ state: \"fresh\" } \\| { state: \"inflight\"; expiresAt; retryAfterMs } \\| { state: \"done\"; result? }` |\n| `complete(ctx, key, result?, opts?)` | mutation | `{ recorded: true } \\| { recorded: false; reason: \"missing\" \\| \"expired\" \\| \"already_done\" }` |\n| `get(ctx, key, scope?)` | query | `{ status, result?, expiresAt } \\| null` |\n| `purge(ctx, opts?)` | mutation | `number` (keys removed in the first bounded pass) |\n\nFull reference: [docs/API.md](docs/API.md).\n\n## React\n\nBackend-only — no `./react` entry. Pure infra dedup with no user-facing reactive surface.\n\n## Security\n\n- Auth-agnostic — the host resolves identity and decides who may run an operation.\n- Tables sandboxed — reached only through the exported functions.\n- Server-sourced expiry — a skewed client clock can't hijack a replay or bypass dedup; `key` / `scope` / `result` stay opaque.\n\nSee [docs/API.md](docs/API.md).\n\n## Testing\n\n```bash\npnpm test           # single run\npnpm test:coverage  # enforced 100% on covered files\n```\n\nTests run against the real component runtime via `convex-test` (`@edge-runtime/vm`), not mocks.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Author\n\nBuilt by [bntvllnt](https://github.com/bntvllnt) · [bntvllnt.com](https://bntvllnt.com) · [X @bntvllnt](https://x.com/bntvllnt)\n\nPart of the [@vllnt](https://github.com/vllnt) Convex component fleet — [vllnt.com](https://vllnt.com)\n\nIf this is useful, [sponsor the work](https://github.com/sponsors/bntvllnt).\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvllnt%2Fconvex-idempotency","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvllnt%2Fconvex-idempotency","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvllnt%2Fconvex-idempotency/lists"}