@beignet/core
Version:
Core framework primitives for Beignet
478 lines • 18.2 kB
JavaScript
/**
* @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