Dados no Dispositivo: localStorage, IndexedDB e OPFS
Política local-first para reduzir tráfego, armazenar rascunhos e sincronizar somente o que precisa ser durável.
Objetivo: usar o armazenamento do dispositivo como camada deliberada da arquitetura, principalmente para rascunhos e imagens, sem tratá-lo como um backup garantido ou como substituto do banco canônico.
Estado atual
| Componente | Estado no workspace | Direção |
|---|---|---|
WebappAI_DB v3 | canônico em src/lib/db/device/indexeddb | nome físico e versão preservados |
image_blobs | implementado e testado | Blobs e miniaturas separados dos metadados |
navigator.storage.persist() e estimate() | implementado | adicionar UX de quota, limpeza e exportação |
localStorage | uso distribuído | centralizar preferências em src/lib/db/device |
| Outbox local geral | planejado | IndexedDB, nunca localStorage |
| OPFS | estudo futuro | adotar apenas para arquivos grandes medidos |
O fluxo de mídia já evita o upload automático de rascunhos de IA no modo local. A arte selecionada é promovida ao backend durável antes de participar de um pedido. Os detalhes específicos estão em Mídia local-first.
A escada de armazenamento
Escolha a camada menos durável que ainda satisfaz o requisito:
| Camada | Deve armazenar | Não deve armazenar |
|---|---|---|
| Memória | estado de tela, respostas descartáveis, Blob URLs | qualquer referência que precise sobreviver a reload |
sessionStorage | hints pequenos limitados à aba | dados de negócio ou binários |
localStorage | tema, consentimento, filtros, flags e IDs reconstruíveis | imagens, base64, secrets, filas, grandes JSONs |
| IndexedDB | objetos estruturados, Blobs, miniaturas, cache e outbox | única cópia de pedidos/arquivos confirmados |
| OPFS | arquivos locais grandes com acesso intensivo | primeira opção para dados pequenos e consultáveis |
| Backend durável | pedidos, pagamentos, documentos fiscais e mídia promovida | rascunhos descartáveis de alta rotatividade |
localStorage é síncrono e bloqueia a thread principal. IndexedDB é assíncrono,
transacional por object store e aceita Blob sem conversão para base64. OPFS
oferece uma interface de arquivos privada à origem, mas adiciona complexidade e
não elimina políticas de quota ou descarte do navegador.
Ciclo de vida de uma imagem
stateDiagram-v2
[*] --> LocalDraft: gerar/importar
LocalDraft --> LocalDraft: editar e criar variantes
LocalDraft --> PromotionPending: usuário confirma uso durável
PromotionPending --> DurableRemote: upload + checksum confirmados
PromotionPending --> LocalDraft: falha recuperável
DurableRemote --> LocalCache: cache opcional
LocalCache --> DurableRemote: cache removido
LocalDraft --> [*]: usuário limpa ou política expira
Regras:
- O original é salvo como
Blob, nunca como string base64. - IDs estáveis, e não Blob URLs, atravessam navegação e reload.
- Miniaturas são geradas best effort e podem ser refeitas.
- Promoção é explícita: escolha do usuário, orçamento ou pedido.
- O backend confirma a referência durável antes do vínculo transacional.
- Falha de upload mantém o rascunho local e uma intenção retomável.
- A fila contém ID, checksum e metadados; o binário usa transporte próprio.
Organização alvo
src/lib/db/device/
├── contracts.ts # portas e erros independentes do navegador
├── preferences/
│ └── local-storage.ts # valores pequenos, versionados e validados
├── indexeddb/
│ ├── database.ts # abertura e upgrades de WebappAI_DB
│ ├── image-store.ts # Blob, miniatura e metadados
│ ├── cache-store.ts # cache com expiração
│ └── outbox-store.ts # intenções pequenas e idempotentes
├── opfs/ # vazio até existir caso medido
├── quota.ts # persist(), estimate() e políticas
└── ownership.ts # usuário, sessão e troca de conta
O módulo público deve expor operações do domínio local, não IDBRequest, nomes
de object stores ou chaves de localStorage.
Contratos mínimos
export interface DeviceImageStore {
putDraft(input: LocalImageDraft): Promise<LocalImageRef>;
getBlob(id: string, ownerId: string): Promise<Blob | null>;
listDrafts(ownerId: string): Promise<LocalImageSummary[]>;
markPromoted(id: string, remote: DurableImageRef): Promise<void>;
deleteDraft(id: string, ownerId: string): Promise<void>;
}
export interface DeviceOutbox {
enqueue(intent: SyncIntent): Promise<void>;
claimBatch(limit: number): Promise<SyncIntent[]>;
acknowledge(id: string): Promise<void>;
}
Toda intenção sincronizável precisa de id, ownerId, createdAt, versão de
schema e chave de idempotência. Retry usa backoff e limite; não deve criar um
loop agressivo quando o dispositivo estiver offline.
Ownership, sessão e privacidade
O storage é separado por origem do navegador, não automaticamente por conta da aplicação. Portanto:
- todo registro sensível inclui
ownerIde é filtrado na leitura; - logout encerra acessos, revoga Blob URLs e aplica a política de limpeza;
- troca de conta nunca exibe rascunhos do usuário anterior;
- modo visitante usa namespace próprio e oferece limpeza explícita;
- BroadcastChannel ou mecanismo equivalente coordena alterações entre abas;
- conteúdo privado não vira URL pública apenas por estar em cache;
- tokens de provider, chaves de serviço e credenciais não entram no storage do navegador.
Excluir tudo no logout nem sempre é a melhor experiência, mas manter dados privados indefinidamente também não é aceitável. A decisão deve ser explícita por classe: apagar, manter criptografado, expirar ou pedir confirmação.
Quota, eviction e durabilidade
O navegador pode negar persistência, limitar espaço ou remover dados. Antes de operações grandes:
- leia
navigator.storage.estimate(); - reserve margem para a operação e para rollback;
- solicite
navigator.storage.persist()sem assumir sucesso; - rejeite cedo com erro de domínio (
device_quota_exceeded); - ofereça limpeza seletiva, exportação e nova tentativa.
Uma política inicial razoável remove primeiro miniaturas reconstruíveis, depois caches expirados e só então rascunhos antigos com confirmação do usuário. Pedidos confirmados não dependem desse processo porque já foram promovidos.
Redução de tráfego
- deduplicar por SHA-256 antes de promover;
- enviar original uma única vez e referenciar seu ID;
- gerar miniaturas no cliente quando isso não comprometer qualidade;
- usar cache com TTL e versionamento de conteúdo;
- evitar polling quando eventos locais ou revalidação condicional bastarem;
- pausar sincronização pesada em conexão limitada quando possível;
- medir bytes evitados, bytes promovidos, taxa de cache hit e falhas de quota.
As métricas não devem carregar nome de arquivo, prompt, conteúdo, email ou IDs de alta cardinalidade. Use classes de tamanho e resultado agregadas.
Sincronização
Local-first não é “sincronizar tudo”. Classifique cada dado:
| Classe | Política |
|---|---|
| Preferência | local; sincronização opcional |
| Cache | nunca sincroniza; pode ser descartado |
| Rascunho | local até ação explícita ou política de backup |
| Intenção de negócio | outbox pequena, idempotente e autenticada |
| Registro canônico | backend confirma antes de ser tratado como durável |
| Binário promovido | upload próprio + checksum; fila guarda apenas referência |
Conflitos precisam de regra por domínio. “Última escrita vence” só é aceitável para preferências sem impacto financeiro. Pedido, pagamento e estoque exigem autoridade do servidor e idempotência.
Testes de conformidade
- upgrade de versões antigas do IndexedDB sem perda;
- reload e reabertura do navegador;
- duas abas editando o mesmo rascunho;
- logout e troca de conta;
- quota negada, storage não persistente e modo privado;
- upload interrompido e retomado;
- Blob presente com miniatura ausente, e vice-versa;
- deduplicação e promoção repetida com a mesma chave;
- limpeza sem remover mídia já vinculada a uma operação pendente.
Roadmap incremental
- Em andamento: inventariar chaves de
localStoragee acessos diretos a IndexedDB. - Concluído para imagens: mover
WebappAI_DBparasrc/lib/db/devicesem renomeá-lo, mantendo reexport no caminho antigo e teste de conformidade. - Migrar preferências pequenas com validação e versionamento.
- Formalizar ownership, quota, limpeza e exportação de rascunhos.
- Adicionar outbox apenas para intenções que precisem sobreviver offline.
- Medir arquivos grandes antes de decidir pela adoção de OPFS.