3.6 KiB
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
DispatcherIdempotencyAdapterinterface — a single methodexecute<T>(key, fn): Promise<T>that runsfnat most once perkey.NoopIdempotencyAdapter— the default adapter that just runsfn()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'sIdempotencyGuard.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
dispatchreceives a non-emptyidempotencyKey. When the key is absent, the dispatcher bypasses the adapter entirely. - The contract is intentionally narrower than
IdempotencyGuard'sexecute(key, fn, opts?)— theoptsparameter is optional in the guard, so omitting it from the adapter signature still leaves the guard usable as a drop-in. Noopreturns 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.