Sincronizzazione offline
Proteggere le modifiche offline in React Native con MMKV e Supabase
Come una coda MMKV gestisce eliminazioni durante l’invio, risposte perse e modifiche obsolete in React Native con Supabase.
Aggiornato il
In questo articolo
Un dato che ricompare dopo essere stato eliminato è un ottimo modo per far perdere fiducia in un diario.
Era uno dei casi che dovevo risolvere in OdoKeep, la mia app per tenere traccia dei veicoli. Una persona poteva creare una voce, eliminarla mentre l'inserimento era ancora in viaggio e ritrovarla alla sincronizzazione successiva. L'eliminazione locale funzionava. Anche l'inserimento sul server. Era la ricostruzione degli eventi nella coda a essere sbagliata.
OdoKeep usa React Native ed Expo, MMKV per la persistenza locale e Supabase per la copia sul server. I repository scrivono localmente e accodano l'operazione. Le schermate usano i dati locali mentre il gestore della sincronizzazione si occupa della rete.
Questo permette di salvare un rifornimento senza connessione. Richiede però che la coda rappresenti più di un elenco di richieste da riprovare.
Una creazione in coda non è una creazione già inviata
La prima distinzione utile separa l'operazione non ancora inviata da quella inviata ma ancora senza risposta.
In una normale sessione offline:
Creare la voce locale
Accodare INSERT
Eliminare la voce locale
Annullare INSERT in coda
Non serve contattare il server: la voce non è mai esistita lì. Ridurre creazione ed eliminazione a nessuna operazione è corretto.
Inseriamo ora una connessione lenta:
Creare la voce locale
Inviare INSERT
Eliminare la voce locale
Annullare INSERT in coda
Il server completa INSERT
La lettura successiva restituisce la voce
Rimuovere un'operazione da un array non richiama una richiesta già partita. La coda definiva ancora la creazione «pending» e l'eliminazione interpretava quel termine come prova che il server non l'avesse vista.
La soluzione è un insieme degli identificatori delle operazioni in corso. In lib/sync-queue.ts, la ricerca di una creazione annullabile include questa condizione:
const pendingCreate = pending.find(
(op) => op.type === "create" && !this.inFlight.has(op.id),
);
Se la creazione è già stata inviata, eliminare la voce deve accodare un'eliminazione sul server. Non si possono più annullare entrambe localmente.
L'insieme rimane volutamente in memoria. Dopo un riavvio il processo non ha richieste attive da seguire. Salvare inFlight farebbe credere al prossimo avvio che un'operazione sia ancora in corso, senza nessuno che possa ripulire il flag. La coda persistente sopravvive; la descrizione delle richieste attive del vecchio processo no.
Questo insieme impedisce anche a un secondo svuotamento della coda di inviare un'operazione già in attesa. La riconnessione e il gesto di aggiornamento non devono produrre due inserimenti contemporanei. Il gestore protegge l'esecuzione e la coda esclude autonomamente le operazioni attive dall'elenco disponibile. Anche una richiesta manuale che salta il tempo di attesa rispetta questa esclusione.
Recuperare un inserimento quando si perde la risposta
Il server può salvare una riga e il telefono perdere la risposta.
Al tentativo successivo, un normale inserimento può fallire perché l'identificatore esiste già. Considerarlo un rifiuto definitivo annullerebbe localmente una voce salvata correttamente sul server.
Nel percorso ordinario di creazione, il gestore chiede a PostgREST di ignorare un identificatore duplicato invece di sovrascriverlo. Se non ritorna una riga, rilegge quell'identificatore. Una riga accessibile conferma l'inserimento e viene adottata dal dispositivo. Se la verifica fallisce, l'operazione resta ripetibile. Una collisione non leggibile non viene considerata un successo.
Non è una garanzia di consegna esattamente una volta. È un percorso di recupero che può ritrovare la prova dell'inserimento senza sostituire i dati esistenti.
Aggiornare la versione che è stata davvero modificata
Condividere un veicolo introduce un altro problema. Due persone possono modificare una voce partendo dalla stessa versione del server, mentre una o entrambe sono offline.
Una scrittura filtrata solo per ID permette all'ultima sincronizzazione di cancellare il lavoro precedente. Entrambe le richieste possono riuscire senza che nessuno sappia di aver perso una modifica.
Gli aggiornamenti accodati contengono quindi baseUpdatedAt, il timestamp del server noto prima della modifica locale. Questa è la condizione essenziale, semplificata da lib/sync-manager.ts:
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);
L'aggiornamento passa solo se la riga conserva la versione da cui è partita la modifica. L'API di aggiornamento Supabase restituisce le righe interessate con .select(), rendendo osservabile un risultato vuoto.
Catturare la base richiede attenzione. Dopo una prima modifica, la riga locale può contenere un timestamp generato dal telefono. La seconda modifica non deve usarlo al posto della base in coda: il server non l'ha mai emesso. La fusione delle operazioni conserva la base originale e combina i cambiamenti del contenuto.
Una riga appena creata è un'altra eccezione. Finché il server non la conferma, il timestamp locale non può diventare una precondizione remota. Per questo la coda distingue «esiste ancora un'operazione di creazione» da «quella creazione è ancora annullabile».
Un risultato vuoto va interpretato. Un altro autore può aver modificato la riga. La riga può essere stata eliminata. Per un veicolo condiviso, l'accesso o il ruolo possono essere cambiati.
Il ramo dei conflitti verifica che esista una riga leggibile con un timestamp del server più recente. Solo questa prova produce l'avviso di modifica obsoleta. L'operazione viene rifiutata definitivamente, l'utente viene avvisato e il dispositivo si riallinea al server. Riprovare senza la precondizione riprodurrebbe la sovrascrittura che si voleva evitare.
La politica attuale fa prevalere la copia server nei conflitti confermati. Non ci sono fusioni campo per campo o un editor dei conflitti. Un'operazione creata da una versione precedente senza base non può ricevere questa protezione retroattivamente. Sono limiti da dichiarare.
Usare un riferimento temporale del server
Anche il download incrementale richiede cautela. Un telefono può avere l'orologio avanti di due minuti. Se salva l'ora locale come riferimento, la lettura successiva salterà modifiche fino a un istante che per il server è ancora futuro.
lib/delta-watermark.ts avanza il riferimento usando i timestamp delle righe ricevute. Una risposta vuota conserva il precedente. La stringa del server rimane nel formato originale per il successivo confronto esclusivo, senza passare dal serializzatore di date del telefono.
L'orologio locale serve a calcolare le attese. Non può certificare quali modifiche del server siano state osservate.
Tentativi e test di regressione
I fallimenti devono distinguere «no» da «non adesso». La coda conta separatamente i tentativi falliti totali e quelli che consumano il limite. Errori di trasporto e problemi temporanei noti producono un'attesa esponenziale con un tetto, senza consumare la quota dei fallimenti permanenti. Una breve interruzione non dovrebbe eliminare un veicolo locale e tutte le voci accodate.
I test verificano annullamento di creazioni non inviate, eliminazione dopo un invio in corso, esclusione da una seconda esecuzione, recupero della risposta persa, conservazione della base e distinzione tra conflitto e accesso mancante. Server e storage sono simulati. Si controllano le decisioni del client, non le politiche distribuite o una rete mobile reale.
Queste correzioni non hanno cambiato il modulo del carburante. Hanno cambiato le prove richieste per considerare una scrittura annullata, confermata o rifiutata definitivamente.
Se stai costruendo una coda offline, prova presto a creare e poi eliminare: lascia aperta la risposta dell'inserimento, elimina la riga locale e ispeziona la coda. Emerge un'ipotesi che un normale test in modalità aereo non raggiunge.
Costruisco OdoKeep per conservare la storia di un veicolo senza richiedere una connessione a ogni voce. Queste sono alcune decisioni meno visibili dietro quella promessa. L'app è disponibile sull'App Store.