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

ComponenteEstado no workspaceDireção
WebappAI_DB v3canônico em src/lib/db/device/indexeddbnome físico e versão preservados
image_blobsimplementado e testadoBlobs e miniaturas separados dos metadados
navigator.storage.persist() e estimate()implementadoadicionar UX de quota, limpeza e exportação
localStorageuso distribuídocentralizar preferências em src/lib/db/device
Outbox local geralplanejadoIndexedDB, nunca localStorage
OPFSestudo futuroadotar 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:

CamadaDeve armazenarNão deve armazenar
Memóriaestado de tela, respostas descartáveis, Blob URLsqualquer referência que precise sobreviver a reload
sessionStoragehints pequenos limitados à abadados de negócio ou binários
localStoragetema, consentimento, filtros, flags e IDs reconstruíveisimagens, base64, secrets, filas, grandes JSONs
IndexedDBobjetos estruturados, Blobs, miniaturas, cache e outboxúnica cópia de pedidos/arquivos confirmados
OPFSarquivos locais grandes com acesso intensivoprimeira opção para dados pequenos e consultáveis
Backend durávelpedidos, pagamentos, documentos fiscais e mídia promovidarascunhos 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:

  1. O original é salvo como Blob, nunca como string base64.
  2. IDs estáveis, e não Blob URLs, atravessam navegação e reload.
  3. Miniaturas são geradas best effort e podem ser refeitas.
  4. Promoção é explícita: escolha do usuário, orçamento ou pedido.
  5. O backend confirma a referência durável antes do vínculo transacional.
  6. Falha de upload mantém o rascunho local e uma intenção retomável.
  7. 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 ownerId e é 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:

  1. leia navigator.storage.estimate();
  2. reserve margem para a operação e para rollback;
  3. solicite navigator.storage.persist() sem assumir sucesso;
  4. rejeite cedo com erro de domínio (device_quota_exceeded);
  5. 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:

ClassePolítica
Preferêncialocal; sincronização opcional
Cachenunca sincroniza; pode ser descartado
Rascunholocal até ação explícita ou política de backup
Intenção de negóciooutbox pequena, idempotente e autenticada
Registro canônicobackend confirma antes de ser tratado como durável
Binário promovidoupload 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

  1. Em andamento: inventariar chaves de localStorage e acessos diretos a IndexedDB.
  2. Concluído para imagens: mover WebappAI_DB para src/lib/db/device sem renomeá-lo, mantendo reexport no caminho antigo e teste de conformidade.
  3. Migrar preferências pequenas com validação e versionamento.
  4. Formalizar ownership, quota, limpeza e exportação de rascunhos.
  5. Adicionar outbox apenas para intenções que precisem sobreviver offline.
  6. Medir arquivos grandes antes de decidir pela adoção de OPFS.

Leitura relacionada