SIAX Technology (sax3l)

@siax/idempotency (0.1.0)

Published 2026-09-16 09:35:08 +00:00 by admin

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 IdempotencyStore interface itself (claim/complete/fail/get, IdempotencyRecord, ClaimOutcome) has zero credit-ledger-specific fieldsoperation is a free-text diagnostic string, result is unknown. 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 direct import { Pool } from 'pg' — both trivially generalized (configurable tableName, dependency-injected query function instead of an owned pg.Pool).
  • credit-ledger-service.ts's withIdempotency() wrapper (claim -> compare requestHash -> replay-if-completed / reject-if-different-body / reject-if-failed -> run fn() -> 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 (no await between the has check and the set, same implicit-mutex reasoning b00k's own in-memory store relies on).
  • createSqlIdempotencyStore({query, tableName?}) — durable, zero dependency (does NOT import pg): query is dependency-injected as (sql, params) => Promise<{rows, rowCount}>, exactly pg.Pool's own .query() shape. A caller passes pool.query.bind(pool) directly. The atomic claim is INSERT ... 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 matching CREATE TABLE string.
  • withIdempotency({store, key, operation, params, fn}) — the middleware wrapper. IdempotencyKeyReuseError / IdempotencyPendingError / IdempotencyPreviouslyFailedError cover 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):

  1. Same key + same body -> same result, side effect runs exactly once.
  2. Different key -> independent execution, own result, own side effect.
  3. Same key + different body -> IdempotencyKeyReuseError, fn() never called the second time.
  4. A failed attempt's key is not silently retried — a NEW key is required (IdempotencyPreviouslyFailedError).
  5. 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.

Details
npm
2026-09-16 09:35:08 +00:00
3994
UNLICENSED
latest
12 KiB
Assets (1)
Versions (1) View all
0.1.0 2026-09-16