/** * Idempotency primitive. A mutating route can declare `schema.idempotency`; the server then dedupes * on an `Idempotency-Key` header: the first request runs and its response is stored, and a retry with * the same key replays the stored response byte-for-byte without re-running the handler. A retry that * reuses a key with a *different* request body is rejected (409) - a key binds to one request. * * This module is the runtime-neutral core: the store interface, an in-memory store, the request * fingerprint, and response (de)serialization. The server owns the request-path lane that reads the * body, consults the store, and captures the response. All logic here is pure/injectable so it is * unit-tested without a server. A durable store (cross-restart) implements the same interface. */ /** Whether a route's idempotency is satisfied by an in-process store or a durable (cross-restart) one. */ export type IdempotencyScope = "request" | "durable"; /** Default retention for a stored idempotent response: 24 hours. */ export declare const DEFAULT_IDEMPOTENCY_TTL_MS = 86400000; /** Canonical request header carrying the client-chosen idempotency key. */ export declare const DEFAULT_IDEMPOTENCY_HEADER = "idempotency-key"; /** Header stamped on a replayed response so clients/proxies can tell a replay from a fresh run. */ export declare const IDEMPOTENT_REPLAY_HEADER = "x-nifra-idempotent-replay"; /** A serialized response held by a store. `body` is base64 so binary payloads round-trip intact. */ export interface StoredResponse { readonly status: number; readonly headers: readonly (readonly [string, string])[]; /** Base64-encoded response body; `""` when the response had no body. */ readonly body: string; } /** * Outcome of reserving a key. `new` → the caller runs the handler and later calls {@link * IdempotencyStore.complete}. `replay` → return the stored response, handler never runs. `mismatch` * → same key, different request fingerprint (client bug) → 409. `in-flight` → the key is reserved but * not yet completed (a concurrent duplicate) → 409 + Retry-After. */ export type IdempotencyBeginResult = { readonly state: "new"; readonly reservation: string; } | { readonly state: "replay"; readonly response: StoredResponse; } | { readonly state: "mismatch"; } | { readonly state: "in-flight"; } | { readonly state: "capacity"; }; /** Namespaces isolate the same client key across tenants/subjects without putting identity in a header. */ export interface IdempotencyEntryKey { readonly namespace: string; readonly key: string; } export interface IdempotencyBeginInput extends IdempotencyEntryKey { readonly fingerprint: string; readonly ttlMs: number; } export interface IdempotencyCompletionInput extends IdempotencyEntryKey { /** Opaque ownership token returned by `begin(state:"new")`. */ readonly reservation: string; readonly response: StoredResponse; } export interface IdempotencyAbandonInput extends IdempotencyEntryKey { /** Opaque ownership token returned by `begin(state:"new")`. */ readonly reservation: string; } /** * Storage seam for idempotent responses. `begin` MUST be atomic: for one key, exactly one concurrent * caller sees `new`; the rest see `in-flight` (or `replay` once completed). The in-memory store gets * this free from the single-threaded event loop; a durable store uses an atomic insert. */ export interface IdempotencyStore { /** * An honest durability marker. Omit/`memory` for process-local stores; a route declaring * `scope: "durable"` rejects anything other than `durable` at registration. */ readonly durability?: "memory" | "durable"; /** Reserve one namespaced key. Exactly one concurrent caller may receive `new`. */ begin(input: IdempotencyBeginInput): IdempotencyBeginResult | Promise; /** * Persist the final response only when `reservation` still owns the key. Returns false for a stale * completion (for example after expiry + re-reservation), which prevents an older request from * overwriting a newer result. */ complete(input: IdempotencyCompletionInput): boolean | Promise; /** Release only the pending reservation owned by `reservation`. */ abandon(input: IdempotencyAbandonInput): boolean | Promise; } /** A key must be a non-empty, bounded, control-char-free token. Fail closed on anything else. */ export declare function validIdempotencyKey(key: string): boolean; /** Namespace values are server-resolved, bounded opaque tokens (normally a tenant/subject hash). */ export declare function validIdempotencyNamespace(namespace: string): boolean; /** * SHA-256 fingerprint binding a key to one request: method, path (+ query), and the raw body bytes. * A collision-resistant hash matters - a weak hash would let a crafted body replay another's response. */ export declare function computeIdempotencyFingerprint(method: string, path: string, body: Uint8Array, contentType?: string): Promise; /** Canonicalize JSON bodies so whitespace/property-order retries bind to the same semantic request. */ export declare function canonicalizeIdempotencyBody(body: Uint8Array, contentType: string | null): Uint8Array; export declare class IdempotencyResponseTooLargeError extends Error { readonly maxBytes: number; constructor(maxBytes: number); } /** Buffer a response into a storable form. Clones first so the live response body stays intact. */ export declare function serializeResponse(response: Response, options?: { readonly maxBytes?: number; }): Promise; /** Rebuild a live response from storage, stamping the replay marker header. */ export declare function responseFromStored(stored: StoredResponse, options?: { readonly maxBytes?: number; }): Response; export interface MemoryIdempotencyStoreOptions { /** Injectable clock (epoch ms) for deterministic TTL tests. Default `Date.now`. */ readonly now?: () => number; /** Hard memory bound. At capacity new keys fail closed; completed/pending entries are never evicted early. */ readonly maxEntries?: number; } /** * In-process idempotency store. Reservation is atomic by construction - `begin` never awaits, so the * single-threaded event loop serializes concurrent callers for one key. Expired entries are treated * as absent (lazy eviction on access); a periodic {@link MemoryIdempotencyStore.sweep} bounds memory. */ export declare class MemoryIdempotencyStore implements IdempotencyStore { readonly durability: "memory"; private readonly entries; private readonly now; private readonly maxEntries; constructor(options?: MemoryIdempotencyStoreOptions); begin(input: IdempotencyBeginInput): IdempotencyBeginResult; complete(input: IdempotencyCompletionInput): boolean; abandon(input: IdempotencyAbandonInput): boolean; /** Evict expired entries. Callers may run this on an interval; access-time eviction covers the rest. */ sweep(): void; /** Live entry count (post-sweep semantics are the caller's; this is a raw size for tests/metrics). */ get size(): number; private storageKey; } /** Convenience factory mirroring the other core primitives' `create*` style. */ export declare function createMemoryIdempotencyStore(options?: MemoryIdempotencyStoreOptions): MemoryIdempotencyStore; //# sourceMappingURL=idempotency.d.ts.map