@desolint/idempotency
v0.0.1
Published
Generic idempotency-key middleware for Express — pluggable storage, no Mongo/Redis dependency baked in. No root export — import only what you need: @desolint/idempotency/config, /services.
Maintainers
Readme
@desolint/idempotency
Generic idempotency-key middleware for Express. Detects duplicate requests (same user + method + path + params + query + body) and blocks retries of an in-flight or already-completed request.
No storage baked in. The package has zero dependency on Mongo, Redis,
or any Desolint package — you supply a small storage adapter (get/
create/update) wrapping whatever database you already use.
No root export. import ... from '@desolint/idempotency' resolves to
nothing — import only what you need:
@desolint/idempotency/config @desolint/idempotency/services| Subpath | What it's for |
| ----------- | ---------------------------------------------------------- |
| /config | initializeIdempotency — wire up your store + hooks once. |
| /services | idempotencyMiddleware() — the actual Express middleware. |
Requirements
- Node.js 22 or newer (declared in
engines) - npm 7 or newer — npm 7+ installs peer dependencies automatically
Install
npm install @desolint/idempotencyThat is all you need on npm 7+: express is listed as a peer dependency, so npm
resolves and installs it for you — and in practice you already have it.
Neither installs peer dependencies automatically, so name it explicitly:
yarn add @desolint/idempotency express
# or
pnpm add @desolint/idempotency expressWhy express is a peer dependency, not a regular one
This package ships middleware, which only works when mounted on the same
express instance your application already created. A second copy would carry its
own router and request/response prototypes, so the middleware would either fail to
mount or run against objects your app never sees.
Declaring it as a peer means npm reuses the copy your application already has instead of nesting a second one. You keep control of the version; this package just states the range it works with.
Storage is deliberately not a dependency: you pass your own IdempotencyStore,
so nothing here pulls in Mongo, Redis, or any other database driver.
Quick start
// config/idempotency.ts
import { initializeIdempotency } from "@desolint/idempotency/config";
import IdempotencyModel from "@/models/IdempotencyModel";
import GeneralServices from "@/services/generalServices";
import { IdempotencyErrorsFactories } from "@/factories";
initializeIdempotency({
// The storage adapter — three functions wrapping your existing Mongo
// model. Swap this for a Redis adapter, an in-memory Map, whatever —
// the middleware doesn't care.
store: {
get: async ({ key }) => {
const { doc } = await GeneralServices.findOne({
model: IdempotencyModel,
query: { key },
});
return doc ? { status: (doc as { status: string }).status } : null;
},
create: async ({ key }) => {
try {
await GeneralServices.create({
model: IdempotencyModel,
data: { key, status: "processing" },
});
return { created: true };
} catch (err) {
if ((err as { code?: number }).code === 11000)
return { created: false };
throw err;
}
},
update: async ({ key, status, ifStatus }) => {
// `ifStatus` makes the failed-key retry path race-safe: only apply
// the update if the key is still in that status. Fold it into the
// query when present, and report whether it actually matched.
const { doc } = await GeneralServices.findOneAndUpdate({
model: IdempotencyModel,
query: ifStatus ? { key, status: ifStatus } : { key },
data: { status },
});
return { updated: Boolean(doc) };
},
},
// Who the request belongs to — scopes the idempotency key per-user.
getIdentity: ({ req }) => req.extra.jwtToken!.user._id,
// Called when a duplicate is detected — throw your app's own error.
onDuplicate: () => {
throw IdempotencyErrorsFactories.idempotencyKeyAlreadyProcessing();
},
// Called when the *store itself* errors (DB down, etc.) — optional.
onError: (error) => console.error("Idempotency store error", { error }),
});// app.ts
import { idempotencyMiddleware } from "@desolint/idempotency/services";
app.use(idempotencyMiddleware());/config
initializeIdempotency({store, getIdentity, onDuplicate, onError?, failOpen?})
| Param | Type | Required | Notes |
| ------------- | ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| store | IdempotencyStore | yes | {get, create, update} — see Writing a store below. |
| getIdentity | ({req}) => string | yes | Whatever identifies the caller (user id, API key, ...). Scopes the key so different callers never collide. |
| onDuplicate | ({req, res}) => void | yes | Runs instead of next() for a duplicate. Throw your own error or write to res directly — whatever your app already does. |
| onError | (error: Error) => void | no | Fires when the store errors (not for a duplicate — that's onDuplicate). |
| failOpen | boolean | no | Default false: a store error re-throws (request fails, fail-closed). Set true to let the request through unprotected instead. |
IdempotencyStore
interface IdempotencyStore {
get: (params: {
key: string;
}) => Promise<{ status: IdempotencyStatus } | null>;
create: (params: { key: string }) => Promise<{ created: boolean }>;
update: (params: {
key: string;
status: IdempotencyStatus;
ifStatus?: IdempotencyStatus;
}) => Promise<{ updated: boolean } | void>;
}get—nullmeans "never seen this key" (fresh request).create— must be atomic against your backend:created: falsemeans a concurrent request already claimed the key first (e.g. a Mongo unique-index violation, or RedisSET key val NXreturningnil).update— changes a key's status (processing→completed/failed, orfailed→processingon retry). WhenifStatusis passed, apply the update only if the key's current status still matches it, and return{updated: false}when it didn't — this is what makes reclaiming afailedkey for retry safe against two concurrent retries of the same key. Optional to implement: returningvoid(or ignoringifStatus) keeps working exactly as before, just without that race protection.
IDEMPOTENCY_STATUSES (processing/completed/failed) is exported
from /config too, so your store implementation can reference the same
constants instead of hardcoding strings.
IdempotencyConfig
The object initializeIdempotency takes. Exported so you can type your own wiring
module against it.
IDEMPOTENCY_STATUSES / IdempotencyStatus
The three states a key can be in — processing, completed, failed. Your store
receives and returns these; use the constant rather than string literals so a typo
is a compile error.
resetIdempotencyConfig()
Test-only escape hatch. Clears the configuration so each test file starts from a
clean slate instead of inheriting the previous one's — the config lives on
globalThis, so it would otherwise leak across files. Every package in this scope
exposes the same hatch.
/services
idempotencyMiddleware()
Returns an Express middleware. On each request:
- Computes a key from
getIdentity({req})+ method + path + params + query + body (sorted, so key order in the body never changes the key). - Looks it up via
store.get.- Not found →
store.create. If that loses a race (created: false), it's a duplicate. - Found, status
failed→ reclaimed for immediate retry. - Found, status
processing/completed→ duplicate.
- Not found →
- Duplicate → calls
onDuplicate({req, res})instead ofnext(). - Otherwise → calls
next(), and once the response finishes, marks the keycompleted(2xx) orfailed(anything else) viastore.update.
A store failure at any point routes through onError/failOpen as
described above — it never crashes the process.
generateIdempotencyKey({identity, method, path, params?, query?, body?})
Returns the SHA-256 key the middleware computes for a request. A pure function —
no req, no Express types — exported so you can reproduce the same key outside the
middleware, for example to look up one request's status from a support script.
Object keys are sorted recursively before hashing, including inside arrays, so two
structurally identical payloads that differ only in key order ({a:1,b:2} vs
{b:2,a:1}) produce the same key rather than two different ones.
Development
npm install # install dependencies
npm run build # type-check, then bundle each subpath into dist/
npm test # jest
npm run lint # eslintscripts/build.mjs bundles each subpath into one self-contained JS file plus a
.d.ts, then deletes everything else from dist/ — internal modules
(src/idempotency/*, src/shared/*) never ship, so there is nothing for an editor
or a moduleResolution: "node" consumer to resolve beyond the two public subpaths
documented above.
License
MIT © Desolint — see LICENSE.
Free to use, modify and redistribute, commercially or otherwise. Provided "as is", without warranty or liability of any kind.
