Skip to content

Mutation Idempotency

Every mutation (POST, PATCH) in the Neemias API requires the Idempotency-Key header. This ensures that the same operation is not processed more than once, even if the client retries the request due to timeout or network failure — essential for the offline-first architecture with asynchronous sync.

Required Header

Idempotency-Key: <UUID v4>

The client (frontend) generates a unique UUID v4 key for each mutation operation and sends it in the header. If the header is missing, the backend returns:

json
{
  "error": "VALIDATION_FAILED",
  "message": "Idempotency-Key header is required"
}

Status: 400

Idempotency Ledger

The idempotency_ledger table stores the record of each processed request:

sql
CREATE TABLE idempotency_ledger (
  key TEXT NOT NULL,
  user_id TEXT NOT NULL,
  payload_hash TEXT NOT NULL,
  status_code INTEGER NOT NULL,
  response_body TEXT NOT NULL,      -- serialized JSON
  created_at TEXT NOT NULL,
  PRIMARY KEY(key, user_id)
);

The composite key (key, user_id) ensures that the same idempotency key can be reused by different users without conflict.

idempotentMutation Function

Located in workers/src/db/queries.ts, this function encapsulates all idempotency logic:

typescript
async function idempotentMutation(
  idempotencyKey: string | undefined,
  userId: string,
  payload: unknown,
  execute: MutationExecutor, // () => Promise<MutationResult>
): Promise<{ statusCode: number; body: Record<string, unknown> }>;

Execution Flow

1. Checks if idempotencyKey is present
   └─ Missing → 400 VALIDATION_FAILED

2. Computes payload hash (payloadHash)
   └─ SHA-256 of canonical JSON

3. Queries idempotency_ledger by (key, user_id)

4. If found:
   ├─ payload_hash different → 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD
   └─ payload_hash same → replay detected, returns original response (status_code + body)

5. If NOT found:
   ├─ Executes the mutation (execute())
   ├─ Inserts record into ledger
   └─ Returns mutation result

Replay Detection

If the same key is used with the same payload, the backend recognizes it as a replay and returns the original response — without re-executing the operation. This is safe because the payload is identical.

Reuse Rejection (409)

If the same key is used with a different payload, the backend rejects with 409 Conflict:

json
{
  "error": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
  "message": "Idempotency key already used with a different payload"
}

This prevents a client from accidentally reusing a key for different operations.

Atomicity with D1.batch()

The business operation and ledger insertion are atomic via D1.batch() (available starting from D1 v0.26.0):

typescript
// Inside idempotentMutation
const { statusCode, body, statements = [] } = await execute();

const ledgerStmt = db
  .prepare(
    "INSERT INTO idempotency_ledger(key, user_id, payload_hash, status_code, response_body, created_at) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
  )
  .bind(idempotencyKey, userId, payloadHashValue, statusCode, JSON.stringify(body), nowISO());

await db.batch([...statements, ledgerStmt]);

The MutationExecutor can return additional D1 statements (e.g., INSERTs, UPDATEs) that are included in the same batch(). Thus, either everything persists (business + ledger) or nothing persists.

Payload Hash

The payloadHash() function in workers/src/modules/idempotency.ts generates a deterministic SHA-256 hash of the payload:

typescript
async function payloadHash(payload: unknown): Promise<string>;

The payload is serialized as canonical JSON (stable key ordering) before hashing, ensuring that two semantically identical payloads produce the same hash regardless of key order in the original object.

Usage in the Frontend

In the frontend, EventWriter generates an idempotency key (UUID v4) for each event and stores it in the syncQueue entry. When syncQueueRepository drains the queue, each POST /api/v1/sync/events request includes the corresponding Idempotency-Key header.


Source: workers/CONTEXT.md + queries.ts

Distributed under MIT License.