@studnicky/idempotency-guard
Idempotency key guard composing
@studnicky/cache,@studnicky/concurrency, and@studnicky/json.
Install
pnpm add @studnicky/idempotency-guardUsage
IdempotencyGuard#run(key, payload, factory) fingerprints payload via Hash.value() and checks the composed LruCache for an entry under key. A matching fingerprint replays the cached result; a mismatched fingerprint throws IdempotencyConflictError before factory runs; no entry runs the call through the composed Coalesce so concurrent callers sharing the key share one execution:
import { IdempotencyConflictError, IdempotencyGuard } from '../src/index.js';
class TelemetryIdempotencyGuard extends IdempotencyGuard {
readonly events: string[] = [];
static tracked(): TelemetryIdempotencyGuard {
return new TelemetryIdempotencyGuard({ 'capacity': 1000, 'ttlMs': 60_000 });
}
protected override onReplay(key: string): void {
console.log(`[idempotency-guard] replay key=${key}`);
this.events.push(`replay:${key}`);
}
protected override onCoalesce(key: string): void {
console.log(`[idempotency-guard] coalesce key=${key}`);
this.events.push(`coalesce:${key}`);
}
protected override onConflict(key: string): void {
console.log(`[idempotency-guard] conflict key=${key}`);
this.events.push(`conflict:${key}`);
}
protected override onExecute(key: string): void {
console.log(`[idempotency-guard] execute key=${key}`);
this.events.push(`execute:${key}`);
}
}
class Shared {
static resolve: (value: string) => void = () => {};
}
class SharedFactory {
static factoryCalls = 0;
static pending: Promise<string> = new Promise<string>((resolve) => { Shared.resolve = resolve; });
static async create(): Promise<string> {
SharedFactory.factoryCalls += 1;
return await SharedFactory.pending;
}
}
class IdempotencyGuardDemo {
static async run(): Promise<{
readonly 'factoryCalls': number;
readonly 'first': { 'chargeId': string };
readonly 'guard': TelemetryIdempotencyGuard;
readonly 'replayed': { 'chargeId': string };
readonly 'resultA': string;
readonly 'resultB': string;
}> {
const guard = TelemetryIdempotencyGuard.tracked();
// New key -> onExecute, factory runs
const first = await guard.run('order-42', { 'amount': 500 }, () => {
return { 'chargeId': 'ch_1' };
});
// Same key, same payload -> onReplay, factory does NOT run
const replayed = await guard.run('order-42', { 'amount': 500 }, () => {
return { 'chargeId': 'ch_should_not_run' };
});
// Same key, DIFFERENT payload -> onConflict, then throws
try {
await guard.run('order-42', { 'amount': 999 }, () => {
return { 'chargeId': 'ch_should_not_run' };
});
} catch (error) {
if (error instanceof IdempotencyConflictError) {
console.log(`[idempotency-guard] rejected reuse of key="${error.key}"`);
} else {
throw error;
}
}
// Concurrent calls with the same (new) key share one execution via Coalesce
const callA = guard.run('order-99', { 'region': 'us' }, SharedFactory.create);
const callB = guard.run('order-99', { 'region': 'us' }, SharedFactory.create);
Shared.resolve('shared-result');
const [resultA, resultB] = await Promise.all([callA, callB]);
console.log('Events:', guard.events);
return {
'factoryCalls': SharedFactory.factoryCalls,
'first': first,
'guard': guard,
'replayed': replayed,
'resultA': resultA,
'resultB': resultB
};
}
}
const results = await IdempotencyGuardDemo.run();Hooks
| Hook | Fires when |
|---|---|
onReplay(key) | A repeat call for key finds a matching-fingerprint cached entry and replays it |
onCoalesce(key) | A caller joins an already in-flight execution for key |
onConflict(key) | Fires immediately before throwing IdempotencyConflictError for a fingerprint mismatch |
onExecute(key) | key is genuinely new (or its entry expired) and factory is about to run |
IdempotencyGuard introduces no hooks duplicating what LruCache/Coalesce already expose — its own hooks are specifically about idempotency semantics (replay/conflict/execute/coalesce). Generic cache/coalesce lifecycle (onHit, onEvict, onCoalesceSettled, onTimeout, ...) stays reachable via getCache()/getCoalesce() for a consumer who subclasses the composed instances directly.
Transparency contract
IdempotencyGuard's own hooks (onReplay, onCoalesce, onConflict, onExecute) are specifically about idempotency semantics — never a restatement of generic cache/coalesce lifecycle:
| Getter | Returns |
|---|---|
getCache() | The composed LruCache<string, { fingerprint, result }> instance |
getCoalesce() | The composed Coalesce<unknown> instance |
Every getter returns the exact instance used internally — never a copy or wrapper. A consumer who needs LruCache's onEvict/onExpire/onHit or Coalesce's onCoalesceSettled/onTimeout subclasses those primitives directly and passes nothing new through IdempotencyGuard — IdempotencyGuard composes LruCache/Coalesce internally rather than accepting them as injected config, so those getters are the only path to the live instances.
Composition order
run() computes the payload fingerprint → checks the cache (onReplay / onConflict paths short-circuit here) → on a miss, delegates to the composed Coalesce (onExecute for the leader about to invoke factory, onCoalesce for followers joining the in-flight call) → stores { fingerprint, result } in the cache on success.
Errors
| Error | Thrown when |
|---|---|
IdempotencyConflictError | run() is called with a key whose cached entry has a different payload fingerprint |
IdempotencyGuardConfigError | IdempotencyGuardBuilder#build() is called without capacity or ttlMs |
Documentation
Full reference: https://studnicky.github.io/substrate/packages/idempotency-guard