Idempotência de Mutações
Toda mutação (POST, PATCH) na API do Neemias exige o header Idempotency-Key. Isso garante que uma mesma operação não seja processada mais de uma vez, mesmo que o cliente retente a requisição por timeout ou falha de rede — essencial para a arquitetura offline-first com sincronia assíncrona.
Header Obrigatório
Idempotency-Key: <UUID v4>O cliente (frontend) gera uma chave UUID v4 única para cada operação de mutação e a envia no header. Se o header estiver ausente, o backend retorna:
{
"error": "VALIDATION_FAILED",
"message": "Idempotency-Key header is required"
}Status: 400
Ledger de Idempotência
A tabela idempotency_ledger armazena o registro de cada requisição processada:
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, -- JSON serializado
created_at TEXT NOT NULL,
PRIMARY KEY(key, user_id)
);A chave composta (key, user_id) garante que a mesma chave de idempotência pode ser reutilizada por usuários diferentes sem conflito.
Função idempotentMutation
Localizada em workers/src/db/queries.ts, esta função encapsula toda a lógica de idempotência:
async function idempotentMutation(
idempotencyKey: string | undefined,
userId: string,
payload: unknown,
execute: MutationExecutor, // () => Promise<MutationResult>
): Promise<{ statusCode: number; body: Record<string, unknown> }>;Fluxo de Execução
1. Verifica se idempotencyKey está presente
└─ Ausente → 400 VALIDATION_FAILED
2. Calcula hash do payload (payloadHash)
└─ SHA-256 do JSON canônico
3. Consulta idempotency_ledger por (key, user_id)
4. Se encontrado:
├─ payload_hash diferente → 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD
└─ payload_hash igual → replay detectado, retorna resposta original (status_code + body)
5. Se NÃO encontrado:
├─ Executa a mutation (execute())
├─ Insere registro no ledger
└─ Retorna resultado da mutationDetecção de Replay
Se a mesma chave for usada com o mesmo payload, o backend reconhece como replay e retorna a resposta original — sem reexecutar a operação. Isso é seguro porque o payload é idêntico.
Rejeição de Reuso (409)
Se a mesma chave for usada com um payload diferente, o backend rejeita com 409 Conflict:
{
"error": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
"message": "Idempotency key already used with a different payload"
}Isso impede que um cliente reutilize acidentalmente uma chave para operações diferentes.
Atomicidade com D1.batch()
A operação de negócio e a inserção no ledger são atômicas via D1.batch() (disponível a partir do D1 v0.26.0):
// Dentro de 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]);O MutationExecutor pode retornar statements D1 adicionais (ex: INSERTs, UPDATEs) que são incluídos no mesmo batch(). Assim, ou tudo persiste (negócio + ledger) ou nada persiste.
Hash de Payload
A função payloadHash() em workers/src/modules/idempotency.ts gera um hash SHA-256 determinístico do payload:
async function payloadHash(payload: unknown): Promise<string>;O payload é serializado como JSON canônico (ordenação estável de chaves) antes do hash, garantindo que dois payloads semanticamente idênticos produzam o mesmo hash independentemente da ordem das chaves no objeto original.
Uso no Frontend
No frontend, o EventWriter gera uma chave de idempotência (UUID v4) para cada evento e a armazena na entrada da syncQueue. Quando o syncQueueRepository drena a fila, cada requisição POST /api/v1/sync/events inclui o header Idempotency-Key correspondente.
Fonte: workers/CONTEXT.md + queries.ts