import { Hash, Hex } from 'ox' import * as Store from './Store.js' /** * API-level retry coordination for idempotent write endpoints, letting a * caller recover a successful response after a transport timeout. * * A key moves from missing to pending to complete. Only the claim token may * finish or release pending state, preventing an older request from * overwriting a newer owner. */ /** Identity for one caller-supplied idempotent request. */ export type Identity = { /** API key that submitted the request. */ apiKeyId: string /** Opaque caller-supplied idempotency key. */ idempotencyKey: string /** Hash of the request content bound to this idempotency key. */ inputHash: string } /** Returns a stable digest without retaining request contents in durable state. */ export function inputHash(input: unknown): string { return Hash.sha256(Hex.fromString(canonicalJson(input))) } /** Creates an idempotency coordinator over one durable-store key namespace. */ export function create(options: create.Options) { const { namespace, pendingTtl, receiptTtl } = options function keyFor(identity: Identity) { // Scope keys to the authenticated API key so different tenants can reuse // the same client-generated value. Hashing keeps that opaque value out of // durable-store key names and enumeration output. const digest = Hash.sha256(Hex.fromString(identity.idempotencyKey)).slice(2) return `${namespace}:${identity.apiKeyId}:${digest}` } /** Claims a request or returns its current outcome. */ async function claim(options: claim.Options): Promise> { const { identity, state } = options const token = crypto.randomUUID() return Store.change>( state, keyFor(identity), (current) => { const currentRecord = record(current) // A completed request is the only cacheable terminal outcome. Failed // attempts must be retried with the same input. Legacy records lack // an input hash, so cannot be safely replayed. if (currentRecord && currentRecord.inputHash !== identity.inputHash) return { op: 'noop', result: { type: 'mismatch' } } if (currentRecord?.type === 'complete') return { op: 'noop', result: { response: currentRecord.response, type: 'replay' } } // A parallel request must not settle the same caller operation while // the first request still owns its pending claim. if (currentRecord?.type === 'pending') return { op: 'noop', result: { type: 'pending' } } return { op: 'set', result: { token, type: 'claimed' }, value: encode({ inputHash: identity.inputHash, token, type: 'pending' }), } }, { ttl: pendingTtl }, ) } /** Persists a successful response for an owned request claim. */ async function complete(options: complete.Options): Promise { const { identity, response, state, token } = options await Store.change( state, keyFor(identity), (current) => { const currentRecord = record(current) // Claim tokens fence delayed requests: a request cannot complete a // claim it no longer owns after expiry or a later retry. if ( currentRecord?.type !== 'pending' || currentRecord.inputHash !== identity.inputHash || currentRecord.token !== token ) return { op: 'noop', result: undefined } canonicalJson(response, 'Idempotency responses must be JSON values.') return { op: 'set', result: undefined, value: encode({ inputHash: identity.inputHash, response, type: 'complete' }), } }, { ttl: receiptTtl }, ) } /** Releases an owned retryable request claim. */ async function release(options: release.Options): Promise { const { identity, state, token } = options await Store.change(state, keyFor(identity), (current) => { const currentRecord = record(current) // Never delete a newer pending claim after this request has lost // ownership through expiry and another caller has retried. if ( currentRecord?.type !== 'pending' || currentRecord.inputHash !== identity.inputHash || currentRecord.token !== token ) return { op: 'noop', result: undefined } return { op: 'delete', result: undefined } }) } return { claim, complete, release } } export declare namespace create { /** Coordinator configuration bound at creation. */ type Options = { /** Durable-store key namespace (e.g. `mpp:relay:v1`). */ namespace: string /** How long a pending claim blocks equivalent requests, in milliseconds. */ pendingTtl: number /** How long a completed response replays, in milliseconds. */ receiptTtl: number } } export declare namespace claim { /** Dependencies for claiming one idempotency key. */ type Options = { /** API-key-scoped idempotency identity. */ identity: Identity /** Authoritative state with atomic compare-and-swap, normally a Durable Object. */ state: Store.State } /** Outcome from attempting to claim an idempotency key. */ type Result = | { token: string; type: 'claimed' } | { type: 'mismatch' } | { response: response; type: 'replay' } | { type: 'pending' } } export declare namespace complete { /** Dependencies for persisting one terminal response. */ type Options = { /** API-key-scoped idempotency identity. */ identity: Identity /** Terminal successful response to replay. */ response: response /** Authoritative state with atomic compare-and-swap, normally a Durable Object. */ state: Store.State /** Token returned by the successful claim. */ token: string } } export declare namespace release { /** Dependencies for releasing one idempotency claim. */ type Options = { /** API-key-scoped idempotency identity. */ identity: Identity /** Authoritative state with atomic compare-and-swap, normally a Durable Object. */ state: Store.State /** Token returned by the successful claim. */ token: string } } /** A completed request whose response may be replayed until expiry. */ type CompleteRecord = { inputHash?: string response: response type: 'complete' } /** An in-flight request owned by the opaque claim token. */ type PendingRecord = { inputHash?: string token: string type: 'pending' } type ParsedRecord = { inputHash?: unknown response?: unknown token?: unknown type?: unknown } type JsonRecord = { [key: string]: unknown } function encode(record: CompleteRecord | PendingRecord) { return JSON.stringify(record) } function canonicalJson( value: unknown, errorMessage = 'Idempotency inputs must be JSON values.', ): string { if (value === null) return 'null' if (typeof value === 'number' && !Number.isFinite(value)) throw new Error(errorMessage) if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') return JSON.stringify(value) if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item, errorMessage)).join(',')}]` if (typeof value === 'object') { const record = value as JsonRecord return `{${Object.keys(record) .sort() .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key], errorMessage)}`) .join(',')}}` } throw new Error(errorMessage) } function record( value: null | string, ): CompleteRecord | PendingRecord | undefined { if (value === null) return undefined try { const parsed: unknown = JSON.parse(value) if (!parsed || typeof parsed !== 'object') return undefined const candidate = parsed as ParsedRecord if (candidate.type === 'pending' && typeof candidate.token === 'string') return candidate as PendingRecord if (candidate.type === 'complete' && candidate.response !== undefined) return candidate as CompleteRecord } catch {} return undefined }