O OdoKeep já está na App Store. Baixe grátis.

Todos os artigos

Sincronização offline

Proteger alterações offline em React Native com MMKV e Supabase

Como uma fila em MMKV lida com eliminações durante o envio, respostas perdidas e edições desatualizadas em um app React Native com Supabase.

Atualizado em

Neste artigo

Um registro que reaparece depois de ser eliminado é uma boa forma de fazer alguém perder a confiança em um diário de bordo.

Foi um dos problemas que precisei resolver no OdoKeep, meu app de gerenciamento de veículos. Um usuário podia criar um registro, eliminá-lo enquanto o pedido de inserção ainda estava em curso e voltar a vê-lo na sincronização seguinte. A eliminação local funcionava. A inserção no servidor também. O que estava errado era a interpretação da fila sobre o que tinha acontecido.

O OdoKeep usa React Native e Expo, MMKV para persistência local e Supabase para a cópia no servidor. Os repositórios gravam localmente e colocam a operação na fila. As telas podem trabalhar com os dados locais enquanto o gerenciador de sincronização trata da rede.

Isso permite salvar um abastecimento sem conexão. Também obriga a fila a representar mais do que uma lista de pedidos para repetir depois.

Uma criação na fila não é uma criação já enviada

A primeira distinção útil foi entre uma operação ainda não enviada e outra já enviada, mas sem resposta.

Em uma sessão offline normal:

Criar o registro localmente
Colocar INSERT na fila
Eliminar o registro localmente
Cancelar o INSERT na fila

Não há motivo para contatar o servidor. O registro nunca existiu lá. Reduzir a criação e a eliminação a nenhuma operação é a otimização correta.

Agora imagine uma conexão lenta entre o segundo e o terceiro passos:

Criar o registro localmente
Enviar INSERT
Eliminar o registro localmente
Cancelar o INSERT na fila
O servidor conclui INSERT
A próxima leitura devolve o registro

Retirar uma operação de um array não cancela uma requisição que já está na rede. A fila ainda chamava a criação de “pendente”, e a eliminação interpretava essa palavra como prova de que o servidor nunca a tinha recebido.

A correção passa por um conjunto de identificadores das operações em curso. Em lib/sync-queue.ts, a procura de uma criação que ainda possa ser cancelada inclui esta condição:

const pendingCreate = pending.find(
  (op) => op.type === "create" && !this.inFlight.has(op.id),
);

Se a criação já foi enviada, a exclusão do registro precisa adicionar uma operação de remoção no servidor à fila. Já não é possível cancelar o par apenas localmente.

Este conjunto fica deliberadamente em memória. Depois de um reinício, o processo já não tem um pedido ativo para acompanhar. Persistir uma flag inFlight faria com que a inicialização seguinte acreditasse que a operação ainda estava sendo enviada, sem nada que pudesse limpar esse estado. A fila duradoura sobrevive; a descrição dos pedidos ativos deste processo, não.

O conjunto também impede que uma segunda execução da fila volte a enviar uma operação cuja resposta ainda não chegou. Uma sincronização iniciada ao recuperar a conexão e outra iniciada por um gesto de atualização não devem enviar a mesma inserção ao mesmo tempo. O gerenciador protege a execução completa, e a fila exclui, por sua vez, as operações em curso da lista de trabalho disponível. Mesmo uma atualização manual que ignora a espera entre tentativas respeita essa exclusão.

Recuperar uma inserção quando a resposta desaparece

Há outro caso incômodo: o servidor pode confirmar a inserção e o celular perder a resposta.

Na tentativa seguinte, uma inserção simples pode falhar porque o identificador já existe. Tratar esse resultado como uma recusa permanente faria desaparecer localmente um registro que o servidor tinha salvo.

No percurso normal de criação, o gerenciador pede ao PostgREST que ignore um identificador duplicado, em vez de substituir os dados existentes. Se a operação não devolver uma linha, faz uma leitura pelo identificador. Uma linha legível confirma que a criação chegou ao servidor, e o dispositivo a adota. Se essa leitura de confirmação falhar, a operação continua elegível para nova tentativa. Uma colisão sem uma linha legível não conta como sucesso.

Isso não garante entrega exatamente uma vez. É um mecanismo de repetição que recupera provas de uma inserção sem substituir o conteúdo de uma linha existente.

Atualizar a versão que foi efetivamente editada

O compartilhamento de veículos trouxe outro problema. Duas pessoas podem editar um registro a partir da mesma versão do servidor, estando uma ou ambas offline.

Uma atualização filtrada apenas pelo identificador permite que a última sincronização apague as alterações da primeira pessoa. Os dois pedidos podem ter sucesso. Nenhuma delas descobre que perdeu a edição.

Por isso, as atualizações na fila transportam baseUpdatedAt: a data de atualização do servidor que o dispositivo conhecia antes da edição local. A condição essencial de escrita, simplificada de lib/sync-manager.ts, é esta:

const write = client
  .from(tableName)
  .update(operation.data)
  .eq("id", operation.data.id);

if (operation.baseUpdatedAt) {
  write.eq("updated_at", operation.baseUpdatedAt);
}

const result = await write.select(returningColumns);

A atualização só pode ser aplicada enquanto a linha mantiver a versão sobre a qual foi editada. A API de atualização do Supabase permite devolver as linhas afetadas com .select(), tornando uma correspondência vazia observável pelo cliente.

É fácil salvar a versão de base errada. Depois da primeira edição local, a linha no dispositivo pode conter uma data gerada localmente. Uma segunda edição não deve substituir a base da operação por esse valor: o servidor nunca o emitiu. A consolidação mantém a base original do servidor e combina as alterações ao conteúdo.

Um registro recém-criado é outra exceção. Até o servidor confirmar a criação, a sua data local não pode ser usada como condição sobre uma versão do servidor. É precisamente por isso que a fila distingue “a criação ainda está registrada” de “a criação ainda pode ser cancelada”.

Um resultado de atualização vazio também precisa de interpretação. Pode significar que outra pessoa alterou a linha, que a linha foi eliminada ou, em um veículo compartilhado, que o acesso do usuário foi revogado ou o seu papel mudou.

O tratamento de conflitos verifica se existe uma linha legível com uma data mais recente emitida pelo servidor. Só essa prova origina a mensagem de edição desatualizada. A operação segue o percurso de recusa permanente, o usuário é informado e o dispositivo reconcilia os dados com o servidor. Repetir a escrita sem a condição original recriaria a perda de dados que a verificação pretende evitar.

A política atual é simples: em um conflito confirmado, prevalece a cópia do servidor. Não há fusão por campo nem editor de conflitos. Uma operação criada por uma versão antiga do app sem data de base também não recebe esta proteção retroativamente. Estes limites fazem parte da descrição honesta das garantias do sistema.

Ler alterações a partir de uma referência do servidor

As leituras incrementais exigem o mesmo cuidado com as datas. Um celular pode estar dois minutos adiantado. Se salvar a sua hora atual como referência, a leitura seguinte ignora alterações até um ponto que ainda está no futuro do servidor.

lib/delta-watermark.ts avança essa referência usando as datas das linhas recebidas. Uma resposta vazia mantém a referência anterior. A data escolhida é preservada na sua representação textual original para a comparação exclusiva seguinte, em vez de passar pela serialização de datas do celular.

O relógio do dispositivo serve para calcular a espera entre tentativas. Não certifica quais as alterações do servidor que o dispositivo já observou.

Limites de repetição e testes de regressão

O tratamento das falhas também precisa distinguir “não” de “agora não”. A fila conta todas as tentativas que falharam separadamente das falhas que consomem o limite de repetição. Erros de transporte e falhas temporárias conhecidas do servidor aumentam a espera exponencial, até um teto, sem consumir a margem reservada às falhas permanentes. Uma interrupção breve do serviço não deve remover um veículo criado localmente e todos os registros que dependem dele.

Os testes de regressão exercitam estas decisões diretamente: cancelar uma criação ainda não enviada, manter a eliminação depois de uma criação em curso, impedir um segundo envio simultâneo, recuperar uma resposta perdida, preservar a base original de uma atualização e distinguir conflito de falta de acesso. O servidor e o armazenamento são simulados. Os testes verificam as decisões do cliente, não as políticas instaladas nem uma rede móvel real.

Nenhuma destas correções alterou o formulário de abastecimento. Alteraram as provas de que a sincronização precisa antes de considerar uma escrita cancelada, confirmada ou definitivamente recusada.

Para quem está construindo uma fila offline, vale a pena testar cedo o par criar e eliminar. Mantenha a resposta da inserção em espera, elimine a linha local e veja o que fica na fila. Este teste expõe uma suposição que um teste habitual em modo avião não alcança.

Crio o OdoKeep para quem quer manter o histórico do seu veículo sem precisar de conexão em cada registro. Estas são algumas das decisões menos visíveis por trás dessa promessa. O app está disponível na App Store.