first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
# `distributed-guard`
## Purpose
Provides distributed idempotency protection via a pluggable `LockProvider` that serializes concurrent access across multiple processes or nodes before consulting the idempotency store. The `DistributedIdempotencyGuard` guarantees exactly-once execution even when multiple service instances race to handle the same idempotency key simultaneously, preventing double-charge or double-write scenarios.
## Features
- `DistributedIdempotencyGuard` — wraps any store + lock provider for multi-node safety
- `MemoryLockProvider` — in-process lock map for unit tests and single-node deployments
- Exponential back-off with jitter on lock contention
- Automatic lock release in a `finally` block — never leaks locks on exception
- Configurable retry count, base delay, and lock TTL
- Fingerprint mismatch detection under the lock (same key, different payload)
- `AbortSignal` support propagated through lock acquisition retries
- Injectable `clock` for deterministic testing
## Usage: Basic Example
```ts
import { DistributedIdempotencyGuard, MemoryLockProvider } from "@suaveplan/idempotency/distributed";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
const guard = new DistributedIdempotencyGuard({
store: new MemoryIdempotencyStore(),
lockProvider: new MemoryLockProvider(),
ttlMs: 3_600_000,
lockTtlMs: 5_000,
});
const result = await guard.execute("payment-42", () => chargeCard());
```
## Usage: Advanced Example
```ts
import { DistributedIdempotencyGuard } from "@suaveplan/idempotency/distributed";
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
import { RedisLockProvider } from "@suaveplan/idempotency/distributed";
import { LockAcquisitionError } from "@suaveplan/idempotency";
const guard = new DistributedIdempotencyGuard({
store: new RedisIdempotencyStore({ client: redisAdapter }),
lockProvider: new RedisLockProvider({ client: redisAdapter, ownerId: "worker-1" }),
retryCount: 5,
retryBaseDelayMs: 50,
lockTtlMs: 10_000,
});
try {
await guard.execute("order-99", () => fulfillOrder(order), { input: order });
} catch (err) {
if (err instanceof LockAcquisitionError) {
console.error(`Lock failed after ${err.attempts} attempts`);
}
}
```
## API Reference
### `new DistributedIdempotencyGuard(config: DistributedGuardConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `store` | `IdempotencyStoreAdapter` | required | Record persistence |
| `lockProvider` | `LockProvider` | required | Distributed lock backend |
| `ttlMs` | `number` | `86_400_000` | Record TTL (24 h) |
| `lockTtlMs` | `number` | `10_000` | Lock TTL (10 s) |
| `retryCount` | `number` | `3` | Max lock acquisition retries |
| `retryBaseDelayMs` | `number` | `100` | Base back-off delay (ms) |
| `fingerprintFn` | `fn` | `fingerprintPayload` | Payload hasher |
| `clock` | `() => number` | `Date.now` | Monotonic clock |
### `execute<T>(key, fn, opts?): Promise<T>`
Acquires the distributed lock, checks the store, and executes `fn` if no cached result exists.
**Throws:** `LockAcquisitionError` | `IdempotencyMismatchError`
### `MemoryLockProvider`
In-memory lock map with TTL-based auto-expiration. Suitable for testing and single-process deployments.
**Methods:** `acquire(resource, ttlMs)`, `release(resource)`, `isLocked(resource)`, `clear()`
## Implementation Notes
- The lock key is prefixed `idempotency:lock:<key>` to avoid collisions with store keys.
- Exponential back-off delay formula: `baseDelay * 2^attempt + random(0, baseDelay)`.
- The lock is always released in `finally` — even when `fn` throws or the AbortSignal fires.
- `MemoryLockProvider.clock` is injectable to enable deterministic TTL expiration in tests.

View File

@@ -0,0 +1,79 @@
# `redis-lock-provider`
## Purpose
Provides a Redis-backed implementation of the `LockProvider` interface using atomic `SET NX` (set-if-not-exists) operations with TTL. The `RedisLockProvider` enables distributed mutual exclusion across multiple service instances when used inside `DistributedIdempotencyGuard`, preventing duplicate execution of the same idempotent operation in multi-node deployments.
## Features
- Atomic `SET NX` acquisition — no race conditions on concurrent acquisition attempts
- Owner-verified release — only the process that acquired the lock can release it
- Configurable key prefix for namespace isolation
- Auto-generated `ownerId` when not explicitly provided (timestamp + random suffix)
- Compatible with any `RedisClientAdapter` implementation
## Usage: Basic Example
```ts
import { RedisLockProvider } from "@suaveplan/idempotency/distributed";
import type { RedisClientAdapter } from "@suaveplan/idempotency/store";
declare const redisAdapter: RedisClientAdapter;
const lock = new RedisLockProvider({
client: redisAdapter,
ownerId: "worker-1",
});
const acquired = await lock.acquire("payment-42", 10_000);
if (acquired) {
try {
// critical section
} finally {
await lock.release("payment-42");
}
}
```
## Usage: Advanced Example
```ts
import { DistributedIdempotencyGuard } from "@suaveplan/idempotency/distributed";
import { RedisLockProvider } from "@suaveplan/idempotency/distributed";
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
const guard = new DistributedIdempotencyGuard({
store: new RedisIdempotencyStore({ client: redisAdapter }),
lockProvider: new RedisLockProvider({
client: redisAdapter,
prefix: "app:locks:",
ownerId: process.env.WORKER_ID,
}),
lockTtlMs: 15_000,
retryCount: 5,
});
```
## API Reference
### `new RedisLockProvider(config: RedisLockProviderConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `client` | `RedisClientAdapter` | required | Redis adapter |
| `prefix` | `string` | `"lock:"` | Key namespace prefix |
| `ownerId` | `string` | auto-generated | Unique owner identifier |
### `acquire(resource, ttlMs): Promise<boolean>`
Calls `client.setNx(prefix + resource, ownerId, ttlMs)`. Returns `true` if the lock was acquired.
### `release(resource): Promise<void>`
Reads the lock value; deletes the key only if it still contains `ownerId`. This check-then-delete sequence prevents releasing another owner's lock after TTL auto-expiry.
## Implementation Notes
- The owner-verified release (read + delete) is not atomic in this implementation. For production use with very short TTLs and high contention, consider a Lua script for atomic compare-and-delete. The current approach is safe for the typical use case where the lock TTL is significantly longer than the operation duration.
- The `ownerId` should be unique per process/pod; using a Kubernetes pod name or UUID is recommended in production.
- The key stored at `prefix + resource` is the `ownerId` string, not a JSON blob, keeping the Redis value minimal.

View File

@@ -0,0 +1,78 @@
# Idempotency Error Codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines and registers all stable error codes and their HTTP/retry metadata for the `@suaveplan/idempotency` package. Every error thrown by `@suaveplan/idempotency` carries one of these codes, enabling downstream consumers — HTTP adapters, retry wrappers, logging pipelines — to classify failures programmatically without string-matching against error messages.
## Features
- All codes grouped under the `IDEMPOTENCY` namespace (e.g. `IDEMPOTENCY.CONFLICT`)
- HTTP status, severity level, and retryability metadata for every code
- Exported as `IDEMPOTENCY_ERROR_METADATA` for use in HTTP problem-detail mappers
- `IdempotencyErrorCode` union type for exhaustive switch discrimination
- Registered once at module load via a side-effect import in `src/index.ts`
- Re-exported callable `registerIdempotencyErrorCodes` for hosts that clear and re-register in isolated tests
## Usage: Basic Example
```typescript
import { IDEMPOTENCY_ERROR_METADATA, type IdempotencyErrorCode } from "@suaveplan/idempotency/errors";
// Map a code to its HTTP status for a problem-detail response
function toHttpStatus(code: IdempotencyErrorCode): number {
return IDEMPOTENCY_ERROR_METADATA[code].httpStatus;
}
```
## Usage: Advanced Example
```typescript
import { IdempotencyError } from "@suaveplan/idempotency/errors";
import { IDEMPOTENCY_CODES } from "@suaveplan/idempotency/errors";
import { IDEMPOTENCY_ERROR_METADATA } from "@suaveplan/idempotency/errors";
// Express middleware error handler
app.use((err: unknown, req, res, next) => {
if (err instanceof IdempotencyError) {
const meta = IDEMPOTENCY_ERROR_METADATA[err.code as IdempotencyErrorCode];
res.status(meta.httpStatus).json({
type: `https://errors.suaveplan.com/idempotency/${err.code}`,
title: meta.description,
retryable: meta.retryable,
});
return;
}
next(err);
});
```
## API Reference
### `IDEMPOTENCY_ERROR_METADATA`
`Readonly<Record<IdempotencyErrorCode, IdempotencyCodeMetadata>>` — maps every error code to its metadata.
#### `IdempotencyCodeMetadata`
| Field | Type | Description |
|---|---|---|
| `httpStatus` | `number` | Suggested HTTP status code for this error |
| `severity` | `"info" \| "warning" \| "error"` | Log severity for this error |
| `retryable` | `boolean` | Whether the caller can retry the same request |
| `description` | `string` | Human-readable description for problem-detail bodies |
### `IdempotencyErrorCode`
Union type of all values in `IDEMPOTENCY_CODES`. Every concrete error class exposes a `.code` property of this type.
### `registerIdempotencyErrorCodes()`
Callable registration function. Called automatically on import via side-effect; exported for test isolation.
## Implementation Notes
The codes use `defineCodes` from `@suaveplan/error` for namespace collision protection. The metadata object is frozen at module load — callers that need different HTTP mappings should wrap `IDEMPOTENCY_ERROR_METADATA` rather than mutating it. The `retryable` flag reflects idempotent safety: `CONFLICT` and `MISMATCH` are not retryable because the same key exists with different state, while `STORE_ERROR` and `LOCK_ACQUISITION_FAILED` are retryable because they are transient.

View File

@@ -0,0 +1,97 @@
# `idempotency-error`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error hierarchy for `@suaveplan/idempotency`. All errors extend `SuaveplanError` from `@suaveplan/error`, giving downstream consumers (loggers, OTel adapters, HTTP problem-detail responders, wire serializers) a uniform error shape without extra glue code. Stable `IDEMPOTENCY.*` error codes allow machine-readable error classification across service boundaries.
## Features
- `IdempotencyError` abstract base extends `SuaveplanError<TCode>` narrowed to `IdempotencyCode`
- `IDEMPOTENCY_CODES` frozen constant for all stable code strings
- `IdempotencyConflictError` — in-flight duplicate request detected; operation pending
- `IdempotencyMismatchError` — same key reused with different request payload fingerprint
- `LockAcquisitionError` — distributed lock could not be acquired after N retries
- `VersionConflictError` — key versioning conflict when strategy is `'reject'`
- Typed context accessors (`key`, `attempts`, fingerprints) for ergonomic error handling
## Usage: Basic Example
```ts
import { IdempotencyConflictError, IdempotencyMismatchError } from "@suaveplan/idempotency/errors";
try {
await guard.execute(key, handler);
} catch (err) {
if (err instanceof IdempotencyConflictError) {
// safe to retry after TTL
console.warn("Already processing key:", err.key);
}
}
```
## Usage: Advanced Example
```ts
import { LockAcquisitionError, VersionConflictError } from "@suaveplan/idempotency/errors";
import { walkCauses } from "@suaveplan/error";
try {
await distributedGuard.execute(key, payload, handler);
} catch (err) {
if (err instanceof LockAcquisitionError) {
console.error(`Lock failed after ${err.attempts} attempts for key ${err.key}`);
}
// Traverse cause chain — works with SuaveplanError natively
for (const cause of walkCauses(err)) {
console.debug("cause:", cause.message);
}
}
```
## API Reference
### `IDEMPOTENCY_CODES`
Frozen record mapping short names to fully qualified `IDEMPOTENCY.*` codes:
| Key | Code |
|-----|------|
| `CONFLICT` | `IDEMPOTENCY.CONFLICT` |
| `MISMATCH` | `IDEMPOTENCY.MISMATCH` |
| `STORE_ERROR` | `IDEMPOTENCY.STORE_ERROR` |
| `LOCK_ACQUISITION_FAILED` | `IDEMPOTENCY.LOCK_ACQUISITION_FAILED` |
| `KEY_GENERATION_FAILED` | `IDEMPOTENCY.KEY_GENERATION_FAILED` |
| `VERSION_CONFLICT` | `IDEMPOTENCY.VERSION_CONFLICT` |
| `KEY_EXPIRED` | `IDEMPOTENCY.KEY_EXPIRED` |
| `KEY_INVALID` | `IDEMPOTENCY.KEY_INVALID` |
### `IdempotencyConflictError`
- **Constructor**: `(key: string, options?: SuaveplanErrorOptions)`
- **Properties**: `.key: string`
- **Code**: `"IDEMPOTENCY.CONFLICT"`
### `IdempotencyMismatchError`
- **Constructor**: `(key: string, options?: SuaveplanErrorOptions)`
- **Properties**: `.key: string`
- **Code**: `"IDEMPOTENCY.MISMATCH"`
### `LockAcquisitionError`
- **Constructor**: `(key: string, attempts: number, options?: SuaveplanErrorOptions)`
- **Properties**: `.key: string`, `.attempts: number`
- **Code**: `"IDEMPOTENCY.LOCK_ACQUISITION_FAILED"`
### `VersionConflictError`
- **Constructor**: `(key: string, existingFingerprint: string, newFingerprint: string, options?: SuaveplanErrorOptions)`
- **Properties**: `.key: string`
- **Code**: `"IDEMPOTENCY.VERSION_CONFLICT"`
## Implementation Notes
All concrete errors pass a literal `messageKey` string (`idempotency.errors.*`) that the i18n resolver in `@suaveplan/error` can translate if a resolver is configured; otherwise the key is used as the human-readable message. Context is attached via `options.context` and surfaced through the `SuaveplanError.context` property — never interpolated into the raw message string.

View File

@@ -0,0 +1,109 @@
# `idempotency-guard`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `IdempotencyGuard` class — the core single-node idempotency primitive for `@suaveplan/idempotency`. It wraps arbitrary async operations and ensures each is executed **at most once** per key, returning the cached result on repeat invocations. A full `pending → completed | failed` state machine enables safe concurrent retries and fingerprint-based mismatch detection.
## Features
- Full lifecycle state machine: `pending` → `completed | failed`
- Cache hit: returns the stored response without re-executing the function
- Conflict detection: throws `IdempotencyConflictError` for in-flight duplicates
- Fingerprint mismatch: throws `IdempotencyMismatchError` when payload changes
- Lock timeout: automatically re-executes if a `pending` record is stale
- `AbortSignal` support: cancels before the store lookup or before execution
- Injectable `clock` for deterministic testing
- Custom `fingerprintFn` for domain-specific payload hashing
- `has`, `revoke`, and `cleanup` management utilities
## Usage: Basic Example
```ts
import { IdempotencyGuard } from "@suaveplan/idempotency/guard";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
const guard = new IdempotencyGuard({
store: new MemoryIdempotencyStore(),
ttlMs: 3_600_000, // 1 hour
});
// First call: executes handler and caches result
const result1 = await guard.execute("order-42", () => createOrder());
// Second call with same key: returns cached result immediately
const result2 = await guard.execute("order-42", () => createOrder());
assert(result1 === result2);
```
## Usage: Advanced Example
```ts
import { IdempotencyGuard, IdempotencyConflictError } from "@suaveplan/idempotency/guard";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
const guard = new IdempotencyGuard({
store: new MemoryIdempotencyStore(),
ttlMs: 86_400_000,
lockTimeoutMs: 10_000,
clock: () => Date.now(),
});
// With fingerprinting + AbortSignal
const controller = new AbortController();
try {
const result = await guard.execute(
"order-99",
() => chargeCard(payload),
{ input: payload, signal: controller.signal }
);
} catch (err) {
if (err instanceof IdempotencyConflictError) {
// key is pending — safe to retry after lock TTL
}
}
```
## API Reference
### `new IdempotencyGuard(config: IdempotencyGuardConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `store` | `IdempotencyStoreAdapter` | required | Persistence adapter |
| `ttlMs` | `number` | `86_400_000` | Record TTL (24 h) |
| `lockTimeoutMs` | `number` | `30_000` | Pending record timeout (30 s) |
| `fingerprintFn` | `(input: unknown) => string` | `fingerprintPayload` | Payload hasher |
| `clock` | `() => number` | `Date.now` | Monotonic clock |
### `execute<T>(key, fn, opts?): Promise<T>`
Executes `fn` idempotently under `key`.
**Params:**
- `opts.input?: unknown` — payload used for fingerprint mismatch detection
- `opts.signal?: AbortSignal` — aborts before the store lookup or before `fn` is called
**Throws:** `IdempotencyConflictError` | `IdempotencyMismatchError` | `DOMException` (abort)
### `has(key): Promise<boolean>`
Returns `true` if a non-expired record exists for `key`.
### `revoke(key): Promise<boolean>`
Removes the record; returns `true` if it existed.
### `cleanup(): Promise<number>`
Removes all expired records. Returns the count removed.
## Implementation Notes
- The state machine always writes a `pending` record before executing `fn`, guaranteeing crash recovery on re-execution.
- `lockTimeoutMs` defines the window during which a `pending` record blocks concurrent callers. After expiry the guard assumes the in-flight operation crashed and re-executes.
- `failed` records are re-executable — the guard intentionally does not cache failures, enabling safe at-most-once semantics while allowing retry.
- The `clock` injection lets tests advance time deterministically without patching `Date.now` globally.

View File

@@ -0,0 +1,99 @@
# `key-generator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides idempotency key generation and payload fingerprinting utilities for `@suaveplan/idempotency`. Key generation uses `globalThis.crypto.randomUUID()` for random keys and a djb2 hash for deterministic composite keys. Fingerprinting enables mismatch detection: the same idempotency key reused with a different payload fingerprint triggers an `IdempotencyMismatchError`.
## Features
- `generateIdempotencyKey(config?)` — random UUID-based key with optional prefix/separator
- `compositeKey(...parts)` — deterministic djb2 hash of ordered string parts; bounded-length output
- `fingerprintPayload(payload)` — synchronous djb2 fingerprint with stable JSON serialization (key-order independent)
- `fingerprintPayloadAsync(payload)` — SHA-256 fingerprint via `globalThis.crypto.subtle` for cryptographic strength
- Recursive stable `stableStringify` ensuring `{ a: 1, b: 2 }` === `{ b: 2, a: 1 }`
- Approved use of `globalThis.crypto.randomUUID` and `globalThis.crypto.subtle` (see `tests/no-disallowed.test.ts`)
## Usage: Basic Example
```ts
import {
generateIdempotencyKey,
compositeKey,
fingerprintPayload,
} from "@suaveplan/idempotency/key";
// Random key with optional prefix
const key = generateIdempotencyKey({ prefix: "order" });
// e.g. "order:550e8400-e29b-41d4-a716-446655440000"
// Deterministic key from known identifiers
const k = compositeKey("user-42", "order-99");
// djb2 hex of "user-42:order-99"
// Payload fingerprint for mismatch detection
const fp = fingerprintPayload({ amount: 100, currency: "USD" });
```
## Usage: Advanced Example
```ts
import {
compositeKey,
fingerprintPayloadAsync,
generateIdempotencyKey,
} from "@suaveplan/idempotency/key";
// SHA-256 fingerprint for high-value operations
async function guardPayment(userId: string, body: PaymentBody) {
const key = compositeKey("payment", userId, body.orderId);
const fp = await fingerprintPayloadAsync(body);
return guard.execute(key, () => processPayment(body), body);
}
// Prefixed keys for multi-tenant systems
const tenantKey = generateIdempotencyKey({
prefix: `tenant:${tenantId}`,
separator: ":",
});
```
## API Reference
### `generateIdempotencyKey(config?: KeyGeneratorConfig): string`
Generates a random key using `globalThis.crypto.randomUUID()`. If `config.prefix` is set, it is prepended with the separator (default `':'`).
**Params:**
- `config.prefix?: string` — optional prefix string
- `config.separator?: string` — separator between parts (default `':'`)
**Returns:** `string` — a unique key
### `compositeKey(...parts: string[]): string`
Creates a deterministic key from one or more ordered string parts. Internally joins with `':'` and applies djb2 hashing to produce a fixed-length hex string.
**Throws:** `RangeError` when called with zero parts.
### `fingerprintPayload(payload: unknown): string`
Synchronous djb2 fingerprint of any JSON-serializable payload. Object key ordering is normalized before hashing.
**Returns:** `string` — 8-character hex fingerprint.
### `fingerprintPayloadAsync(payload: unknown): Promise<string>`
SHA-256 fingerprint using `globalThis.crypto.subtle.digest`. Requires an async context.
**Returns:** `Promise<string>` — 64-character lower-case hex string.
## Implementation Notes
- djb2 was chosen for synchronous fingerprinting due to its simplicity and zero-dependency nature. It is **not** collision-resistant for adversarial inputs; use `fingerprintPayloadAsync` where cryptographic strength matters.
- `stableStringify` sorts object keys recursively, making `{ b: 2, a: 1 }` and `{ a: 1, b: 2 }` produce identical digests. Arrays preserve order.
- `globalThis.crypto.randomUUID()` is available in Node 15+, Bun, Deno, and all modern browsers without any polyfill.
- `globalThis.crypto.subtle` is available in the same environments. It is **not** available in some older edge runtimes; fall back to `fingerprintPayload` if needed.

View File

@@ -0,0 +1,106 @@
# `idempotency-metrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `IdempotencyMetrics` class for collecting, aggregating, and reporting idempotency operation statistics. Supports both aggregate lifetime totals and rolling-window statistics for real-time dashboards. Optionally emits OpenTelemetry counters and histograms when a `Meter` is injected, enabling deep observability in OTel-instrumented deployments.
## Features
- Aggregate counters: `totalRequests`, `cacheHits`, `cacheMisses`, `conflicts`, `mismatches`
- Derived metrics: `hitRate`, `avgExecutionDurationMs`, `duplicateRequests`
- Rolling window snapshot via `getWindowedSnapshot()` — configurable window (default: 5 min)
- OTel counters: `idempotency.request.total`, `idempotency.store.operation.total`, `idempotency.lock.acquisition.total`
- OTel histogram: `idempotency.execution.duration_ms`
- Periodic reporting via configurable `MetricsReporter` callback
- `reset()` for isolated test scenarios
- Fluent `withMeter(meter)` for OTel wiring
## Usage: Basic Example
```ts
import { IdempotencyMetrics } from "@suaveplan/idempotency/metrics";
const metrics = new IdempotencyMetrics();
metrics.recordHit();
metrics.recordMiss();
metrics.recordDuration(45);
const snap = metrics.getSnapshot();
console.log(snap.hitRate); // 0.5
console.log(snap.avgExecutionDurationMs); // 45
```
## Usage: Advanced Example
```ts
import { IdempotencyMetrics } from "@suaveplan/idempotency/metrics";
import { metrics as otelMetrics } from "@opentelemetry/api";
const meter = otelMetrics.getMeter("my-service");
const idempotencyMetrics = new IdempotencyMetrics({
reporter: (snap) => logger.info("idempotency snapshot", snap),
reportIntervalMs: 30_000,
windowMs: 60_000, // 1-minute rolling window
}).withMeter(meter);
idempotencyMetrics.startReporting();
// ... application runs ...
idempotencyMetrics.stopReporting();
```
## API Reference
### `new IdempotencyMetrics(config?: IdempotencyMetricsConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `reporter` | `MetricsReporter` | — | Periodic snapshot callback |
| `reportIntervalMs` | `number` | `60_000` | Reporting interval (ms) |
| `windowMs` | `number` | `300_000` | Rolling window size (ms) |
### `withMeter(meter: Meter): this`
Wires up OTel instruments. Chainable.
### `recordHit() / recordMiss() / recordConflict() / recordMismatch(): void`
Increment the corresponding counter and push a timestamped event.
### `recordDuration(durationMs: number): void`
Record execution latency; updates `avgExecutionDurationMs` and the OTel histogram.
### `recordStoreOperation(operation): void`
Increments `idempotency.store.operation.total` with operation label.
### `recordLockAcquisition(result): void`
Increments `idempotency.lock.acquisition.total` with result label.
### `getSnapshot(): IdempotencyMetricsSnapshot`
Returns aggregate (lifetime) counters and derived metrics.
### `getWindowedSnapshot(): IdempotencyMetricsSnapshot`
Returns metrics for the configured rolling window only. Prunes stale events.
### `startReporting() / stopReporting(): void`
Start/stop the periodic reporter interval.
### `reset(): void`
Clears all counters and events.
## Implementation Notes
- Events are kept in a `TimestampedEvent[]` array; `pruneEvents` trims entries older than the rolling window on `getWindowedSnapshot`.
- OTel instruments are lazily created; if `withMeter` is never called, no OTel dependency is exercised at runtime.
- `stopReporting` is idempotent and safe to call multiple times.
- `reset` does not stop the reporter timer; call `stopReporting` first if needed.

View File

@@ -0,0 +1,90 @@
# `idempotency-middleware`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a framework-agnostic idempotency middleware factory that wraps request/response handlers with deduplication logic. `createIdempotencyMiddleware` reads an idempotency key from the request, checks the store, and returns the cached response or executes the handler and caches the result. Works with Express, Fastify, Hono, Fetch API, or any custom request/response abstraction.
## Features
- Generic `TReq` / `TRes` type parameters for full type safety
- Configurable key extraction from any request type
- Per-operation TTL overrides for granular cache expiration
- Fingerprint mismatch detection before returning cached responses
- `transformResponse` hook for adapter-specific response transformations
- Bypass behaviour: if no key is extracted, the handler runs without caching
- State machine write (`pending` → `completed | failed`) before executing handler
## Usage: Basic Example
```ts
import { createIdempotencyMiddleware } from "@suaveplan/idempotency/middleware";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
interface MyRequest { headers: Record<string, string>; body: unknown }
interface MyResponse { status: number; data: unknown }
const middleware = createIdempotencyMiddleware<MyRequest, MyResponse>({
store: new MemoryIdempotencyStore(),
extractKey: (req) => req.headers["idempotency-key"],
});
const result = await middleware(req, async (r) => ({ status: 200, data: await process(r.body) }));
console.log(result.cached); // true on second call with same key
```
## Usage: Advanced Example
```ts
const middleware = createIdempotencyMiddleware<MyRequest, MyResponse>({
store,
extractKey: (req) => req.headers["idempotency-key"],
extractInput: (req) => req.body,
extractOperation: (req) => req.headers["x-operation"],
ttlOverrides: {
"create-payment": 86_400_000, // 24h
"send-email": 3_600_000, // 1h
},
transformResponse: (res) => ({ ...res, fromCache: true }),
});
```
## API Reference
### `createIdempotencyMiddleware<TReq, TRes>(config): Handler`
Returns `async (request: TReq, handler: (req: TReq) => Promise<TRes>) => Promise<MiddlewareResult<TRes>>`.
**Config options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `store` | `IdempotencyStoreAdapter` | required | Record persistence |
| `extractKey` | `(req) => string \| undefined` | required | Key extraction |
| `extractInput` | `(req) => unknown` | — | Input for fingerprinting |
| `ttlMs` | `number` | `86_400_000` | Default TTL |
| `ttlOverrides` | `Record<string, number>` | — | Per-operation TTL |
| `extractOperation` | `(req) => string \| undefined` | — | Operation name for TTL lookup |
| `fingerprintFn` | `fn` | `fingerprintPayload` | Custom hasher |
| `transformResponse` | `(res: TRes) => TRes` | — | Response transformer |
### `MiddlewareResult<TRes>`
```ts
interface MiddlewareResult<TRes> {
response: TRes;
cached: boolean;
key: string;
}
```
**Throws:** `IdempotencyMismatchError` when the request fingerprint differs from the cached fingerprint.
## Implementation Notes
- The middleware always writes a `pending` record before calling the handler, ensuring crash recovery on retry.
- `failed` records do not block retries — if the handler throws, subsequent requests with the same key re-execute the handler.
- Bypassed requests (no key extracted) run the handler directly without touching the store.
- `transformResponse` is applied both on cache hit and on fresh execution, so the response shape is always consistent.

View File

@@ -0,0 +1,78 @@
# `database-store`
## Purpose
Provides a persistent `IdempotencyStoreAdapter<T>` backed by any relational database via the `IdempotencyDatabaseAdapter` interface. Stores idempotency records in a single flat table with JSON-serialised response columns. Auto-creates the table on first use when `autoCreate` is `true` (default), making zero-setup integration painless.
## Features
- Wraps any database that implements `IdempotencyDatabaseAdapter` (SQLite, PostgreSQL, MySQL, etc.)
- Auto-creates `idempotency_records` table on first use (configurable)
- Upsert semantics: `set` inserts or updates existing rows correctly
- JSON serialisation of arbitrary response payloads
- `cleanup(beforeTimestamp)` deletes expired rows and returns the removed count
- Generic `<T>` parameter for typed response payloads
- Injectable table name for multi-tenant or schema-isolated deployments
## Usage: Basic Example
```ts
import { DatabaseIdempotencyStore } from "@suaveplan/idempotency/store";
import type { IdempotencyDatabaseAdapter } from "@suaveplan/idempotency/store";
declare const myDbAdapter: IdempotencyDatabaseAdapter;
const store = new DatabaseIdempotencyStore({ adapter: myDbAdapter });
await store.set("order-123", {
key: "order-123", status: "completed", response: { id: 1 },
createdAt: Date.now(), updatedAt: Date.now(), expiresAt: Date.now() + 86400_000,
});
```
## Usage: Advanced Example
```ts
import { DatabaseIdempotencyStore } from "@suaveplan/idempotency/store";
const store = new DatabaseIdempotencyStore({
adapter: pgAdapter,
tableName: "payments.idempotency",
autoCreate: false, // table managed by migrations
});
// Periodic cleanup of expired records
setInterval(() => store.cleanup(Date.now()), 60_000);
```
## API Reference
### `new DatabaseIdempotencyStore<T>(config: DatabaseIdempotencyStoreConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `adapter` | `IdempotencyDatabaseAdapter` | required | Database client adapter |
| `tableName` | `string` | `"idempotency_records"` | Table name |
| `autoCreate` | `boolean` | `true` | Auto-create table if absent |
### `get(key): Promise<IdempotencyRecord<T> | undefined>`
Queries the table for the given key. Returns `undefined` if not found.
### `set(key, record): Promise<void>`
Upserts the record: updates if key exists, inserts otherwise.
### `delete(key): Promise<boolean>`
Deletes the row; returns `true` if a row was removed.
### `cleanup(beforeTimestamp): Promise<number>`
Deletes all rows with `expires_at < beforeTimestamp`. Returns the count removed.
## Implementation Notes
- `ensureTable` runs once per instance (guarded by `initialized` flag); it is idempotent and can be disabled by setting `autoCreate: false`.
- JSON serialisation uses `JSON.parse` / `JSON.stringify`; circular references or non-serializable values will throw at `set` time.
- The `protected` visibility on `adapter`, `tableName`, `rowToRecord`, and `ensureTable` allows subclasses to extend without full re-implementation.
- No connection pooling or transaction management is performed — that responsibility belongs to the injected adapter.

View File

@@ -0,0 +1,85 @@
# `memory-store`
## Purpose
Provides an in-memory implementation of `IdempotencyStoreAdapter<T>` backed by a `Map`. It is the default store for unit tests, local development, and single-process deployments that do not need persistence across restarts. Records are lazily expired on `get` and can be batch-pruned via `cleanup`.
## Features
- O(1) `get` / `set` / `delete` via a `Map` keyed by idempotency key
- Lazy TTL expiration: expired records are detected and removed on `get`
- `cleanup(beforeTimestamp)` for batch removal of expired records
- Injectable `clock` function for deterministic testing with `FakeClock`
- `size` getter and `clear()` for test assertions and store resets
- Generic `<T>` type parameter propagated through stored records
## Usage: Basic Example
```ts
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
import { IdempotencyGuard } from "@suaveplan/idempotency/guard";
const store = new MemoryIdempotencyStore();
const guard = new IdempotencyGuard({ store, ttlMs: 3600_000 });
const result = await guard.execute("key-1", () => fetchUser(1));
console.log(store.size); // 1
```
## Usage: Advanced Example
```ts
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
// Inject a fake clock for deterministic TTL testing
let now = 1_000_000;
const fakeClock = () => now;
const store = new MemoryIdempotencyStore(fakeClock);
await store.set("k", {
key: "k", status: "completed", response: 42,
createdAt: now, updatedAt: now, expiresAt: now + 1000,
});
// Advance clock past TTL
now += 5000;
const result = await store.get("k"); // undefined — expired
```
## API Reference
### `new MemoryIdempotencyStore<T>(clock?: Clock)`
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `clock` | `() => number` | `Date.now` | Monotonic clock for TTL checks |
### `get(key): Promise<IdempotencyRecord<T> | undefined>`
Returns the record if it exists and has not expired. Deletes and returns `undefined` for expired records.
### `set(key, record): Promise<void>`
Upserts a record unconditionally.
### `delete(key): Promise<boolean>`
Removes the record; returns `true` if it existed.
### `cleanup(beforeTimestamp): Promise<number>`
Iterates all stored records and removes those whose `expiresAt <= beforeTimestamp`. Returns the count removed.
### `size: number`
Current number of records in the map, including expired entries not yet pruned.
### `clear(): void`
Immediately removes all records.
## Implementation Notes
- The `clock` is injected at construction time rather than captured from `Date.now` directly, enabling fully deterministic unit tests without patching globals.
- Lazy expiration keeps `get` at O(1) while `cleanup` is O(n) — call it periodically in long-running processes.
- The `protected` visibility on `records` and `clock` allows subclasses to extend without re-implementing the core logic.

View File

@@ -0,0 +1,76 @@
# `redis-store`
## Purpose
Provides a Redis-backed `IdempotencyStoreAdapter<T>` with automatic key expiration using Redis native TTL. By accepting a generic `RedisClientAdapter` interface rather than a concrete library, this module integrates with `ioredis`, `redis`, Upstash Redis, or any compatible client without coupling to a specific driver.
## Features
- JSON-serialised records stored as Redis string values
- Automatic TTL set on every `set()` call based on `record.expiresAt`
- Fallback TTL (`defaultTtlSeconds`) for records whose expiry is in the past
- Configurable key prefix for namespace isolation (multi-tenant)
- `cleanup()` scans expired records server-side for manual purging
- Generic `<T>` type parameter for typed payloads
## Usage: Basic Example
```ts
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
import type { RedisClientAdapter } from "@suaveplan/idempotency/store";
declare const redisAdapter: RedisClientAdapter; // your ioredis wrapper
const store = new RedisIdempotencyStore({
client: redisAdapter,
prefix: "app:idempotency:",
});
```
## Usage: Advanced Example
```ts
import { RedisIdempotencyStore } from "@suaveplan/idempotency/store";
const store = new RedisIdempotencyStore({
client: redisAdapter,
prefix: `tenant:${tenantId}:idempotency:`,
defaultTtlSeconds: 3600, // 1 hour fallback
});
// Manual cleanup (most records expire via Redis TTL automatically)
const removed = await store.cleanup(Date.now());
console.log(`Manually removed ${removed} stale records`);
```
## API Reference
### `new RedisIdempotencyStore<T>(config: RedisIdempotencyStoreConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `client` | `RedisClientAdapter` | required | Redis client adapter |
| `prefix` | `string` | `"idempotency:"` | Key namespace prefix |
| `defaultTtlSeconds` | `number` | `86400` | Fallback TTL when expiresAt is past |
### `get(key): Promise<IdempotencyRecord<T> | undefined>`
Fetches and JSON-parses the record. Returns `undefined` if not found (key expired or never set).
### `set(key, record): Promise<void>`
Serialises the record as JSON and stores it with a TTL computed from `record.expiresAt - Date.now()`.
### `delete(key): Promise<boolean>`
Calls `DEL` on the prefixed key; returns `true` if a key was removed.
### `cleanup(beforeTimestamp): Promise<number>`
Scans all keys under the prefix, parses each, and deletes those with `expiresAt < beforeTimestamp`. Returns the count removed.
## Implementation Notes
- Redis TTL handles the common expiration case automatically; `cleanup` is only needed for records that were not expired by Redis (e.g. records with very long TTLs where early manual deletion is desired).
- `scan` is used rather than `KEYS` to avoid blocking the Redis event loop in production.
- JSON serialisation assumes records are fully serialisable; `Date` objects should be stored as timestamps (numbers).

View File

@@ -0,0 +1,76 @@
# idempotency.types
## Purpose
Shared type definitions that act as the contract between the `IdempotencyGuard`, the pluggable store adapters, the distributed-lock providers, the middleware layer, and the metrics collector. Keeping these types in a single flat file means every subsystem imports the same shapes and no subsystem invents its own record format, which is critical for interoperability between in-memory, Redis, and database-backed stores.
## Features
- Zero runtime cost — all exports are pure TypeScript types
- `IdempotencyRecord<T>` captures the full lifecycle (`pending | completed | failed`) with request-body fingerprint for mismatch detection and optional version counter
- `IdempotencyStoreAdapter<T>` describes the minimal CRUD + `cleanup` surface every backend must implement
- `ConflictResolutionStrategy` encodes the three supported policies for same-key-different-payload collisions: `reject`, `overwrite`, `version`
- `IdempotencyGuardConfig` gathers store, TTL, conflict strategy, clock injection, and optional metrics into one strongly typed options bag
- `DistributedIdempotencyGuardConfig` and `LockProvider` extend the base shape with lease semantics for multi-node deployments
## Usage: Basic Example
```ts
import type {
IdempotencyRecord,
IdempotencyStatus,
} from "@suaveplan/idempotency/types/idempotency.types";
const record: IdempotencyRecord<{ orderId: string }> = {
key: "order-abc",
status: "completed",
response: { orderId: "ord_1" },
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 86_400_000,
};
const status: IdempotencyStatus = record.status;
```
## Usage: Advanced Example
```ts
import type {
IdempotencyStoreAdapter,
IdempotencyGuardConfig,
} from "@suaveplan/idempotency/types/idempotency.types";
class MemoryStore<T> implements IdempotencyStoreAdapter<T> {
private map = new Map<string, import("@suaveplan/idempotency/types/idempotency.types").IdempotencyRecord<T>>();
async get(key: string) { return this.map.get(key); }
async set(key: string, r: any) { this.map.set(key, r); }
async delete(key: string) { return this.map.delete(key); }
async cleanup(before: number) {
let n = 0;
for (const [k, v] of this.map) if (v.expiresAt < before) { this.map.delete(k); n++; }
return n;
}
}
const config: IdempotencyGuardConfig = {
store: new MemoryStore(),
ttlMs: 3_600_000,
conflictStrategy: "reject",
clock: () => Date.now(),
};
```
## API Reference
- `Clock` — `() => number` alias for ms-since-epoch.
- `IdempotencyStatus` — `"pending" | "completed" | "failed"`.
- `IdempotencyRecord<T>` — full stored record including `fingerprint` and optional `version`.
- `ConflictResolutionStrategy` — `"reject" | "overwrite" | "version"`.
- `IdempotencyStoreAdapter<T>` — `get` / `set` / `delete` / `cleanup` interface.
- `IdempotencyGuardConfig` — top-level guard configuration.
- `DistributedIdempotencyGuardConfig` / `LockProvider` — distributed-mode extensions.
## Implementation Notes
The `fingerprint` field is an optional SHA-256 hex of the request body, computed at the middleware layer via `fingerprintPayload` and compared against stored records to detect same-key-different-payload conflicts early. `version` is only populated when `conflictStrategy: "version"` is active. The `cleanup(beforeTimestamp)` contract is intentionally batch-oriented so Redis and SQL adapters can implement it as a single ranged `ZREMRANGEBYSCORE` or `DELETE ... WHERE expires_at < ?` rather than looping per key.

View File

@@ -0,0 +1,106 @@
# `key-versioning`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides multi-version idempotency record management for scenarios where the same key may legitimately be reused with different input payloads over its lifetime. `KeyVersioning` wraps the idempotency store and enforces a configurable `ConflictResolutionStrategy`, maintaining an in-process version history for audit, rollback, and debugging.
## Features
- Three conflict strategies: `reject` (default), `overwrite`, `version`
- In-process version history with configurable `maxVersions` cap
- `resolve(key, input, fn?)` returns whether to execute and which version to write
- `addVersionEntry` records a snapshot into the history
- `getHistory`, `getLatestVersion`, `clearHistory` for inspection and cleanup
- Generic `<T>` parameter for typed version entries
- Compatible with any `IdempotencyStoreAdapter`
## Usage: Basic Example
```ts
import { KeyVersioning } from "@suaveplan/idempotency/versioning";
import { MemoryIdempotencyStore } from "@suaveplan/idempotency/store";
const versioning = new KeyVersioning({
store: new MemoryIdempotencyStore(),
strategy: "version",
maxVersions: 5,
});
const { version, shouldExecute } = await versioning.resolve("order-42", payload);
if (shouldExecute) {
const result = await chargeCard(payload);
versioning.addVersionEntry("order-42", {
version,
fingerprint: fingerprintPayload(payload),
response: result,
status: "completed",
createdAt: Date.now(),
});
}
```
## Usage: Advanced Example
```ts
import { KeyVersioning, VersionConflictError } from "@suaveplan/idempotency/versioning";
const versioning = new KeyVersioning({ store, strategy: "reject" });
try {
const { shouldExecute } = await versioning.resolve("key", differentPayload);
} catch (err) {
if (err instanceof VersionConflictError) {
// Log fingerprint conflict
console.warn("Version conflict for key:", err.key);
}
}
// Inspect history
const history = versioning.getHistory("order-42");
console.log("Versions:", history.map((e) => e.version));
```
## API Reference
### `new KeyVersioning<T>(config: KeyVersioningConfig)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `store` | `IdempotencyStoreAdapter` | required | Record persistence |
| `strategy` | `ConflictResolutionStrategy` | `"reject"` | Conflict strategy |
| `maxVersions` | `number` | `10` | Max history entries per key |
### `resolve(key, input, fingerprintFn?): Promise<{ version: number; shouldExecute: boolean }>`
Checks the store and applies the conflict strategy. Returns:
- `shouldExecute: true` — caller should invoke `fn`
- `shouldExecute: false` — cached result can be returned
**Throws:** `VersionConflictError` when strategy is `'reject'` and a conflict is detected.
### `addVersionEntry(key, entry): void`
Appends a `VersionEntry<T>` to the in-process history. Trims to `maxVersions` if exceeded.
### `getHistory(key): VersionEntry<T>[]`
Returns all recorded version entries for the key, oldest first.
### `getLatestVersion(key): number`
Returns the latest version number, or `0` if no history exists.
### `clearHistory(key): void`
Removes all in-process history for the key.
## Implementation Notes
- Version history is in-process only; it is not persisted to the store. On process restart, history is empty even if the store contains records with version numbers.
- `maxVersions` evicts the oldest entries from the left of the array, so `getLatestVersion` always reflects the most recent.
- The `overwrite` strategy calls `store.delete(key)` before returning, ensuring the next `set` creates a fresh record at version `1`.
- `VersionConflictError` carries both `existingFingerprint` and `newFingerprint` in its context for downstream diagnostics.