import type { Request, Response, NextFunction } from 'express'; interface CachedEntry { statusCode: number; body: unknown; expiresAt: number; /** Reservation placeholder: the request is in-flight, no real response yet. * A duplicate that sees a pending entry is rejected with 409, not replayed. */ pending?: boolean; } /** * Pluggable backend for idempotency-key replay cache. The default in-memory * implementation works fine single-replica; a Redis-backed implementation * is required for correct deduplication across horizontally-scaled replicas * (otherwise two replicas behind a load-balancer cache independently and a * retry hitting a different pod won't replay). */ export interface IdempotencyStore { get(key: string): Promise; set(key: string, entry: CachedEntry, ttlSeconds: number): Promise; /** * Atomically claim `key` IFF it is currently absent. Returns `true` when THIS * caller set the entry (won the race), `false` when an entry already exists. * This is what serializes concurrent first-time requests that share an * Idempotency-Key — a plain get-then-set leaves a window where two requests * both see "not cached" and both execute the mutation. */ reserve(key: string, entry: CachedEntry, ttlSeconds: number): Promise; /** Remove a key — releases a reservation after a non-cacheable (non-2xx) response. */ delete(key: string): Promise; } /** Default in-memory store. Single-replica only. Exported for testing. */ export declare function createMemoryStore(): IdempotencyStore; /** * Minimal Redis surface the idempotency store needs (an ioredis subset). * `set` supports the variadic option tail: `SET key val EX [NX]`, which * returns `'OK'` on success and `null` when an `NX` set was refused. */ export interface RedisIdempotencyClient { get(key: string): Promise; set(key: string, value: string, ...args: (string | number)[]): Promise; del(key: string): Promise; } /** * Redis-backed {@link IdempotencyStore} for correct deduplication across * horizontally-scaled replicas. `reserve` maps to an atomic `SET … NX` so two * pods behind a load-balancer can't both win a first-time request sharing an * Idempotency-Key; a retry landing on ANY pod sees the same committed response. * Redis TTL owns expiry (the `CachedEntry.expiresAt` field is carried only for * API-shape parity with the memory store). */ export declare function createRedisIdempotencyStore(redis: RedisIdempotencyClient): IdempotencyStore; /** * Build a Redis-backed idempotency store from the shared env Redis (the same * `REDIS_URL` / `REDIS_SENTINELS` wiring the rate-limiter and audit-spool use). * Returns `null` when Redis isn't configured/available so the caller keeps the * in-memory default (single-replica correctness). Never throws. */ export declare function createEnvRedisIdempotencyStore(): IdempotencyStore | null; /** Inject the process-wide idempotency store (e.g. a Redis-backed one). */ export declare function setIdempotencyStore(store: IdempotencyStore): void; /** The current process-wide default store (in-memory unless overridden). */ export declare function getIdempotencyStore(): IdempotencyStore; export interface IdempotencyMiddlewareOptions { /** Custom store backend. Defaults to the process-wide store set via * {@link setIdempotencyStore} (in-memory until `createApp` wires Redis). */ store?: IdempotencyStore; } /** * Middleware that supports idempotency keys for POST/PUT/DELETE mutations. * * When a request includes the `Idempotency-Key` header: * - First call: processes normally, caches the response * - Subsequent calls with same key: returns cached response (prevents duplicate mutations) * * Defaults to an in-memory store (single-replica). Pass a custom * cross-replica `{ store }` implementing `IdempotencyStore` to dedupe across * replicas. */ export declare function idempotencyMiddleware(options?: IdempotencyMiddlewareOptions): (req: Request, res: Response, next: NextFunction) => void; export {};