Skip to content

@studnicky/idempotency-guard

Idempotency key guard composing @studnicky/cache, @studnicky/concurrency, and @studnicky/json.

Install

bash
pnpm add @studnicky/idempotency-guard

Usage

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:

ts
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

HookFires 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:

GetterReturns
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 IdempotencyGuardIdempotencyGuard 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

ErrorThrown when
IdempotencyConflictErrorrun() is called with a key whose cached entry has a different payload fingerprint
IdempotencyGuardConfigErrorIdempotencyGuardBuilder#build() is called without capacity or ttlMs

Documentation

Full reference: https://studnicky.github.io/substrate/packages/idempotency-guard

Source on GitHub