@studnicky/idempotency-guard
Idempotency key guard composing
@studnicky/cache,@studnicky/concurrency, and@studnicky/json.
Install
pnpm add @studnicky/idempotency-guardUsage
IdempotencyGuard<TResult>#run(key, payload, factory) fingerprints payload via Hash.value() and checks the composed LruCache for an entry under key. TResult belongs to the guard instance and is shared by every key it owns. 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 { IdempotencyPayloadEntity } from '../src/entities/index.js';
import { IdempotencyConflictError, IdempotencyGuard } from '../src/index.js';
class ChargeResult {
constructor(readonly chargeId: string) {}
}
class TelemetryIdempotencyGuard extends IdempotencyGuard<ChargeResult> {
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: ChargeResult) => void = () => {};
}
class SharedFactory {
static factoryCalls = 0;
static pending: Promise<ChargeResult> = new Promise<ChargeResult>((resolve) => {
Shared.resolve = resolve;
});
static async create(): Promise<ChargeResult> {
SharedFactory.factoryCalls += 1;
return await SharedFactory.pending;
}
}
class IdempotencyGuardDemo {
static async run(): Promise<{
readonly 'factoryCalls': number;
readonly 'first': ChargeResult;
readonly 'guard': TelemetryIdempotencyGuard;
readonly 'replayed': ChargeResult;
readonly 'resultA': ChargeResult;
readonly 'resultB': ChargeResult;
}> {
const guard = TelemetryIdempotencyGuard.tracked();
// New key -> onExecute, factory runs
const first = await guard.run('order-42', IdempotencyPayloadEntity.create({ 'amount': 500 }), () => {
return new ChargeResult('ch_1');
});
// Same key, same payload -> onReplay, factory does NOT run
const replayed = await guard.run('order-42', IdempotencyPayloadEntity.create({ 'amount': 500 }), () => {
return new ChargeResult('ch_should_not_run');
});
// Same key, DIFFERENT payload -> onConflict, then throws
try {
await guard.run('order-42', IdempotencyPayloadEntity.create({ 'amount': 999 }), () => {
return new ChargeResult('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', IdempotencyPayloadEntity.create({ 'region': 'us' }), SharedFactory.create);
const callB = guard.run('order-99', IdempotencyPayloadEntity.create({ 'region': 'us' }), SharedFactory.create);
Shared.resolve(new ChargeResult('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();Try it
The output shows onExecute firing for a new key, onReplay replaying the cached result for a repeat call with a matching payload, onConflict rejecting a mismatched payload under the same key, and onCoalesce joining a concurrent caller into one in-flight execution.
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 generic cache or coalescing lifecycle. Its hooks are specifically about idempotency semantics: replay, conflict, execution, and joining an in-flight call.
Ownership contract
IdempotencyGuard.create({ capacity, ttlMs }) creates and owns its cache and coalescer. Those collaborators are implementation details and have no public getters. Consumers observe the guard through onReplay, onCoalesce, onConflict, and onExecute.
Import IdempotencyGuard, IdempotencyConflictError, and IdempotencyGuardError from @studnicky/idempotency-guard; import schemas from @studnicky/idempotency-guard/entities and IdempotencyGuardEntryInterface from @studnicky/idempotency-guard/interfaces. IdempotencyGuardEntryInterface<TResult> composes the schema-derived fingerprint from the metadata entity and retains the caller-owned generic result.
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 |
IdempotencyGuardError | Base domain error for idempotency-guard failures |
Documentation
Full reference: https://studnicky.github.io/substrate/packages/idempotency-guard
Entities
@studnicky/idempotency-guard/entities exports idempotency entry metadata and guard-option schemas.
import { IdempotencyGuardOptionsEntity } from '@studnicky/idempotency-guard/entities';Interfaces
@studnicky/idempotency-guard/interfaces exports cached-entry contracts.
import type { IdempotencyGuardEntryInterface } from '@studnicky/idempotency-guard/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
IdempotencyGuard | Deduplicates work, replays matching cached results, and rejects conflicts. | @studnicky/idempotency-guard |
IdempotencyConflictError | Represents reuse of an idempotency key with a different payload. | @studnicky/idempotency-guard |
IdempotencyGuardError | Base error for idempotency-guard failures. | @studnicky/idempotency-guard |