Compatibility: convex@^1.41.0
Construct the client with the mounted component and optional config:
import { Idempotency } from "@vllnt/convex-idempotency";
import { v } from "convex/values";
const idem = new Idempotency<MyResult>(components.idempotency, {
defaultScope: "global", // namespace applied when a call omits `scope`
defaultInflightTtlMs: 60_000, // inflight lease (ms) when `begin` omits `inflightTtlMs` (60s)
defaultDoneTtlMs: 86_400_000, // done grace (ms) when `complete` omits `doneTtlMs` (24h)
upsertOnMissing: false, // record finished work even if the claim row vanished
resultValidator: v.object({ id: v.string() }).parse, // narrow the opaque result at the boundary
});Idempotency<TResult = unknown> is generic over the host's stored outcome type.
All methods take the host ctx (a query or mutation context) as the first
argument.
Time is server-sourced. Every handler reads Date.now() itself; no method
accepts a caller-supplied now, so a skewed or hostile client clock cannot make
a key look live or expired.
Result validation. When resultValidator is set it runs at the client
boundary: over the value written by complete (before storage) and over the
value returned by begin (done replay) and get. It must return a typed
TResult or throw. Omit it to leave results unvalidated.
opts: { scope?: string; inflightTtlMs?: number }.
Claim key for an operation. The discriminated result is:
{ state: "fresh" }— the key was just minted (or a prior claim had expired and was re-minted in place); the caller should perform the work and then callcomplete.{ state: "inflight"; expiresAt; retryAfterMs }— another attempt holds an unexpired claim; do not re-run.expiresAtis when the lease frees andretryAfterMsis the backoff before retrying.{ state: "done"; result? }— a prior attempt already completed;resultcarries the recorded outcome (validated) for a short-circuit replay.
Mints an inflight key with expiresAt = now + inflightTtlMs. An expired key (any
status) is re-minted fresh by patching the existing row in place (the id is
reused, the stale result cleared).
Expiry check order: begin checks expiry before the done-state. An
expired done key is therefore re-minted fresh — the grace window has elapsed and
the operation may safely re-run.
inflightTtlMs must be a positive finite number. Passing 0, a negative
value, or Infinity throws ConvexError({ code: "INVALID_TTL" }). A zero or
negative TTL would produce expiresAt ≤ now, immediately expiring the claim.
opts: { scope?: string; doneTtlMs?: number; upsertOnMissing?: boolean }.
Mark key done, recording result and extending the grace window
(expiresAt = now + doneTtlMs). The discriminated result lets a host detect a
lost claim:
{ recorded: true }— the outcome was written.{ recorded: false; reason }— the claim was lost;reasonismissing(no key),expired(the inflight lease lapsed before completion), oralready_done(a prior attempt won).
When upsertOnMissing is set, a missing/expired key is written as done
anyway — recording finished work rather than dropping it (returns
{ recorded: true }).
Expiry check order: complete checks the done-state before expiry. An
expired done key therefore returns already_done, not expired — a prior
winner's recorded outcome must not be overwritten by a late attempt, even after
the grace window has lapsed. The expired reason applies only to inflight keys
whose lease lapsed before completion.
doneTtlMs must be a positive finite number. Passing 0, a negative value,
or Infinity throws ConvexError({ code: "INVALID_TTL" }).
opts: { before?: number; batch?: number } (defaults: before = Date.now(),
batch = 200).
Delete up to batch keys whose expiresAt < before, oldest first via the
by_expires index, and return the count removed in the first pass. If a full
batch was removed the sweep self-reschedules through the component scheduler until
the expired tail is clean. Idempotent — safe to run anytime. A built-in daily cron
(crons.ts) drives this automatically; call purge directly only for an extra or
custom-cadence sweep.
The stored state for key in scope, or null if no key is held. status is
inflight or done; result is present only when done (validated); expiresAt
is the absolute ms timestamp after which the key may be re-minted.