Offline Sync com tempest-fastapi-sdk
Esta receita liga o frontend (tempest-react-sdk) ao backend (tempest-fastapi-sdk) num fluxo offline-first completo: o app continua usável sem rede, grava localmente, e sincroniza com o servidor quando a conexão volta — sem perder nada e sem duplicar.
Ela é o par desta receita do backend: Offline-First Sync — tempest-fastapi-sdk. Leia as duas juntas: o contrato (endpoints, watermark, tombstones) vem do backend; aqui montamos o lado do navegador.
As duas metades
┌─────────────── navegador (tempest-react-sdk) ───────────────┐ ┌──────── servidor (tempest-fastapi-sdk) ────────┐
│ createOfflineStore (IndexedDB) ← fonte de verdade local │ │ GET /api/analyses/changes → delta (since) │
│ createApiClient → push (upsert por id) │ ───► │ PUT /api/analyses/{id} → upsert idempotente │
│ installBackgroundSync (SW) → fila + replay offline │ │ soft delete (is_deleted/deleted_at) │
└──────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────┘
- Pull (servidor → local): busca só o que mudou desde o último
server_time(watermark), pagina até esgotar, aplica upserts e tombstones no store local. - Push (local → servidor): cada mutação usa um
idgerado no cliente e faz upsert — então repetir a chamada não duplica. O service worker enfileira o que falhar offline e reenvia sozinho quando volta a rede.
Por que isso funciona com replay automático
O contrato do backend é upsert por id do cliente (idempotente) + last-write-wins por updated_at. Isso é exatamente o que torna o installBackgroundSync seguro: reenviar a mesma mutação N vezes converge pro mesmo estado.
O jeito rápido: createOfflineSync
O createOfflineSync empacota o motor inteiro — outbox durável (IndexedDB), flush single-flight (push → pull), guarda de offline, loop de paginação e watermark — atrás de três callbacks de transporte. Você diz como entregar uma mutação, como buscar uma página do delta e como aplicar um item; o SDK cuida do resto.
// src/sync/engine.ts
import { createOfflineSync } from "tempest-react-sdk";
import { api } from "@/lib/api";
import { analyses } from "./store"; // createOfflineStore
import { fromDto, toPayload, type Analysis, type AnalysisDto } from "./types";
export const sync = createOfflineSync<Analysis, AnalysisDto>({
databaseName: "AnalysesOutbox",
watermark: { storageKey: "analyses.watermark" },
// push: entregue uma mutação da fila
deliver: async (entry) => {
if (entry.op === "delete") {
await api.delete(`/api/analyses/${entry.recordId}`);
return;
}
await api.put(`/api/analyses/${entry.recordId}`, { body: toPayload(entry.payload!) });
},
// pull: uma página do delta desde o watermark
pullPage: async (since, cursor) => {
const page = await api.get<{ items: AnalysisDto[]; next_cursor: string | null; server_time: string }>(
"/api/analyses/changes",
{ params: { since: since ?? undefined, cursor: cursor ?? undefined } },
);
return { items: page.items, nextCursor: page.next_cursor, serverTime: page.server_time };
},
// applyRemote: funda um item do servidor no store local (você é dono do conflito)
applyRemote: async (dto) => {
if (dto.is_deleted) {
await analyses.delete(dto.id);
return;
}
const local = await analyses.get(dto.id);
// last-write-wins: preserva uma edição local pendente que seja mais nova
if (local?.pending && local.updated_at > dto.updated_at) return;
await analyses.put(fromDto(dto));
},
});
Uso na UI:
// grava local, enfileira e dispara o flush
await analyses.put({ ...record, pending: true });
await sync.enqueue(record.createdOnServer ? "update" : "create", record.id, record);
await sync.flush("after-mutation");
sync.pendingCount(); // badge de pendências
sync.resetWatermark(); // no logout / troca de conta
sync.clearOutbox();
Quando sincronizar
Dispare sync.flush("boot") no boot, sync.flush("online-event") quando a rede voltar (veja useOnline), e no handler de web push. O flush é single-flight — gatilhos concorrentes colapsam num único run — e é pulado sozinho quando offline (summary.skipped).
Entregas que falham ficam na fila
Se deliver lança, a entrada permanece na outbox com attempts incrementado e lastError gravado — o próximo flush tenta de novo. Combine com installBackgroundSync no service worker para reenviar mesmo com o app fechado.
A ordem por registro é preservada na falha — e antes não era
Quando uma entrada falha, as demais entradas daquele mesmo registro ficam
sem ser tentadas nesta passada, e voltam na próxima na ordem original. Elas
aparecem em summary.deferred, separadas de summary.failed, porque nunca
foram enviadas.
Isso importa porque deliver normalmente é um PUT upsert. Sem a regra, um
create que falha seguido de um update do mesmo registro entregava o
update primeiro: o servidor criava o registro pelo payload do update, e o
create reenviado depois sobrescrevia com o snapshot mais antigo. A edição
do usuário desaparecia sem erro em lugar nenhum.
O bloqueio é por registro, não por passada: um registro que o servidor está recusando não segura as mutações de nenhum outro.
As seções abaixo mostram o que o motor faz por baixo (e como montar à mão quando você precisa de controle fino — múltiplos stores, merge por campo, etc.).
Pré-requisitos
- Backend com a receita offline-sync do
tempest-fastapi-sdk(endpointGET /api/analyses/changes+ rota de upsert). dexieinstalado (peer docreateOfflineStore):npm i dexie.- Um app PWA — gere com
create-tempest-app --pwa(já traz o service worker). Veja Scaffold › PWA.
1. O modelo
Espelhe a entidade do backend. O id é gerado no cliente (UUID) e updated_at é a chave de conflito:
// src/sync/types.ts
export interface Analysis {
id: string; // UUID gerado no cliente (crypto.randomUUID)
user_id: string;
animal_id: string;
notes: string;
created_at: string; // ISO 8601
updated_at: string; // ISO 8601 — last-write-wins
is_deleted: boolean;
deleted_at: string | null;
}
/** Resposta de GET /api/analyses/changes (SyncPaginationSchema do backend). */
export interface ChangesResponse {
items: Analysis[];
next_cursor: string | null;
has_more: boolean;
limit: number;
server_time: string; // watermark — guarde para o próximo `since`
}
2. O store local (IndexedDB)
// src/sync/store.ts
import { createOfflineStore } from "tempest-react-sdk";
import type { Analysis } from "./types";
/** Fonte de verdade offline, escopada por usuário. */
export const analyses = createOfflineStore<Analysis, string>({
databaseName: "FamachaOffline",
version: 1,
tableName: "analyses",
indexes: "&id, user_id, updated_at",
ownerField: "user_id",
});
3. Pull — baixar o delta
Drena todas as páginas desde o watermark, aplica upserts e remove tombstones, e só então persiste o novo server_time. A regra de ouro do backend: o próximo since é o server_time da resposta, nunca o relógio do cliente.
// src/sync/pull.ts
import { analyses } from "./store";
import { api } from "@/lib/api";
import type { Analysis, ChangesResponse } from "./types";
const sinceKey = (userId: string) => `sync:analyses:since:${userId}`;
/** Sincroniza o servidor → local. Idempotente: pode rodar quantas vezes quiser. */
export async function pullAnalyses(userId: string): Promise<void> {
const since = localStorage.getItem(sinceKey(userId)) ?? undefined;
let cursor: string | undefined;
let serverTime: string | undefined;
do {
const page = await api.get<ChangesResponse>("/api/analyses/changes", {
params: { since, cursor, limit: 100, include_deleted: true },
});
const live = page.items.filter((a) => !a.is_deleted);
const tombstones = page.items.filter((a) => a.is_deleted);
if (live.length) await analyses.bulkPut(live, userId);
for (const dead of tombstones) await analyses.delete(dead.id);
serverTime ??= page.server_time; // snapshot da 1ª página
cursor = page.has_more ? (page.next_cursor ?? undefined) : undefined;
} while (cursor);
if (serverTime) localStorage.setItem(sinceKey(userId), serverTime);
}
Primeira sincronia drena tudo
Sem since, o backend devolve o estado completo paginado. O loop acima respeita has_more e next_cursor até esgotar — não pare na primeira página.
4. Push — mutações offline-first
Grave primeiro no local (UI responde na hora), depois faça o upsert no servidor. Como o id é do cliente e a rota é upsert, o reenvio é seguro:
// src/sync/push.ts
import { analyses } from "./store";
import { api } from "@/lib/api";
import type { Analysis } from "./types";
export async function saveAnalysis(
userId: string,
input: { animal_id: string; notes: string; id?: string },
): Promise<Analysis> {
const now = new Date().toISOString();
const existing = input.id ? await analyses.get(input.id) : undefined;
const record: Analysis = {
id: input.id ?? crypto.randomUUID(),
user_id: userId,
animal_id: input.animal_id,
notes: input.notes,
created_at: existing?.created_at ?? now,
updated_at: now,
is_deleted: false,
deleted_at: null,
};
// 1. Local primeiro — a UI nunca espera a rede.
await analyses.put(record, userId);
// 2. Upsert no servidor (idempotente por id). Se falhar offline, o
// service worker enfileira e reenvia sozinho (passo 5).
await api.put(`/api/analyses/${record.id}`, { body: record });
return record;
}
/** Soft delete: marca tombstone local + propaga. */
export async function deleteAnalysis(userId: string, id: string): Promise<void> {
const now = new Date().toISOString();
await analyses.update(id, { is_deleted: true, deleted_at: now, updated_at: now });
await api.put(`/api/analyses/${id}`, {
body: { ...(await analyses.get(id)) },
});
}
5. Background sync no service worker
No src/sw.ts (gerado pelo --pwa), enfileire as mutações de /api/analyses que falharem offline — elas são reenviadas quando a conexão voltar:
// src/sw.ts
import { installBackgroundSync } from "tempest-react-sdk/sw";
installBackgroundSync({
match: (url) => url.pathname.startsWith("/api/analyses"),
});
Isso cobre PUT/POST/PATCH/DELETE. O saveAnalysis acima já gravou local, então a UI não trava: o await api.put(...) rejeita offline, o SW guarda a requisição numa fila IndexedDB e a replaya no evento sync (ou no próximo request, em navegadores sem Background Sync API). Como o upsert é idempotente, o replay converge. Detalhes em Web Push & Service Worker.
6. Quando sincronizar
Dispare um pullAnalyses no boot, ao voltar online e periodicamente:
// src/sync/index.ts
import { pullAnalyses } from "./pull";
import { useAuth } from "@/stores/auth";
export function startSync(): void {
const userId = useAuth.getState().user?.id;
if (!userId) return;
const run = () => void pullAnalyses(userId).catch(console.warn);
run(); // boot
window.addEventListener("online", run); // reconectou
setInterval(run, 60_000); // polling leve (ou troque por web push)
}
Push em vez de polling
Se o backend dispara um web push quando há mudança, troque o setInterval por um pullAnalyses no handler de notificação — sincronia quase em tempo real e sem polling. Veja Web Push + usePushSubscription.
UI reativa e flush automático
Não fie o estado de sincronização na mão: useOfflineSync(sync, opts) inscreve o componente no motor (via subscribe/getState) e expõe { phase, pending, syncing, lastSummary, lastError, flush, enqueue }, com flush opcional no mount, no evento online e num intervalo. useSyncStatus(sync) devolve um { tone, pending, syncing } pronto pro <SyncStatusBadge>. Para a barra offline e o prompt de atualização do SW, veja PWA & Offline-First.
import { useOfflineSync, SyncStatusBadge, useSyncStatus } from "tempest-react-sdk";
import { sync } from "@/sync/engine";
function SyncBadge() {
const { tone, pending } = useSyncStatus(sync);
return <SyncStatusBadge tone={tone} pending={pending} />;
}
Para mutações otimistas ligadas ao TanStack Query, use useOfflineMutation — veja Query.
Coerência entre abas
Passe crossTab: true no createOfflineSync pra propagar o SyncState entre abas do mesmo origin via BroadcastChannel — uma aba que dá flush zera o badge de pendências em todas. O outbox já é um IndexedDB compartilhado; isto só espelha o estado em memória. flush continua single-flight por aba (entregas idempotentes por id cobrem o resto). Chame sync.dispose() no teardown pra fechar o canal.
export const sync = createOfflineSync<Analysis, AnalysisDto>({
databaseName: "AnalysesOutbox",
crossTab: true,
// ...
});
Conflitos: last-write-wins
O backend resolve por updated_at (a escrita mais recente vence). No cliente, o pull faz bulkPut, que sobrescreve o registro local pela versão do servidor — então uma edição local não confirmada pode ser substituída por uma versão mais nova do servidor. Se o seu domínio precisa de merge mais fino (campos independentes, CRDT), trate isso na camada de saveAnalysis/pull; o SDK te dá os blocos, não impõe a política.
Para os dois casos comuns, o SDK exporta resolvedores prontos pra usar dentro do applyRemote (empate → remoto vence):
import { lastWriteWins, higherVersionWins } from "tempest-react-sdk";
applyRemote: async (dto) => {
const local = await analyses.get(dto.id);
await analyses.save(lastWriteWins(local, dto, (r) => r.updatedAt));
// ou: higherVersionWins(local, dto, (r) => r.version)
};
Recap
createOfflineStoreé a fonte de verdade local (IndexedDB, escopado poruser_id).- Pull drena
GET /api/analyses/changesporhas_more, aplica upserts/tombstones e guarda oserver_timecomo próximosince. - Push grava local primeiro e faz upsert por
id— idempotente. installBackgroundSyncreenfileira e replaya mutações offline; idempotência torna o replay seguro.- Conflito = last-write-wins por
updated_at, definido pelo backend. - Casa 1:1 com a receita offline-sync do
tempest-fastapi-sdk. 🚀