UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

478 lines 18.2 kB
/** * @beignet/core/idempotency * * Idempotency primitives for retry-safe commands, webhooks, and jobs. */ /** Default lifetime for unfinished idempotency reservations. */ export const DEFAULT_IDEMPOTENCY_RESERVATION_TTL_SEC = 300; /** * Error thrown when an idempotency key is reused with a different fingerprint. */ export class IdempotencyConflictError extends Error { namespace; key; scopeKey; storedFingerprint; receivedFingerprint; constructor(args) { 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 { namespace; key; scopeKey; constructor(args) { 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 { namespace; key; scopeKey; constructor(args) { 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) { 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. */ action; /** Operation namespace supplied to the failed mutation. */ namespace; /** Client-provided idempotency key supplied to the failed mutation. */ key; /** Normalized logical scope key supplied to the failed mutation. */ scopeKey; constructor(args) { 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; } } function assertNonEmptyString(name, value) { if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`${name} must be a non-empty string`); } } function assertTtl(ttlSec) { if (ttlSec === undefined) return; if (!Number.isInteger(ttlSec) || ttlSec <= 0) { throw new Error("ttlSec must be a positive integer when provided"); } } function normalizeScopeValue(value) { 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) { 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) { 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, now) { assertTtl(ttlSec); return ttlSec === undefined ? null : new Date(now.getTime() + ttlSec * 1000); } function isExpired(entry, now) { return entry.expiresAt !== null && entry.expiresAt.getTime() <= now.getTime(); } function reservationFromRecord(record, receivedFingerprint) { 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, }; } /** * 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 = {}) { const storeNow = options.now ?? (() => new Date()); const records = new Map(); const pendingReservations = new Map(); 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; pendingReservations.set(storageKey, new Promise((resolve) => { releasePendingReservation = resolve; })); try { const reservationToken = options.createReservationToken?.() ?? (await createRandomReservationToken()); const reservedAt = storeNow(); const record = { 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(idempotency, options) { 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; } case "inProgress": { throw new IdempotencyInProgressError(reservation); } case "conflict": { throw new IdempotencyConflictError(reservation); } case "reserved": { let 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; throw new Error(`Idempotency port returned unsupported reservation status "${String(unsupported.status)}".`); } } } async function createRandomReservationToken() { 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) { if (typeof path === "string") return path.split(".").filter(Boolean); return path.map(String); } function shouldOmitPath(path, omit) { 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, options, path, seen) { if (shouldOmitPath(path, options.omit ?? [])) { return undefined; } if (value === undefined || typeof value === "function") { return undefined; } if (value === null || typeof value === "string" || typeof value === "boolean") { return value; } if (typeof value === "number") { if (!Number.isFinite(value)) { throw new IdempotencyFingerprintError("Cannot fingerprint non-finite numeric values."); } return value; } if (typeof value === "bigint") { return value.toString(); } if (value instanceof Date) { return value.toISOString(); } if (Array.isArray(value)) { return value.map((item, index) => canonicalize(item, options, [...path, String(index)], seen) ?? null); } if (typeof value === "object") { if (seen.has(value)) { throw new IdempotencyFingerprintError("Cannot fingerprint circular values."); } seen.add(value); const result = {}; for (const key of Object.keys(value).sort()) { const nestedValue = canonicalize(value[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) { 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, `undefined` and functions are omitted, `Date` values * become ISO strings, BigInts become strings, and circular or non-finite values * throw. Exact omit paths may be supplied as dotted strings or string arrays. */ export async function createIdempotencyFingerprint(value, options = {}) { 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 ?? null); const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(json)); return `sha256:${bytesToHex(digest)}`; } //# sourceMappingURL=index.js.map