@siax/idempotency (0.1.0)
Installation
@siax:registry=https://git.cloud.siax.io/api/packages/sax3l/npm/npm install @siax/idempotency@0.1.0"@siax/idempotency": "0.1.0"About this package
@siax/idempotency
P012 (Network/DB/Event/Idempotency/SLO Standards, new P000-P049 masterplan):
withIdempotency() — the standard "same Idempotency-Key + same request
body -> same result, no duplicate side effect; different key -> independent
execution; same key + DIFFERENT body -> rejected" contract, plus two
IdempotencyStore implementations. Zero external dependencies. Node ESM
(.mjs), Node ≥ 20 — same convention as @siax/schemas,
@siax/write-guard, @siax/secret-resolver, @siax/event-client.
The honest extraction decision
P012 recon found ≥5 independently-invented, non-shared idempotency
implementations across the estate: b00k's credit-ledger, n0d's
derive-job-id-from-key src/api/idempotency.ts, n0tify's canonical-hash
replay/conflict/new design, m0b's client-side key generation only, st0re's
artifact-checksum test. No shared @siax/idempotency existed.
b00k's IdempotencyStore (packages/b00k-service/src/credit-ledger/ idempotency-store.ts + pg-idempotency-store.ts) was judged genuinely
reusable, not domain-specific, on inspection:
- The
IdempotencyStoreinterface itself (claim/complete/fail/get,IdempotencyRecord,ClaimOutcome) has zero credit-ledger-specific fields —operationis a free-text diagnostic string,resultisunknown. Nothing about wallets, reservations, or money leaks into the contract. pg-idempotency-store.ts's only b00k-specific detail was a hardcoded table name (b00k_idempotency) and a directimport { Pool } from 'pg'— both trivially generalized (configurabletableName, dependency-injectedqueryfunction instead of an ownedpg.Pool).credit-ledger-service.ts'swithIdempotency()wrapper (claim -> comparerequestHash-> replay-if-completed / reject-if-different-body / reject-if-failed -> runfn()-> complete/fail) is likewise domain-agnostic — it's Stripe's own idempotency-key pattern, not something b00k invented for wallets specifically.
What was left behind, deliberately, as too domain-specific: b00k's
stringifyWithMoney (a Money-aware canonicalizer for its own decimal
type) — this package's canonicalHash is a plain deterministic stringify
with no opinion on domain value types; a caller with meaningful non-JSON
values should pre-serialize them. Also left behind: b00k's own
MutationResult type (operation/idempotencyKey/replayed baked into
every return value) — this package returns {...result, replayed} instead,
letting the caller's own result shape pass through unmodified.
Not reimplemented, reused as-is: the interface SHAPE and the
claim-before-execute ALGORITHM. Not the TypeScript source files themselves
— b00k is TypeScript importing pg directly; siax-standard is
zero-dependency Node ESM .mjs by repo convention (same "reimplemented
here, not imported, because these are separate repos with separate deploy
lifecycles" reasoning packages/write-guard/src/aud0-client.mjs's module
doc already uses for a different extraction).
What's built
createInMemoryIdempotencyStore()— reference implementation, correct within one process (noawaitbetween thehascheck and theset, same implicit-mutex reasoning b00k's own in-memory store relies on).createSqlIdempotencyStore({query, tableName?})— durable, zero dependency (does NOT importpg):queryis dependency-injected as(sql, params) => Promise<{rows, rowCount}>, exactlypg.Pool's own.query()shape. A caller passespool.query.bind(pool)directly. The atomic claim isINSERT ... ON CONFLICT (key) DO NOTHING RETURNING *— the database's own unique constraint is the real mutual-exclusion primitive, safe across multiple processes, unlike the in-memory store.POSTGRES_DDL(tableName?)exports the matchingCREATE TABLEstring.withIdempotency({store, key, operation, params, fn})— the middleware wrapper.IdempotencyKeyReuseError/IdempotencyPendingError/IdempotencyPreviouslyFailedErrorcover the three "not claimed" outcomes.canonicalHash(value)/canonicalStringify(value)— deterministic (object keys sorted recursively, array order preserved) SHA-256, so a structurally-identical retry from a different process/JSON parser still hashes identically.
Contract test — proves the exact P012 mandate
test/with-idempotency.test.mjs, run against both store
implementations (test/store-contract.test.mjs too):
- Same key + same body -> same result, side effect runs exactly once.
- Different key -> independent execution, own result, own side effect.
- Same key + different body ->
IdempotencyKeyReuseError,fn()never called the second time. - A failed attempt's key is not silently retried — a NEW key is
required (
IdempotencyPreviouslyFailedError). - A pending (in-flight) key rejects a concurrent caller rather than double-running the side effect.
Usage
import { withIdempotency, createInMemoryIdempotencyStore } from '@siax/idempotency';
const store = createInMemoryIdempotencyStore(); // or createSqlIdempotencyStore({query: pool.query.bind(pool)})
async function chargeWallet(idempotencyKey, params) {
return withIdempotency({
store,
key: idempotencyKey, // e.g. req.headers['idempotency-key']
operation: 'charge_wallet',
params, // hashed to detect key reuse against a DIFFERENT body
fn: async () => {
const chargeId = await actuallyChargeTheWallet(params); // the real side effect -- runs at most once per key
return { chargeId }; // must be a plain object (or null/undefined) -- see with-idempotency.mjs
},
});
}
Not done this pass — clear worklist, not silently skipped
No repo outside siax-standard imports @siax/idempotency yet — this
package was NOT wired into a live reference service end-to-end (unlike
@siax/event-client, which @siax/write-guard now consumes). siax-standard
itself is a CLI/schema-catalog repo with no HTTP mutation endpoints of its
own to wire this into. The natural, concrete next step: open a follow-up PR
against b00k replacing its own idempotency-store.ts /
pg-idempotency-store.ts with @siax/idempotency's
createInMemoryIdempotencyStore / createSqlIdempotencyStore (b00k already
uses the identical b00k_idempotency table shape this package's
POSTGRES_DDL produces under a custom tableName) — not attempted here,
flagged rather than claimed done.
Single-process only (in-memory store)
createInMemoryIdempotencyStore is correct within one Node process only —
multiple replicas/workers each get their OWN map, so the same key could be
independently claimed by two different processes. Use
createSqlIdempotencyStore (or any store backed by a real unique
constraint) for anything running more than one instance — exactly the
distinction b00k's own module doc draws between its in-memory and Postgres
stores.