UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

1,020 lines (930 loc) 28.2 kB
/** * @beignet/core/idempotency * * Idempotency primitives for retry-safe commands, webhooks, and jobs. */ /** * Value or promise of that value. */ export type MaybePromise<T> = T | Promise<T>; /** * Primitive value accepted inside an idempotency scope object. */ export type IdempotencyScopeValue = | string | number | boolean | null | undefined; /** * Logical scope for idempotency keys. * * String scopes and object scopes are normalized with type tags so adapters can * build stable storage keys without primitive or delimiter collisions. */ export type IdempotencyScope = string | Record<string, IdempotencyScopeValue>; /** * Scope mode that HTTP hooks can use when deriving an idempotency scope. */ export type IdempotencyScopeMode = | "global" | "actor" | "tenant" | "actor-tenant"; /** Default lifetime for unfinished idempotency reservations. */ export const DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC = 300; /** * Contract metadata for idempotency-aware routes. * * This metadata is enforced at the HTTP boundary by * `createIdempotencyHooks(...)` from `@beignet/core/server`. * `runIdempotently(...)` remains the primitive for non-HTTP workflows such as * jobs, listeners, webhooks, and schedules. */ export interface IdempotencyMeta { /** * Whether this operation requires an idempotency key at the HTTP boundary. */ required?: boolean; /** * Header that carries the idempotency key. * * Default: "idempotency-key". */ header?: string; /** * How to scope idempotency keys when an HTTP hook derives the scope. * * By default the HTTP hook scopes to the actor and includes the tenant when * one is present. Use "global" explicitly only when every caller should * share one idempotency-key namespace for the operation. */ scope?: IdempotencyScopeMode; /** * Time-to-live for completed replay results. */ ttlSec?: number; /** * Time-to-live for unfinished reservations. * * @default 300 */ reservationTtlSec?: number; } /** * Input for reserving an idempotency key. */ export interface IdempotencyReserveInput { /** * Operation namespace, usually a use-case or route name. */ namespace: string; /** * Client-provided idempotency key. */ key: string; /** * Logical scope for this key. */ scope?: IdempotencyScope; /** * Fingerprint of the logical command payload. */ fingerprint: string; /** * Optional time-to-live for the completed replay result. */ ttlSec?: number; /** * Time-to-live for unfinished work before a successor may reserve the key. * * @default 300 */ reservationTtlSec?: number; } /** * Result of reserving an idempotency key. */ export type IdempotencyReservation = | { status: "reserved"; namespace: string; key: string; scopeKey: string; fingerprint: string; reservationToken: string; reservedAt: Date; expiresAt: Date | null; } | { status: "replay"; namespace: string; key: string; scopeKey: string; fingerprint: string; result: unknown; reservedAt: Date; completedAt: Date; expiresAt: Date | null; } | { status: "inProgress"; namespace: string; key: string; scopeKey: string; fingerprint: string; reservedAt: Date; expiresAt: Date | null; } | { status: "conflict"; namespace: string; key: string; scopeKey: string; storedFingerprint: string; receivedFingerprint: string; reservedAt: Date; completedAt?: Date; expiresAt: Date | null; }; /** * Input for marking an idempotency key complete. */ export interface IdempotencyCompleteInput { /** * Operation namespace. */ namespace: string; /** * Client-provided idempotency key. */ key: string; /** * Logical scope for this key. */ scope?: IdempotencyScope; /** * Fingerprint that must match the reserved operation. */ fingerprint: string; /** Opaque identity returned by the matching reserved result. */ reservationToken: string; /** * Result to replay for future matching requests. */ result?: unknown; } /** * Input for releasing or marking a failed idempotency reservation. */ export interface IdempotencyFailInput { /** * Operation namespace. */ namespace: string; /** * Client-provided idempotency key. */ key: string; /** * Logical scope for this key. */ scope?: IdempotencyScope; /** * Fingerprint that must match the reserved operation. */ fingerprint: string; /** Opaque identity returned by the matching reserved result. */ reservationToken: string; /** * Error that caused the protected operation to fail. */ error?: unknown; } /** * App-facing idempotency port. */ export interface IdempotencyPort { /** * Atomically reserve a key for work, replay an already completed result, or * report that the key is in progress/conflicting. */ reserve(input: IdempotencyReserveInput): Promise<IdempotencyReservation>; /** * Mark a reserved key as complete and store the result that may be replayed. * * Implementations must reject when the fingerprint, reservation token, or * in-progress state no longer matches the current reservation. */ complete(input: IdempotencyCompleteInput): Promise<void>; /** * Release or mark a reserved key after the protected work fails. * * Implementations must reject when the fingerprint, reservation token, or * in-progress state no longer matches the current reservation. */ fail(input: IdempotencyFailInput): Promise<void>; } /** * Options for reserving an idempotency key around a protected operation. */ export interface RunIdempotentlyOptions<Result> extends IdempotencyReserveInput { /** * Protected operation to run after the key is reserved. */ run: () => MaybePromise<Result>; /** * Replay behavior for completed matching reservations. * * Defaults to returning the stored result. Use `"error"` when callers need to * distinguish replay from first execution. */ replay?: "return" | "error"; } /** * Options for `createIdempotencyFingerprint(...)`. */ export interface CreateIdempotencyFingerprintOptions { /** * Omit values from the fingerprint input. Use this for the idempotency key * itself or other request metadata that does not define the logical command. * * String paths can be top-level keys (`"idempotencyKey"`) or dotted paths * (`"metadata.requestId"`). Array paths avoid ambiguity when keys contain * dots. */ omit?: readonly (string | readonly string[])[]; } /** * In-memory idempotency store for tests and local examples. */ export interface MemoryIdempotencyStore extends IdempotencyPort { /** * Current store entries. */ readonly entries: readonly MemoryIdempotencyEntry[]; /** * Remove all entries. */ clear(): void; } /** * Snapshot entry from the memory idempotency store. */ export interface MemoryIdempotencyEntry { /** * Operation namespace. */ namespace: string; /** * Client-provided idempotency key. */ key: string; /** * Normalized scope key. */ scopeKey: string; /** * Fingerprint of the logical command payload. */ fingerprint: string; /** Opaque identity of the current in-progress reservation. */ reservationToken: string; /** * Memory store status. */ status: "in-progress" | "completed"; /** * Stored result for completed entries. */ result?: unknown; /** * Reservation timestamp. */ reservedAt: Date; /** * Completion timestamp. */ completedAt?: Date; /** * Expiration timestamp, or null for no expiration. */ expiresAt: Date | null; } /** * Error thrown when an idempotency key is reused with a different fingerprint. */ export class IdempotencyConflictError extends Error { readonly namespace: string; readonly key: string; readonly scopeKey: string; readonly storedFingerprint: string; readonly receivedFingerprint: string; constructor(args: { namespace: string; key: string; scopeKey: string; storedFingerprint: string; receivedFingerprint: string; }) { super( `Idempotency key "${args.key}" conflicts with a different payload in namespace "${args.namespace}".`, ); this.name = "IdempotencyConflictError"; this.namespace = args.namespace; this.key = args.key; this.scopeKey = args.scopeKey; this.storedFingerprint = args.storedFingerprint; this.receivedFingerprint = args.receivedFingerprint; } } /** * Error thrown when an idempotency key is already reserved by in-progress work. */ export class IdempotencyInProgressError extends Error { readonly namespace: string; readonly key: string; readonly scopeKey: string; constructor(args: { namespace: string; key: string; scopeKey: string }) { super( `Idempotency key "${args.key}" is already in progress in namespace "${args.namespace}".`, ); this.name = "IdempotencyInProgressError"; this.namespace = args.namespace; this.key = args.key; this.scopeKey = args.scopeKey; } } /** * Error thrown when replay is disabled for a completed idempotency key. */ export class IdempotencyReplayError extends Error { readonly namespace: string; readonly key: string; readonly scopeKey: string; constructor(args: { namespace: string; key: string; scopeKey: string }) { super( `Idempotency key "${args.key}" already completed in namespace "${args.namespace}".`, ); this.name = "IdempotencyReplayError"; this.namespace = args.namespace; this.key = args.key; this.scopeKey = args.scopeKey; } } /** * Error thrown when fingerprint input cannot be canonicalized. */ export class IdempotencyFingerprintError extends Error { constructor(message: string) { super(message); this.name = "IdempotencyFingerprintError"; } } /** * Error thrown when a completion or failure mutation no longer owns the * matching in-progress reservation. */ export class IdempotencyMutationError extends Error { /** Mutation that failed to match the current reservation. */ readonly action: "complete" | "fail"; /** Operation namespace supplied to the failed mutation. */ readonly namespace: string; /** Client-provided idempotency key supplied to the failed mutation. */ readonly key: string; /** Normalized logical scope key supplied to the failed mutation. */ readonly scopeKey: string; constructor(args: { action: "complete" | "fail"; namespace: string; key: string; scope?: IdempotencyScope; }) { const scopeKey = normalizeIdempotencyScope(args.scope); super( `Idempotency ${args.action} for key "${args.key}" in namespace "${args.namespace}" matched no in-progress reservation with this fingerprint and reservation token. The reservation may be missing, expired, already completed or released, or owned by a different execution.`, ); this.name = "IdempotencyMutationError"; this.action = args.action; this.namespace = args.namespace; this.key = args.key; this.scopeKey = scopeKey; } } type CanonicalValue = | null | string | number | boolean | readonly CanonicalValue[] | { readonly [key: string]: CanonicalValue }; const CANONICAL_TYPE_KEY = "$beignet:idempotency-type"; function canonicalType( type: "bigint" | "date" | "negative-zero" | "omitted", value?: string, ): CanonicalValue { return { [CANONICAL_TYPE_KEY]: value === undefined ? [type] : [type, value], }; } type MemoryRecord = MemoryIdempotencyEntry & { replayTtlSec?: number; }; function assertNonEmptyString(name: string, value: string): void { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${name} must be a non-empty string`); } } function assertTtl(ttlSec: number | undefined): void { if (ttlSec === undefined) return; if (!Number.isInteger(ttlSec) || ttlSec <= 0) { throw new Error("ttlSec must be a positive integer when provided"); } } function normalizeScopeValue(value: IdempotencyScopeValue): readonly string[] { if (value === undefined) return ["undefined"]; if (value === null) return ["null"]; if (typeof value === "number") { return ["number", Object.is(value, -0) ? "-0" : String(value)]; } if (typeof value === "boolean") { return ["boolean", value ? "true" : "false"]; } return ["string", value]; } /** * Normalize an idempotency scope into a stable string. */ export function normalizeIdempotencyScope( scope: IdempotencyScope | undefined, ): string { if (scope === undefined) return JSON.stringify(["string", "global"]); if (typeof scope === "string") return JSON.stringify(["string", scope]); return JSON.stringify([ "object", Object.keys(scope) .sort() .map((key) => [key, normalizeScopeValue(scope[key])]), ]); } /** * Create the stable storage key for an idempotency operation. */ export function createIdempotencyStorageKey(input: { namespace: string; key: string; scope?: IdempotencyScope; }): string { assertNonEmptyString("namespace", input.namespace); assertNonEmptyString("key", input.key); // Join with the ASCII unit separator. A control character cannot appear in // real namespace, scope, or key values, so the joined key cannot collide // with a different namespace/scope/key combination. NUL would give the same // collision resistance, but Postgres `text` columns reject NUL bytes // (error 22P05), so database-backed idempotency stores must avoid it. return [ input.namespace, normalizeIdempotencyScope(input.scope), input.key, ].join("\u001f"); } function resolveExpiresAt(ttlSec: number | undefined, now: Date): Date | null { assertTtl(ttlSec); return ttlSec === undefined ? null : new Date(now.getTime() + ttlSec * 1000); } function isExpired(entry: MemoryRecord, now: Date): boolean { return entry.expiresAt !== null && entry.expiresAt.getTime() <= now.getTime(); } function reservationFromRecord( record: MemoryRecord, receivedFingerprint: string, ): IdempotencyReservation { if (record.fingerprint !== receivedFingerprint) { return { status: "conflict", namespace: record.namespace, key: record.key, scopeKey: record.scopeKey, storedFingerprint: record.fingerprint, receivedFingerprint, reservedAt: record.reservedAt, completedAt: record.completedAt, expiresAt: record.expiresAt, }; } if (record.status === "completed" && record.completedAt) { return { status: "replay", namespace: record.namespace, key: record.key, scopeKey: record.scopeKey, fingerprint: record.fingerprint, result: record.result, reservedAt: record.reservedAt, completedAt: record.completedAt, expiresAt: record.expiresAt, }; } return { status: "inProgress", namespace: record.namespace, key: record.key, scopeKey: record.scopeKey, fingerprint: record.fingerprint, reservedAt: record.reservedAt, expiresAt: record.expiresAt, }; } /** * Options for `createMemoryIdempotencyStore(...)`. */ export interface MemoryIdempotencyStoreOptions { /** * Clock used for reservation and completion timestamps. Defaults to the * system clock. */ now?: () => Date; /** Token factory used for deterministic tests. */ createReservationToken?: () => string; } /** * Create an in-memory idempotency store for tests and local examples. * * The memory store is process-local and not suitable for multi-process * production deployments. */ export function createMemoryIdempotencyStore( options: MemoryIdempotencyStoreOptions = {}, ): MemoryIdempotencyStore { const storeNow = options.now ?? (() => new Date()); const records = new Map<string, MemoryRecord>(); const pendingReservations = new Map<string, Promise<void>>(); return { get entries() { return [...records.values()]; }, async reserve(input) { assertNonEmptyString("namespace", input.namespace); assertNonEmptyString("key", input.key); assertNonEmptyString("fingerprint", input.fingerprint); assertTtl(input.ttlSec); assertTtl(input.reservationTtlSec); const storageKey = createIdempotencyStorageKey(input); while (true) { const now = storeNow(); const existing = records.get(storageKey); if (existing && !isExpired(existing, now)) { return reservationFromRecord(existing, input.fingerprint); } if (existing) { records.delete(storageKey); } const pendingReservation = pendingReservations.get(storageKey); if (pendingReservation) { await pendingReservation; continue; } let releasePendingReservation!: () => void; pendingReservations.set( storageKey, new Promise<void>((resolve) => { releasePendingReservation = resolve; }), ); try { const reservationToken = options.createReservationToken?.() ?? (await createRandomReservationToken()); const reservedAt = storeNow(); const record: MemoryRecord = { namespace: input.namespace, key: input.key, scopeKey: normalizeIdempotencyScope(input.scope), fingerprint: input.fingerprint, reservationToken, replayTtlSec: input.ttlSec, status: "in-progress", reservedAt, expiresAt: resolveExpiresAt( input.reservationTtlSec ?? DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC, reservedAt, ), }; records.set(storageKey, record); return { status: "reserved", namespace: record.namespace, key: record.key, scopeKey: record.scopeKey, fingerprint: record.fingerprint, reservationToken: record.reservationToken, reservedAt: record.reservedAt, expiresAt: record.expiresAt, }; } finally { pendingReservations.delete(storageKey); releasePendingReservation(); } } }, async complete(input) { assertNonEmptyString("namespace", input.namespace); assertNonEmptyString("key", input.key); assertNonEmptyString("fingerprint", input.fingerprint); const storageKey = createIdempotencyStorageKey(input); const existing = records.get(storageKey); assertNonEmptyString("reservationToken", input.reservationToken); if ( !existing || existing.fingerprint !== input.fingerprint || existing.reservationToken !== input.reservationToken || existing.status !== "in-progress" ) { throw new IdempotencyMutationError({ action: "complete", namespace: input.namespace, key: input.key, scope: input.scope, }); } existing.status = "completed"; existing.result = input.result; existing.completedAt = storeNow(); existing.expiresAt = resolveExpiresAt( existing.replayTtlSec, existing.completedAt, ); }, async fail(input) { assertNonEmptyString("namespace", input.namespace); assertNonEmptyString("key", input.key); assertNonEmptyString("fingerprint", input.fingerprint); const storageKey = createIdempotencyStorageKey(input); const existing = records.get(storageKey); assertNonEmptyString("reservationToken", input.reservationToken); if ( !existing || existing.fingerprint !== input.fingerprint || existing.reservationToken !== input.reservationToken || existing.status === "completed" ) { throw new IdempotencyMutationError({ action: "fail", namespace: input.namespace, key: input.key, scope: input.scope, }); } records.delete(storageKey); }, clear() { records.clear(); }, }; } /** * Run an operation behind an idempotency reservation. * * The flow is: reserve the key, replay completed matching results, reject * in-progress/conflicting keys, run the operation for new reservations, then * complete or fail the reservation. Callers are responsible for choosing a * namespace, scope, key, and fingerprint that match their business operation. */ export async function runIdempotently<Result>( idempotency: IdempotencyPort, options: RunIdempotentlyOptions<Result>, ): Promise<Result> { const operation = { namespace: options.namespace, key: options.key, scope: options.scope, fingerprint: options.fingerprint, ttlSec: options.ttlSec, reservationTtlSec: options.reservationTtlSec, }; const reservation = await idempotency.reserve(operation); switch (reservation.status) { case "replay": { if (options.replay === "error") { throw new IdempotencyReplayError(reservation); } return reservation.result as Result; } case "inProgress": { throw new IdempotencyInProgressError(reservation); } case "conflict": { throw new IdempotencyConflictError(reservation); } case "reserved": { let result: Result; try { result = await options.run(); } catch (error) { try { await idempotency.fail({ namespace: operation.namespace, key: operation.key, scope: operation.scope, fingerprint: operation.fingerprint, reservationToken: reservation.reservationToken, error, }); } catch (settlementError) { throw new AggregateError( [error, settlementError], "Idempotent operation failed and releasing its reservation also failed.", { cause: error }, ); } throw error; } await idempotency.complete({ namespace: operation.namespace, key: operation.key, scope: operation.scope, fingerprint: operation.fingerprint, reservationToken: reservation.reservationToken, result, }); return result; } default: { const unsupported = reservation as { status?: unknown }; throw new Error( `Idempotency port returned unsupported reservation status "${String(unsupported.status)}".`, ); } } } async function createRandomReservationToken(): Promise<string> { const crypto = globalThis.crypto; if (typeof crypto?.randomUUID === "function") { return crypto.randomUUID(); } if (typeof crypto?.getRandomValues === "function") { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes, (value) => value.toString(16).padStart(2, "0"), ).join(""); } try { const nodeCrypto = await import("node:crypto"); if (typeof nodeCrypto.randomUUID === "function") { return nodeCrypto.randomUUID(); } return nodeCrypto.randomBytes(16).toString("hex"); } catch { throw new Error( "Idempotency reservations require Web Crypto or Node.js crypto.", ); } } function normalizeOmitPath( path: string | readonly string[], ): readonly string[] { if (typeof path === "string") return path.split(".").filter(Boolean); return path.map(String); } function shouldOmitPath( path: readonly string[], omit: readonly (string | readonly string[])[], ): boolean { return omit.some((entry) => { const omitPath = normalizeOmitPath(entry); if (omitPath.length !== path.length) return false; return omitPath.every((segment, index) => segment === path[index]); }); } function canonicalize( value: unknown, options: CreateIdempotencyFingerprintOptions, path: readonly string[], seen: WeakSet<object>, ): CanonicalValue | undefined { if (shouldOmitPath(path, options.omit ?? [])) { return undefined; } if (value === undefined || typeof value === "function") { return undefined; } if (value === null) { return null; } if (typeof value === "string") { return value; } if (typeof value === "boolean") { return value; } if (typeof value === "number") { if (!Number.isFinite(value)) { throw new IdempotencyFingerprintError( "Cannot fingerprint non-finite numeric values.", ); } return Object.is(value, -0) ? canonicalType("negative-zero") : value; } if (typeof value === "bigint") { return canonicalType("bigint", value.toString()); } if (value instanceof Date) { if (!Number.isFinite(value.getTime())) { throw new IdempotencyFingerprintError( "Cannot fingerprint an invalid Date value.", ); } return canonicalType("date", value.toISOString()); } if (Array.isArray(value)) { if (seen.has(value)) { throw new IdempotencyFingerprintError( "Cannot fingerprint circular values.", ); } seen.add(value); const result: CanonicalValue = Array.from(value, (item, index) => { const nestedValue = canonicalize( item, options, [...path, String(index)], seen, ); return nestedValue === undefined ? canonicalType("omitted") : nestedValue; }); seen.delete(value); return result; } if (typeof value === "object") { const prototype = Object.getPrototypeOf(value); if (prototype !== null && prototype !== Object.prototype) { throw new IdempotencyFingerprintError( "Cannot fingerprint unsupported object values. Use plain objects, arrays, Dates, and JSON primitives.", ); } if (Object.hasOwn(value, CANONICAL_TYPE_KEY)) { throw new IdempotencyFingerprintError( `Cannot fingerprint an object containing the reserved key "${CANONICAL_TYPE_KEY}".`, ); } if (seen.has(value)) { throw new IdempotencyFingerprintError( "Cannot fingerprint circular values.", ); } seen.add(value); const result: Record<string, CanonicalValue> = Object.create(null); for (const key of Object.keys(value as Record<string, unknown>).sort()) { const nestedValue = canonicalize( (value as Record<string, unknown>)[key], options, [...path, key], seen, ); if (nestedValue !== undefined) { result[key] = nestedValue; } } seen.delete(value); return result; } throw new IdempotencyFingerprintError( `Cannot fingerprint value of type "${typeof value}".`, ); } function bytesToHex(bytes: ArrayBuffer): string { return [...new Uint8Array(bytes)] .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } /** * Create a SHA-256 fingerprint from a canonicalized value. * * Object keys are sorted, while non-JSON values carry reserved type tags so * BigInts and Dates cannot collide with same-looking strings. Ordinary JSON * inputs retain their existing canonical representation. `undefined` values * and functions are omitted from objects and represented as omitted array * positions. Circular or unsupported object values, reserved tag keys, * invalid dates, and non-finite numbers throw. Exact omit paths may be supplied * as dotted strings or string arrays. */ export async function createIdempotencyFingerprint( value: unknown, options: CreateIdempotencyFingerprintOptions = {}, ): Promise<string> { if (!globalThis.crypto?.subtle) { throw new IdempotencyFingerprintError( "Cannot create an idempotency fingerprint because Web Crypto is unavailable.", ); } const canonical = canonicalize(value, options, [], new WeakSet()); const json = JSON.stringify(canonical ?? canonicalType("omitted")); const digest = await globalThis.crypto.subtle.digest( "SHA-256", new TextEncoder().encode(json), ); return `sha256:${bytesToHex(digest)}`; }