Files
skills/docs/api-stack/api/api-plugin-notifications/src/dispatcher/idempotency-adapter/idempotency-adapter.md
2026-05-29 08:20:56 +01:00

3.6 KiB

idempotency-adapter

(no submodules)

Purpose

Defines the narrow contract that lets NotificationDispatcher delegate per-call deduplication to a caller-supplied implementation without @suaveplan/api-plugin-notifications taking a runtime dependency on @suaveplan/idempotency (or any other concrete guard). This keeps the dispatcher package light while still letting hosts plug a production-grade idempotency guard with cross-process locking when they need exactly-once semantics.

Features

  • DispatcherIdempotencyAdapter interface — a single method execute<T>(key, fn): Promise<T> that runs fn at most once per key.
  • NoopIdempotencyAdapter — the default adapter that just runs fn() every time. Used when the dispatcher is constructed without an explicit adapter, so the dispatch codepath is identical regardless of whether dedup is wired up.
  • Structural compatibility with @suaveplan/idempotency's IdempotencyGuard.execute<T>(key, fn, opts?) — callers can hand the guard directly to the dispatcher (adapter: guard).

Usage: Basic

import { NoopIdempotencyAdapter } from "@suaveplan/api-plugin-notifications";

const adapter = new NoopIdempotencyAdapter();
const result = await adapter.execute("any-key", async () => 42);
// result === 42, fn ran exactly once

Usage: Advanced

Wire @suaveplan/idempotency's IdempotencyGuard straight in — the signatures are structurally compatible:

import { IdempotencyGuard, MemoryStore } from "@suaveplan/idempotency";
import {
    createNotificationDispatcher,
    NoopIdempotencyAdapter,
} from "@suaveplan/api-plugin-notifications";

const guard = new IdempotencyGuard({ store: new MemoryStore() });

const dispatcher = createNotificationDispatcher({
    channels: [apnChannel, emailChannel],
    defaultChannel: "apn",
    // The guard satisfies `DispatcherIdempotencyAdapter` structurally.
    idempotency: { adapter: guard },
});

// Same key twice → guard runs the closure once and returns the cached value.
await dispatcher.dispatch({
    recipient: "u",
    body: "hi",
    idempotencyKey: "order-42",
});
await dispatcher.dispatch({
    recipient: "u",
    body: "hi",
    idempotencyKey: "order-42",
});

For tests where you want a tiny deterministic cache (no idempotency dep), write a 10-line custom adapter:

class CachingAdapter implements DispatcherIdempotencyAdapter {
    private readonly cache = new Map<string, Promise<unknown>>();
    async execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
        let p = this.cache.get(key) as Promise<T> | undefined;
        if (p === undefined) {
            p = fn();
            this.cache.set(key, p as Promise<unknown>);
        }
        return p;
    }
}

API Reference

  • interface DispatcherIdempotencyAdapter { execute<T>(key, fn): Promise<T> }
  • class NoopIdempotencyAdapter implements DispatcherIdempotencyAdapter

Implementation Notes

  • The adapter is consulted only when dispatch receives a non-empty idempotencyKey. When the key is absent, the dispatcher bypasses the adapter entirely.
  • The contract is intentionally narrower than IdempotencyGuard's execute(key, fn, opts?) — the opts parameter is optional in the guard, so omitting it from the adapter signature still leaves the guard usable as a drop-in.
  • Noop returns whatever the closure resolves to. It does NOT cache; duplicate dispatches with the same key trigger the underlying channel twice. That is the intended behaviour for the default — if you want dedup, plug a real guard.