@beignet/core
Version:
Core framework primitives for Beignet
972 lines (886 loc) • 26.5 kB
text/typescript
/**
* @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 };
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 ||
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: Record<string, CanonicalValue> = {};
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, `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: 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 ?? null);
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(json),
);
return `sha256:${bytesToHex(digest)}`;
}