Idempotência de Mutações
Toda mutação (POST, PATCH, PUT, DELETE) 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 (RFC 8785-style, ordem de chaves irrelevante;
corpos não-JSON são hashados como bytes crus)
3. Reivindicação atômica: INSERT ... ON CONFLICT DO NOTHING em (key, user_id)
4. Se perdeu a corrida (meta.changes = 0):
├─ Consulta idempotency_ledger por (key, user_id)
├─ payload_hash diferente → 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD
├─ status_code = 0 (ainda em execução) → 409 IDEMPOTENCY_IN_PROGRESS (retryable)
└─ payload_hash igual → replay: resposta original (status_code + body + headers)
5. Se ganhou a corrida (meta.changes = 1):
├─ Executa a mutation (execute())
├─ Grava status_code + response_body + response_headers no ledger
├─ Falha na execução → libera a reivindicação (DELETE) para permitir retry
└─ 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. O replay preserva os headers da resposta original (ex.: Location), não apenas o corpo.
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. Durante a execução concorrente da mesma chave, retorna 409 IDEMPOTENCY_IN_PROGRESS (retryable) até o vencedor terminar.
Concorrência (claim atômico)
A reivindicação da chave é atômica via INSERT ... ON CONFLICT DO NOTHING (SQLite ≥ 3.24.0, suportado pelo D1):
// Dentro de idempotentMutation
const claim = await db
.prepare(
"INSERT INTO idempotency_ledger(key, user_id, payload_hash, status_code, response_body, response_headers, created_at) VALUES(?1, ?2, ?3, 0, '{}', '[]', ?4) ON CONFLICT(key, user_id) DO NOTHING",
)
.bind(idempotencyKey, userId, payloadHashValue, nowISO())
.run();
if (claim.meta.changes === 0) {
// Perdeu a corrida → re-leitura + replay (ou 409)
}A operação de negócio e a gravação do resultado no ledger são atômicas via D1.batch() (disponível a partir do D1 v0.26.0): ou tudo persiste (negócio + ledger) ou nada persiste. O MutationExecutor pode retornar statements D1 adicionais (ex: INSERTs, UPDATEs) incluídos no mesmo batch().
Retenção
Entradas do ledger são retidas por 24 horas e purgadas periodicamente pelo cron (scheduled handler, purgeExpiredIdempotencyKeys). Chaves reutilizadas após a purga são tratadas como novas requisições (mesmo comportamento do Stripe).
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