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

ts
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

Loading example…

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

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

ErrorThrown when
IdempotencyConflictErrorrun() is called with a key whose cached entry has a different payload fingerprint
IdempotencyGuardErrorBase 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.

typescript
import { IdempotencyGuardOptionsEntity } from '@studnicky/idempotency-guard/entities';

Interfaces

@studnicky/idempotency-guard/interfaces exports cached-entry contracts.

typescript
import type { IdempotencyGuardEntryInterface } from '@studnicky/idempotency-guard/interfaces';

Exports

SymbolPurposeImport path
IdempotencyGuardDeduplicates work, replays matching cached results, and rejects conflicts.@studnicky/idempotency-guard
IdempotencyConflictErrorRepresents reuse of an idempotency key with a different payload.@studnicky/idempotency-guard
IdempotencyGuardErrorBase error for idempotency-guard failures.@studnicky/idempotency-guard

Source on GitHub