@bevingh/idempotency
v0.1.0
Published
Pure idempotency store interface + Redis/memory adapters with in-flight lock + 409, domain unique-key helper, Express middleware; header/scope configurable.
Readme
@bevingh/idempotency
Phase 3 / PR-14 — extracted. HTTP response-cache stores (Redis + memory) with in-flight 409, plus a separate domain unique-key helper. Express adapter with configurable header + scope key.
Purpose
Pure idempotency store interface + Redis adapter with in-flight SET NX lock (409) + memory adapter + Express middleware; header name configurable.
| Field | Value |
|---|---|
| surfaceShape | pure_core_plus_express_adapter |
| dependsOnPackages | @bevingh/errors |
| extractionOrderHint | 4 |
| status | extracted (implementation + tests) |
Champion correction (PR-9 — do not re-litigate)
| Role | Choice | Why | |---|---|---| | Redis champion | mirrly (Bevin-Photos peer) | Redis + SET NX lock + 409 + replay | | Not Redis champion | conduit | Redis without in-flight lock — security race | | Memory pattern | payment-gatway | Map + in-flight 409; multi-instance unsafe by nature |
Lead design finding: DB unique-key does not unify into IdempotencyStore
Same philosophy as @bevingh/auth revocation: investigate before forcing one shape.
| HTTP IdempotencyStore (Redis / memory) | Domain unique key (Didipay ledger, roomsplit writes) |
|---|---|
| Caches HTTP status + body | Uniqueness on a domain row (idempotency_key column) |
| Replay = re-send cached JSON envelope | Replay = return the same domain entity |
| In-flight = middleware 409 | Race = unique constraint + re-read entity |
| begin / complete / abort | findExisting → execute → catch unique → re-find |
Conclusion: Redis and memory do share one interface. Didipay-style domain unique keys do not cleanly implement that interface without awkward fake “HTTP responses.” They use runWithDomainUniqueKey in the same package as a documented separate helper.
Note: maame’s Prisma IdempotencyKey table stores HTTP status/body (closer to a DB-backed store). It could implement IdempotencyStore with an injected repository + optional in-flight row; that is not the same as Didipay’s ledger unique column.
Public API
HTTP store — @bevingh/idempotency
| Export | Role |
|---|---|
| IdempotencyStore | begin / complete / abort |
| createRedisIdempotencyStore(redis, opts?) | mirrly-shaped Redis (inject RedisLike — mockable) |
| createMemoryIdempotencyStore(opts?) | payment-gatway Map; multi-instance unsafe |
| inFlightConflict() | AppError 409 |
| runWithDomainUniqueKey | Separate domain write helper |
Express — @bevingh/idempotency/adapters/express
import { createMemoryIdempotencyStore } from '@bevingh/idempotency';
import { createIdempotencyMiddleware } from '@bevingh/idempotency/adapters/express';
app.use(createIdempotencyMiddleware({
store: createMemoryIdempotencyStore(),
// payment-gatway style:
headerName: 'x-idempotency-key',
scopeKey: (req, raw) => `api:${(req as any).apiClient?.id}:${raw}`,
// mirrly style would be:
// headerName: 'idempotency-key',
// scopeKey: (req, raw) => `${req.user?.id}:${raw}`,
}));| Option | Default | Notes |
|---|---|---|
| headerName | idempotency-key | Override to x-idempotency-key for PayHub lineage |
| scopeKey | required | No hard-coded productId/userId/path composition |
| methods | POST/PUT/PATCH/DELETE | Configurable |
| cacheStatusMin/Max | 200–299 | mirrly only caches 2xx |
Memory store warning
MULTI-INSTANCE UNSAFE — each process has its own Map.
Use Redis (or another shared IdempotencyStore) for multi-instance deploys.Documented in code comments on createMemoryIdempotencyStore and here — not buried.
Future consumers (not built here)
- ussd-service — zero idempotency today; shared lineage with payment-gatway → likely Phase 4/5 adopter. Do not invent ussd-local middleware in this package.
- conduit — should add in-flight lock when adopting Redis store (current code lacks it).
- Texify — memory without in-flight; adopt package store for 409 semantics.
Not ported
- Product handlers / wallet debit
- bevin-events outbound
Idempotency-Keyto Conduit (client concern) - ussd-specific middleware
openVariances
| Item | Handling | |---|---| | conduit no lock | Not champion; use mirrly-shaped Redis store | | memory multi-instance | Documented limitation | | Texify no 409 | Consumer of package, not champion | | header / scope shapes | Configurable middleware options |
Tests
npm run test -w @bevingh/idempotency
npm run build -w @bevingh/idempotencyRedis store tested with a mock RedisLike (no live Redis). Memory concurrent begin → in_flight. Header/scope config isolation. Domain helper race path.
Champion files (read-only)
- mirrly
middleware/idempotency.ts— Redis champion - Bevin-Photos
payments.controller.ts— Redis peer (SET NX 30s) - payment-gatway
middleware/idempotency.js— memory + 409 - maame
middleware/idempotency.ts— DB response-cache evidence (not Didipay domain unique)
