Deterministic idempotency manager for JSON payloads with pluggable persistence. Generate stable idempotency keys, prevent duplicate work, and keep canonical payloads for auditing when you need them.
- Getting Started
- Installation
- Quick Tour
- How It Works
- API Reference
- Storage Adapters
- Utilities
- Error Reference
- Developing and Testing
- Need Help?
Use IdempotencyManager to protect any workflow where repeated payloads should only be processed once. The manager stores a marker the first time it sees a payload, then lets you decide what to do when the payload returns.
import { createClient } from "redis";
import { IdempotencyManager, RedisIdempotencyStore } from "steadykey";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const store = new RedisIdempotencyStore(redis);
const manager = new IdempotencyManager(store, {
keyPrefix: "checkout",
defaultTtlSeconds: 3600,
storeCanonicalPayload: true,
});
const payload = { orderId: "order-123", total: 42.5 };
const registration = await manager.register(payload, {
metadata: { workflow: "checkout" },
});
if (registration.stored) {
// First encounter: perform the expensive work and persist your result.
// Later you can call manager.clear(id) or manager.updateTtl(id, ttl) when done.
} else {
// Duplicate payload: skip the work and reuse the prior result.
}For quick checks, call steadyKey(payload) to get a deterministic hash without creating a manager.
Install the core package plus the adapter dependencies your project uses.
npm install steadykey
# Optional adapter helpers
npm install redis # RedisIdempotencyStore
npm install memcached # MemcachedIdempotencyStore
npm install pg # PostgresIdempotencyStore
npm install mysql2 # MySqlIdempotencyStore
npm install mongodb # MongoIdempotencyStore
npm install better-sqlite3 # SqliteIdempotencyStore
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb # DynamoDbIdempotencyStoreIdempotencyManagerorchestrates key generation, storage, TTL management, and collision detection.- Storage adapters implement the lightweight
IdempotencyStoreinterface so you can bring your own persistence layer. - Utility helpers (
steadyKey,canonicalize,hashCanonicalValue) let you generate and inspect deterministic payload hashes outside of a full manager. - Typed results explain whether the current call was stored (
stored: true) or matches an existing record (stored: false).
- Payloads are canonicalized before hashing. Object keys are sorted,
undefinedvalues are dropped, Maps/Sets/BigInts/Buffers are normalized, and Dates become ISO strings. Identical logical payloads always hash to the same value. - The chosen hash algorithm (
sha256by default) creates an idempotency identifier. IdempotencyManagerprefixes the identifier (defaultidempotency:) to build the storage key.- The storage adapter stores the record if the key is not already present. When the key exists, the stored payload hash is compared to guard against silent collisions.
- TTLs come either from the manager constructor (
defaultTtlSeconds), from each registration call, or can be removed entirely by passingnullor0.
- Returns a deterministic string hash for any JSON-like payload.
options.hashAlgorithmaccepts "sha256" (default) or "sha512".
import { steadyKey } from "steadykey";
const key = steadyKey({ customerId: 123, items: ["A", "B"] });
// same key every call, regardless of object key orderconst manager = new IdempotencyManager(store, options?);storemust satisfy theIdempotencyStoreinterface (see adapters below).options.keyPrefix(string) defaults to "idempotency". Trailing colons are trimmed automatically.options.defaultTtlSeconds(positive integer |null|undefined) sets the fallback TTL.nullorundefinedmeans no expiration.options.hashAlgorithmoverrides the hashing algorithm used for the manager ("sha256" or "sha512").options.storeCanonicalPayloadstores the canonical JSON alongside the record to help with auditing or debugging.
Returns the deterministic hash for a payload using the manager's algorithm. Useful if you want to build keys or pre-compute lookups.
Combines keyPrefix and an id into the stored key. The legacy alias buildRedisKey is still available but deprecated.
Stores a record the first time the payload is seen.
const result = await manager.register(payload, {
ttlSeconds: 900,
metadata: { workflow: "checkout" },
storeCanonicalPayload: false,
});
if (result.stored) {
// process payload
}options.ttlSecondsoverrides the manager default for this call.options.metadataaccepts any JSON-serializable value (objects, strings, numbers, etc.) and is stored with the record.options.storeCanonicalPayloadtoggles payload storage per call.- Result shape:
{ id, key, stored, record }whererecordreflects the stored data (including metadata and canonical payload when present).
Fetch existing records without registering anything. Returns null when no record is found.
const previous = await manager.lookupByPayload(payload);
if (previous) {
console.log(previous.record.metadata);
}- Lookup results include
{ id, key, record, ttlSeconds }wherettlSecondscomes from the backing store when available.
Refreshes, sets, or removes the TTL for an existing record. Pass null or 0 to make the record persistent. Throws when the key does not exist.
Deletes the stored record. Returns true when a record was removed.
IdempotencyRecord:{ id, payloadHash, createdAt, metadata?, canonicalPayload?, ttlSeconds? }IdempotencyRegistrationResult:{ id, key, stored, record }IdempotencyLookupResult:{ id, key, record, ttlSeconds? }HashAlgorithm: union of "sha256" | "sha512"
These types are exported from steadykey so you can annotate your code when TypeScript type safety matters.
Implement the IdempotencyStore interface if you need a bespoke persistence layer.
interface IdempotencyStore {
setIfAbsent(key: string, value: string, ttlSeconds: number | null): Promise<boolean>;
get(key: string): Promise<{ value: string; ttlSeconds?: number | null } | null>;
update(key: string, value: string, ttlSeconds: number | null): Promise<void>;
delete(key: string): Promise<boolean>;
}setIfAbsentmust behave atomically: only returntruewhen the key did not exist.getshould ignore expired entries and return their TTL when known.updatemust throw when the key is missing to avoid silently masking data issues.deleteshould return whether the key was removed.
- Lightweight Map-based implementation ideal for tests.
- Constructor accepts
{ now?: () => number }for deterministic time sources. - Exposes
advanceTime(milliseconds)to fast-forward expirations in tests.
import { InMemoryIdempotencyStore } from "steadykey";
const store = new InMemoryIdempotencyStore();
const manager = new IdempotencyManager(store);
store.advanceTime(5_000); // simulate clock jumps in unit tests- Wraps a
redisclient withset,get,ttl,persist, anddelmethods. - Pass TTLs via
EXso expirations are handled server-side. updateremoves TTLs whenttlSecondsisnull.
const redisStore = new RedisIdempotencyStore(redisClient);- Works with clients compatible with the
memcachednpm package. - TTL reporting is not available, so lookups return
ttlSeconds: undefined. - Uses
addfor atomic set-if-absent operations.
const memcachedStore = new MemcachedIdempotencyStore(memcachedClient);- Requires any client exposing a
query(sql, params)method (e.g.,pg.Pool). - Options:
{ tableName?: string, ensureTable?: boolean }. - Defaults to creating
steadykey_entrieswith anexpires_atindex. Disable auto-DDL withensureTable: false.
const pgStore = new PostgresIdempotencyStore(pool, {
tableName: "public.steadykey_entries",
});- Works with
mysql2/promiseconnections. - Options:
{ tableName?: string, ensureTable?: boolean, keyLength?: number }. - Auto-DDL creates an indexed table with configurable primary key length.
const mysqlStore = new MySqlIdempotencyStore(connection, {
tableName: "steadykey_entries",
keyLength: 128,
});- Accepts a MongoDB collection implementing
insertOne,findOne,updateOne,deleteOne, andcreateIndex. - Options:
{ ensureIndexes?: boolean }. Defaults to building a TTL index onexpiresAt.
const mongoStore = new MongoIdempotencyStore(collection, {
ensureIndexes: true,
});- Works with AWS SDK v3
DynamoDBDocumentClientor compatible clients exposingput,get,update, anddelete. - Options:
{ tableName: string, partitionKey?: string, valueAttribute?: string, ttlAttribute?: string, consistentRead?: boolean }. - Uses conditional writes for atomic inserts and stores TTL as epoch seconds when provided.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { DynamoDbIdempotencyStore } from "steadykey";
const client = new DynamoDBClient({});
const documentClient = DynamoDBDocumentClient.from(client);
const dynamoStore = new DynamoDbIdempotencyStore(documentClient, {
tableName: "steadykey_entries",
});- Compatible with synchronous libraries such as
better-sqlite3or async wrappers that match the minimal interface. - Options:
{ tableName?: string, ensureTable?: boolean }. - Automatically creates a table keyed by
keywith an index onexpires_at(epoch seconds).
const sqliteStore = new SqliteIdempotencyStore(sqliteDb, {
tableName: "steadykey_entries",
});canonicalize(value)returns the deterministic JSON string used for hashing. Useful for debugging when combined withstoreCanonicalPayload.hashCanonicalValue(canonicalValue, algorithm)hashes previously canonicalized JSON. This is exported for advanced integrations or to align custom tooling with Steadykey.
import { canonicalize, hashCanonicalValue } from "steadykey";
const canonical = canonicalize(payload);
const id = hashCanonicalValue(canonical, "sha512");IdempotencyError: thrown for invalid input or misconfigured stores.IdempotencyCollisionError: thrown when two different payloads attempt to reuse the same key.IdempotencySerializationError: wraps canonicalization or JSON serialization issues. Inspect the message for the underlying cause.
Always surface collisions and serialization errors in logs or metrics—they indicate data drift or payloads the hashing strategy cannot support yet.
- Run unit tests with
npm test(Vitest). - Build distributable bundles with
npm run build(outputs ESM, CJS, and type declarations underdist/). - Build once before running the Node examples under
examples/(they import fromdist/index.js). - When adding new storage backends, implement the
IdempotencyStorecontract and add adapter-specific tests undertests/.
- Open an issue or discussion in the repository with payload samples and adapter details.
- Pull requests are welcome—please include tests and update this README when the API surface changes.