# 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(key, fn): Promise` 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(key, fn, opts?)` — callers can hand the guard directly to the dispatcher (`adapter: guard`). ## Usage: Basic ```ts 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: ```ts 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: ```ts class CachingAdapter implements DispatcherIdempotencyAdapter { private readonly cache = new Map>(); async execute(key: string, fn: () => Promise): Promise { let p = this.cache.get(key) as Promise | undefined; if (p === undefined) { p = fn(); this.cache.set(key, p as Promise); } return p; } } ``` ## API Reference - `interface DispatcherIdempotencyAdapter { execute(key, fn): Promise }` - `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.