Prova de conceito de um app de registro de pedidos de venda que funciona sem rede: tudo é gravado no dispositivo, enfileirado na outbox e sincronizado com o servidor quando a conexão volta — com retry/backoff, conflito, idempotência, multi-aba e PWA instalável.
Índice
Funcionalidades
Registro (app normal): criar/editar/excluir pedidos, busca na lista e ícone de estado por item (
CloudCheckno servidor,CloudUploadna fila,TriangleAlertem falha).Backoffice (tudo de sync): espelho do servidor, outbox, simulação de rede/falhas, log de eventos e manutenção.
Outbox em IndexedDB com coalescência — uma única operação por pedido, ordem preservada (
seq),chavede idempotência renovada a cada mutação.Retry com backoff exponencial + jitter (base 1s, teto 30s, 6 tentativas); erros
422vão direto para quarentena.Pull bidirecional com cursor e tombstones (remoções propagadas).
Conflito
409: o device adota a versão do servidor, descarta a operação e contabiliza o conflito.Idempotência: replay da mesma
chaveno servidor devolve o mesmo registro.Multi-aba:
BroadcastChannelpara propagar mudanças +navigator.lockspara garantir uma única drenagem por vez.Simulação: switch Online/Offline, taxa de falha, latência, pausar fila, reiniciar servidor, limpar dispositivo.
PWA: manifest instalável + service worker (registrado só em produção).
UI: tema claro/escuro, busca, badge de última sincronização, layout responsivo (tabelas viram lista de cards no mobile).
Começando
Requisitos: Node 24+ e npm 11+.
npm install
npm run dev # http://localhost:5173
Scripts
Script | O que faz |
|---|---|
| servidor de desenvolvimento com HMR |
|
|
| serve o |
| oxlint (0 erros; 5 avisos conhecidos em arquivos shadcn) |
| Vitest (jsdom + fake-indexeddb) |
| Vitest em watch mode |
| Prettier escreve a formatação padrão |
| Prettier verifica (roda no CI) |
| só o |
| lint + format:check + test + build (rode antes de commitar) |
O service worker só é registrado com
import.meta.env.PROD; a experiência offline end-to-end aparece emnpm run preview, não nodev.
Arquitetura
flowchart TD
UI["UI React (Registro / Backoffice)"] --> HOOK["useEngine — useSyncExternalStore"]
HOOK --> ENG["SyncEngine<br/>outbox, retry, pull, log"]
ENG --> IDB[("IndexedDB poc-offline-sync<br/>store pedidos + fila")]
ENG --> SRV["Servidor mock<br/>localStorage"]
IDB -- "drenagem (push)" --> SRV
SRV -- "desde(cursor) (pull)" --> IDB
ENG -. "BroadcastChannel" .-> ABA["Outra aba"]
ENG -- "Web Locks (uma drenagem)" --> LOCK["poc-sync:drenar"]
src/lib/engine.ts— núcleo: fila, coalescência, backoff, pull, conflito, log, snapshot para o React.src/lib/mock-server.ts— backend simulado:put/removeidempotentes,409/422/503, tombstones edesde(cursor).src/lib/idb.ts— helpers de IndexedDB (listar/salvar/remover/limpar).src/lib/types.ts— contratos compartilhados (Pedido,Operacao,RegistroServidor,Snapshot).
Fluxo de sincronização
A UI grava no IndexedDB e na outbox — nunca fala com o servidor direto.
A outbox mantém uma operação por pedido: edições repetidas coalescem (
revechavesão renovados a cada mutação).drenar()envia na ordem da fila com latência simulada; sucesso remove a operação e grava aserverRevdo registro no device.Depois de um push (e no
init/reconexão) roda o pull:server.desde(cursor)aplica registros alterados e tombstones.Falha transitória → backoff + jitter; falha permanente (
422) → quarentena na UI;409→ adota a versão do servidor.
Protocolo cliente ↔ servidor
Situação | Resposta | Comportamento do engine |
|---|---|---|
Envio ok |
| remove da fila, grava |
Replay da mesma | mesmo registro | idempotente: nada muda |
|
| vai direto para quarentena ( |
Instabilidade |
| backoff + jitter até esgotar 6 tentativas |
Device com |
| device adota o servidor, descarta a op, conta o conflito |
Device apaga | tombstone | pull propaga a remoção para as demais abas/devices |
Backoff: espera = exp/2 + rand(0, exp/2) com exp = min(base * 2^(tentativa-1), teto) — evita thundering herd.
Multi-aba e concorrência
BroadcastChannel('poc-sync:canal'): quando uma aba termina de drenar, as outras rereem IndexedDB e o servidor.navigator.locks('poc-sync:drenar', { ifAvailable }): só uma aba drena por vez; quem não pega a trava cede.Operação interrompida:
syncinggravado no IndexedDB significa reload no meio do envio — no próximoinitvolta parapending.Prefs de simulação ficam em
poc-sync:sim;onlineusanavigator.onLine+ listeners (o switch do header sobrepõe).
PWA e modo offline
public/manifest.webmanifest+theme-colornoindex.html(instalável).public/sw.js: network-first para navegação (fallback do cache) e cache-first para assets same-originGET.Registro em
src/main.tsx, apenas quandoimport.meta.env.PROD.
Estrutura do projeto
src/
├─ components/ # app-header, app-sidebar, pedidos-panel, fila-sync,
│ └─ ui/ # componentes shadcn (não editar manualmente)
├─ hooks/ # use-engine (subscribe/getSnapshot)
├─ lib/
│ ├─ engine.ts # SyncEngine: outbox, backoff, pull, conflito, log
│ ├─ mock-server.ts # backend simulado (localStorage)
│ ├─ idb.ts # helpers de IndexedDB
│ ├─ types.ts # contratos
│ └─ backoff.ts # backoff + formatadores
├─ views/ # registro-view, backoffice-view
└─ test/ # setup do Vitest (fake-indexeddb)
docs/screenshots/ # imagens do README
.github/workflows/ # CI (lint, format, test, build)
Testes
npm test
20 testes em 3 arquivos:
backoff.test.ts— limites do backoff e formatadores.mock-server.test.ts— idempotência,409,422, tombstones,desde(cursor).engine.test.ts— outbox offline, coalescência, descarte de create não enviado, push ao voltar online, quarentena, conflito, pull e tombstone.
Padrão: cada teste cria uma SyncEngine nova com taxaFalha: 0 e latenciaMs: 0, espera com vi.waitFor e encerra com engine.parar() (fecha timers/canal). O beforeEach limpa localStorage e as duas stores do IndexedDB.
Gitflow
main— apenas releases estáveis (commits verdes no CI). Sempre via merge--no-ffa partir dedevelop.develop— integração: é onde o trabalho acontece.feat/*,fix/*,chore/*,docs/*— ramificam dedevelope voltam para ela.Commits: Conventional Commits em pt-BR (
feat:,fix:,chore:,docs:,test:).Hooks (husky + lint-staged) rodam Prettier e oxlint nos arquivos staged;
npm run verifyantes de cada merge emdevelop.Com remoto configurado, os merges passam a ser feitos por Pull Request (CI roda em
mainedevelop).
Limitações conhecidas (é uma POC)
Servidor é
localStorageno mesmo navegador — não há rede real nem backend.Conflito resolve automático (device adota o servidor); não há UI de escolha.
Sem autenticação, paginação nem migração de esquema.
Cursor de pull é monotônico por dispositivo (sem re-sync completo).
Licença
MIT — veja LICENSE.

