Porque um upsert que sobrescreve a linha inteira, chamado uma segunda vez, escreve de novo os valores do momento da criação por cima de tudo o que mudou depois. Se o cliente reenvia a criação sozinho, por reconexão ou fila offline, o status avançado volta ao inicial. A correção é separar criar de atualizar: a criação deve inserir e, se a linha já existir, deixá-la como está.
O que é um upsert, e por que parece seguro
Upsert é "atualiza ou insere": se a linha não existe, cria, e se existe, atualiza. No PostgreSQL ele se escreve INSERT ... ON CONFLICT, e a documentação dá duas alternativas para o caso de conflito. DO NOTHING simplesmente evita inserir. DO UPDATE atualiza a linha que conflita com a proposta. A documentação ainda garante que o DO UPDATE tem resultado atômico, inserção ou atualização, mesmo sob alta concorrência.
O problema é a palavra "idempotente", que costuma ser atribuída ao upsert sem conferir. Idempotente quer dizer que repetir a chamada dá o mesmo resultado que fazê-la uma vez. Isso só vale se a segunda chamada não desfizer nada. Um DO UPDATE que grava todos os campos do payload desfaz.
O que aconteceu comigo
Num app meu que transcreve reuniões, o endereço de "criar reunião" gravava com upsert, sobrescrevendo a linha. O cliente do app reenvia essa criação por desenho: quando o service worker reconecta, quando a fila offline é descarregada, quando a rede oscila. Na segunda chamada, a reunião que já estava concluída voltou para "em andamento", e o título real, promovido depois por outra ação, foi trocado pelo texto padrão "Reuniao no Google Meet". Era a terceira porta para um defeito que eu já tinha fechado duas vezes: a reunião "ao vivo para sempre".
Esse tipo de erro não aparece no caminho feliz nem em teste unitário, porque a primeira chamada faz tudo certo. Ele só existe na segunda, e a segunda é exatamente o que o cliente faz sozinho. Quem lê o código vê "upsert" e segue em frente.
A segunda ocorrência, sem reenvio nenhum
A mesma raiz apareceu numa importação de documentos em lote, onde não houve reenvio. O ON CONFLICT DO UPDATE escrevia só o que vinha no documento novo, e o gerador dos SQL não preservava um campo que o documento novo não trazia. Num dos lotes havia 947 linhas nesse campo, 78 kB, que sobreviveram só porque corrigi o gerador antes de aplicar. Sem isso, uma funcionalidade daquele mês teria morrido em silêncio, com o número certo na tela e nenhum erro.
O que fazer
- Separe as ações. Criar e atualizar são operações diferentes, cada uma com seu endereço ou sua função.
- Na criação, não atualize. Use
ON CONFLICT DO NOTHINGe deixe a linha existente intacta:
insert into reunioes (id, titulo, status)
values ($1, $2, 'em_andamento')
on conflict (id) do nothing;
No supabase-js, a opção ignoreDuplicates do upsert serve a isso. A página de referência cita a opção sem detalhar os valores; no meu uso, com ignoreDuplicates: true ela se comporta como DO NOTHING.
- Quando o upsert precisa mesmo atualizar um documento, preserve o que não veio com
coalesce(excluded.campo, tabela.campo), em vez deset dados = <novo>cru. - Arquive antes de trocar. Antes de aplicar a importação, copiei as linhas existentes para uma tabela de histórico. Sem essa cópia, "o número certo na tela" é a única prova que sobra, e ela não mostra o que sumiu.
- Ganhe de brinde o fim de uma corrida. Na minha versão antiga, a criação fazia
selecte depoisinsert, e dois envios paralelos estouravam a chave primária e viravam erro 500. ComON CONFLICT DO NOTHING, o segundo simplesmente não faz nada.
Como provar que ficou certo
O teste é o roteiro completo, não a primeira chamada: criar, mudar o status, concluir, reenviar a criação e conferir que nada regrediu. Leia os campos direto do banco, não a resposta 200 do endereço, porque o 200 vem igual nos dois casos.
Reenvio é comportamento normal de qualquer cliente que tenha rede ruim ou fila offline. Montar integrações que aguentam isso é parte do que faço em automação e integrações.