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 numa app React Native com Supabase.
Atualizado em
Neste artigo
Um registo que reaparece depois de ser eliminado é uma boa forma de fazer alguém perder a confiança num diário de bordo.
Foi um dos problemas que tive de resolver no OdoKeep, a minha app de gestão de veículos. Um utilizador podia criar um registo, 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. Os ecrãs podem trabalhar com os dados locais enquanto o gestor de sincronização trata da rede.
Isto permite guardar um abastecimento sem ligaçã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.
Numa sessão offline normal:
Criar o registo localmente
Colocar INSERT na fila
Eliminar o registo localmente
Cancelar o INSERT na fila
Não há motivo para contactar o servidor. O registo nunca existiu lá. Reduzir a criação e a eliminação a nenhuma operação é a otimização correta.
Agora coloque-se uma ligação lenta entre o segundo e o terceiro passos:
Criar o registo localmente
Enviar INSERT
Eliminar o registo localmente
Cancelar o INSERT na fila
O servidor conclui INSERT
A próxima leitura devolve o registo
Retirar uma operação de um array não faz regressar um pedido que já está na rede. A fila continuava a chamar «pendente» à criação, 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, eliminar o registo tem de colocar uma eliminação no servidor na 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 o arranque seguinte acreditasse que a operação ainda estava a ser 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 descarga da fila volte a enviar uma operação cuja resposta ainda não chegou. Uma sincronização iniciada ao recuperar a ligação e outra iniciada por um gesto de atualização não devem enviar a mesma inserção em simultâneo. O gestor protege a descarga 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 telemóvel 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 registo que o servidor tinha guardado.
No percurso normal de criação, o gestor 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 adota-a. 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.
Isto 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
A partilha de veículos trouxe outro problema. Duas pessoas podem editar um registo 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 guardar 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 registo acabado de criar é 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á registada» 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, num veículo partilhado, que o acesso do utilizador 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 utilizador é 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: num 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 da 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 telemóvel pode estar dois minutos adiantado. Se guardar 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 telemóvel.
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
As falhas também têm de distinguir «não» de «agora não». A fila conta todas as tentativas falhadas 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 registos 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á a construir 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 de voo não alcança.
Crio o OdoKeep para quem quer manter o histórico do seu veículo sem precisar de ligação em cada registo. Estas são algumas das decisões menos visíveis por trás dessa promessa. A app está disponível na App Store.