This project uses Convex as its backend.
When working on Convex code, always read example/convex/_generated/ai/guidelines.md first for
important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that
override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running npx convex ai-files install.
Exactly-once idempotency key ledger for retried operations, as a Convex component. It follows the
vllnt Component Standard (see the convex-components hub .claude/rules/component-standard.md).
src/
├── shared.ts # constants: component name, default scope, TTLs, batch size
├── test.ts # convex-test register() helper
├── client/
│ ├── index.ts # Idempotency<TResult> class (consumer-facing API)
│ └── types.ts # public TypeScript interfaces
└── component/
├── schema.ts # sandboxed table: keys {key, scope, status, result?, expiresAt}
├── convex.config.ts # defineComponent("idempotency")
├── mutations.ts # begin, complete, purge
├── queries.ts # get
├── validators.ts # shared validators (beginResult, completeResult, jsonValue)
└── crons.ts # daily purge cron (self-rescheduling)
Sandboxed table: keys — unique per (scope, key), indexed by_scope_key (lookup) and
by_expires (sweep). No host tables are touched. The stored result is opaque to the component;
the host narrows it via a resultValidator at the client boundary.
Component owns:
- The dedup ledger (
keystable) — mint, expire, purge - Server-sourced time —
Date.now()inside every handler; no caller-suppliednow - TTL validation —
inflightTtlMs/doneTtlMsmust be positive finite numbers - Lifecycle:
absent → inflight → done → expired → re-minted - The discriminated return shapes (
BeginResult,CompleteResult) - The daily purge cron and
purgemutation
Host owns:
- The operation being deduped and its domain meaning
- Auth and authorization — whether a caller may use a given key/scope
- The stored
resulttype (TResult) — opaque to the component, narrowed byresultValidator - Interpreting
{ state: "inflight" }(backoff) and{ recorded: false }(lost claim)
Auth: the component is completely auth-agnostic. The host resolves identity, decides access, and
passes an opaque key. scope provides namespacing per tenant/operation-type; both are opaque strings.
-
Expiry-before-done in
begin(intentional asymmetry):beginchecks expiry before done-state. An expired done key is therefore re-mintedfresh— the grace window has elapsed and the operation may safely re-run. This is the correct recovery path: the done grace exists precisely to replay within the window; outside it the key behaves as never-seen. -
Done-before-expiry in
complete(intentional asymmetry):completechecks done-state before expiry. An expired done key returnsalready_done, notexpired. A late attempt must never overwrite a prior winner's recorded outcome — theexpiredreason is reserved for inflight keys whose lease lapsed before completion. -
Server-sourced time: every handler calls
Date.now()internally; no API surface accepts a caller-suppliednow. A hostile or skewed client clock cannot force a key to look live (hijack a replay) or expired (bypass dedup). -
INVALID_TTL guard:
inflightTtlMsanddoneTtlMsmust be positive finite numbers. Passing0, a negative, orInfinitythrowsConvexError({ code: "INVALID_TTL" })before any write. A zero/negative TTL would produceexpiresAt ≤ now, immediately expiring the claim. -
Row re-mint in place: an expired key is patched (status reset, result cleared, new
expiresAt) rather than deleted and re-inserted, preserving the row_idacross the re-mint. -
Replay without re-execution:
beginreturning{ state: "done", result }short-circuits the caller; the result is the stored validated value — the work is never re-executed. -
Bounded purge + self-reschedule:
purgeremoves up tobatchexpired keys (default 200) per pass and self-reschedules viactx.schedulerwhen a full batch was removed, running until the tail is clean. Idempotent and safe to call anytime. The built-in daily cron drives it automatically. -
Backend-only (no
./reactentry): pure infra dedup — no user-facing reactive surface. No hooks or client components shipped. This was an explicit analysis decision (see README).
- Mutations in
mutations.ts, queries inqueries.ts(enforced by@vllnt/eslint-config/convex). - Explicit
args+returnson every Convex function. - Host data via typed generics /
resultValidator— neverv.any()dumps;jsonValueis the documented last resort for the stored opaque result. - 100% test coverage is BLOCKING (
vitest.config.mtsthresholds: statements, branches, functions, lines). - Runtime deps: only official
@convex-dev/*+@vllnt/*.
| Changed | Update in the same commit |
|---|---|
| Public API (begin/complete/get/purge signatures) | README API Reference table, docs/API.md, llms.txt context, regenerate llms-full.txt |
| Config options / defaults | README API Reference, docs/API.md constructor section |
| Schema / table / indexes | README Architecture, docs/API.md |
| Error codes | docs/API.md → ## Error codes table |
peerDependencies.convex version |
llms.txt context line (convex@^X.Y.Z), docs/API.md Compatibility line, README Installation peer note |
| Lifecycle / expiry asymmetry | docs/API.md begin/complete sections, Key design decisions above |
| Any change | pnpm generate:llms to keep llms-full.txt current |
Grep old values before committing (e.g. git grep "1.36.1" → must be empty).