Arquitetura de Dados Agnóstica e Incremental

Como desacoplar persistência, identidade, continuidade e arquivos dos provedores concretos sem reescrever o sistema de uma vez.

Estado atual — fundação R1 implementada: src/lib/db já contém contratos, composition, registry físico de providers, continuidade e o IndexedDB canônico. Os caminhos antigos reexportam continuidade e mídia local para não quebrar consumidores. Os clientes e tipos Supabase também estão canônicos em src/lib/db/providers/supabase; src/lib/supabase é a fachada temporária. GET /api/db/capabilities oferece diagnóstico sanitizado apenas a administradores. O Supabase continua sendo o backend remoto principal e muitas rotas ainda usam seu SDK diretamente; Turso e PostgreSQL no homelab têm descriptors, mas ainda não possuem adapters de negócio ativos.

Por que “agnóstico” não significa “intercambiável”

Supabase, PostgreSQL, Turso/libSQL, MongoDB e SQLite AI têm modelos e capacidades diferentes. Esconder todas essas diferenças sob uma interface CRUD genérica produziria uma abstração frágil.

Neste projeto, agnóstico significa:

  • regras de negócio dependem de contratos da aplicação, não de SDKs externos;
  • cada responsabilidade declara as capacidades de que realmente precisa;
  • um adapter traduz o contrato para o provider escolhido;
  • diferenças importantes continuam explícitas e testáveis;
  • a migração ocorre por fatias verticais, com shadow read, métricas e rollback.

O provider fica na borda da arquitetura. Ele não vira a linguagem do domínio.

Mapa desta seção

DocumentoPergunta respondida
Dados no dispositivoO que fica em localStorage, IndexedDB ou, futuramente, OPFS?
Integração incremental com TursoComo adicionar o primeiro banco remoto alternativo com baixo risco?
Continuidade local-primaryComo operar Postgres local com Supabase como Auth, snapshots e fila de continuidade?
Mídia local-firstComo impedir que rascunhos de imagem consumam tráfego remoto?
Perfil Supabase local-firstQual é a visão executiva do perfil híbrido local + Supabase?
Storage e egressComo separar binários do banco e migrar um ObjectStore?
Chave de serviço do SupabaseComo configurar telemetria server-side sem expor secrets?

Separação por capacidade

Um “banco” não é uma responsabilidade única. A aplicação precisa separar os papéis abaixo antes de escolher ou trocar providers.

CapacidadeContrato da aplicaçãoImplementação atual ou candidata
Dados transacionais canônicosCanonicalStoreSupabase/Postgres hoje; Postgres local no perfil local-primary
Identidade e sessãoIdentityProviderSupabase Auth
Consultas e projeçõesProjectionStoreSupabase hoje; Turso como primeiro experimento
Continuidade durante falhaContinuityTransportsnapshots e comandos no Supabase
Arquivos duráveisObjectStoreSupabase Storage; R2/RustFS planejados
Preferências pequenas do navegadorDevicePreferenceStorelocalStorage
Dados estruturados e blobs locaisDeviceDataStoreIndexedDB (WebappAI_DB)
Arquivos locais grandesDeviceFileStoreOPFS, somente se medições justificarem

Esses contratos podem evoluir em ritmos diferentes. Trocar o banco de uma projeção não deve forçar a troca do Auth ou do storage de arquivos.

Perfis de implantação

O mesmo código pode compor adapters diferentes por perfil:

PerfilEscrita canônicaLeituraAuthContinuidadeUso
supabase-fullSupabase PostgresSupabaseSupabasenão ativaestado predominante atual
local-primaryPostgres locallocal + snapshotsSupabaseSupabasealvo para operação no homelab
turso-projectionpermanece no provider atualTurso para uma projeçãoSupabaseinalteradaexperimento incremental
managed-postgresPostgreSQL gerenciadomesmo Postgres/projeçõesconfigurávelconfigurávelportabilidade futura

O perfil é escolhido no composition root do servidor. Código de domínio não deve ler DB_PROVIDER, instanciar clientes ou decidir fallback por conta própria.

Organização física: implementada e alvo

Todo código de integração com bancos e storage deve convergir para:

src/lib/db/
├── contracts/              # portas, tipos e erros da aplicação
├── domain/                 # serviços que coordenam capacidades
├── providers/
│   ├── supabase/           # clientes/tipos canônicos; queries migram por fatia
│   ├── turso/              # descriptor; adapter read-only ainda planejado
│   ├── homelab/            # descriptor PostgreSQL local
│   └── types.ts            # estágio do adapter/registro
├── continuity/             # implementação canônica; caminho antigo reexporta
├── device/
│   ├── preferences/        # localStorage, valores pequenos
│   ├── indexeddb/          # dados estruturados, blobs e outbox local
│   └── opfs/               # evolução futura, não requisito inicial
├── routing/                # seleção explícita, shadow e fallback
├── migrations/             # registro neutro das versões lógicas
└── index.ts                # API pública mínima

src/pages/api/db/
├── capabilities.ts         # implementado: admin-only, sem rede ou secrets
├── health.ts               # futuro, somente com requisito operacional
└── sync.ts                 # futuro, quando existir sincronização autenticada

db/
├── supabase/               # migrations, templates e configuração do provider
├── turso/                  # schema/bootstrap quando o adapter existir
└── postgres/               # artefatos do Postgres independente, se necessários

src/pages/api/db não é uma API administrativa pública genérica. Rotas nesse diretório devem ser server-only, autenticadas, estreitas e sem retornar DSNs, tokens, nomes internos desnecessários ou detalhes de topologia.

Durante a refatoração, módulos antigos podem reexportar a nova implementação. Isso preserva consumidores enquanto a dependência é invertida gradualmente.

Contratos orientados ao domínio

Prefira portas pequenas e com intenção clara:

export interface CatalogReadStore {
  getProductBySlug(slug: string): Promise<ProductProjection | null>;
  listActiveProducts(input: CatalogQuery): Promise<ProductProjection[]>;
}

export interface OrderStore {
  createOrder(command: CreateOrderCommand): Promise<CreateOrderResult>;
  getOrderForUser(orderId: string, userId: string): Promise<Order | null>;
}

export interface ObjectStore {
  putObject(input: PutObjectInput): Promise<StoredObject>;
  getObject(key: string): Promise<StoredObjectBody | null>;
  deleteObject(key: string): Promise<void>;
}

Evite expor SupabaseClient, builders de query, PostgrestError, SQL cru ou tipos do SDK nos contratos. Paginação, consistência, transações, conflitos e erros precisam ter semântica definida pela aplicação.

Ciclo incremental e repetível

Cada fatia migra pelo mesmo ciclo:

flowchart LR
    A[Inventariar acoplamento] --> B[Definir contrato e invariantes]
    B --> C[Encapsular adapter atual]
    C --> D[Testar contrato]
    D --> E[Adicionar novo adapter]
    E --> F[Shadow read e métricas]
    F --> G{Gates aprovados?}
    G -- não --> H[Desligar flag e corrigir]
    H --> E
    G -- sim --> I[Canário]
    I --> J[Expandir e remover legado]
    J --> A

Uma fatia só está concluída quando:

  • contrato e invariantes estão documentados;
  • adapter atual e alternativo passam pela mesma suíte de conformidade;
  • divergência de shadow read é explicável e monitorada;
  • métricas têm baixa cardinalidade e não contêm PII;
  • existe flag de rollback testada;
  • o SDK do provider não escapou para novos consumidores.

Ordem recomendada

  1. Inventário e fronteira: localizar imports, variáveis, tabelas, RPCs, buckets e rotas Supabase.
  2. Leituras públicas: extrair CatalogReadStore; é a fatia de menor risco para validar o Turso.
  3. Dados no dispositivo: centralizar localStorage e IndexedDB, mantendo o nome físico WebappAI_DB durante a transição.
  4. ObjectStore: separar bytes, metadados e URLs públicas; rascunhos não são enviados automaticamente.
  5. Escritas simples: migrar somente depois de definir idempotência, transações e política de conflito.
  6. Identidade e continuidade: manter como capacidades separadas; trocar um banco não implica trocar Auth ou a fila de contingência.

Schema e migrations

As migrations existentes permanecem em db/supabase/migrations. O nome de uma migration não é um contrato multi-provider. Para cada capacidade extraída, registre também uma versão lógica e as invariantes esperadas.

Um adapter alternativo pode materializar outra representação desde que preserve:

  • identidade e unicidade observáveis;
  • ordenação e paginação determinísticas;
  • nulabilidade e valores padrão relevantes;
  • autorização equivalente;
  • idempotência das escritas;
  • semântica de datas, precisão numérica e exclusão.

Antes de dupla escrita, prefira backfill verificável e shadow read. Dual-write sem outbox, reconciliação e telemetria cria divergência silenciosa.

Como avaliar providers

O catálogo de serviços gratuitos ajuda a descobrir opções, mas não decide a arquitetura. Avalie cada provider por capacidade:

CritérioPergunta
RuntimeFunciona no Cloudflare/edge sem socket TCP persistente?
ModeloRelacional, documento, vetorial ou arquivo?
TransaçõesQuais garantias e limites existem?
Auth/RLSÉ nativo, externo ou responsabilidade da aplicação?
PortabilidadeExportação e restauração são automatizáveis?
QuotasO que ocorre ao atingir limite de armazenamento, requests ou egress?
OperaçãoHá observabilidade, backups, regiões e recuperação adequados?
Lock-inQuanto SQL, SDK ou recurso proprietário chega ao domínio?

MongoDB pode ser apropriado para documentos; SQLite AI para workloads locais e de IA; Turso para SQLite/libSQL distribuído; PostgreSQL gerenciado para manter semântica relacional próxima da atual. Nenhum é substituto universal.

Guardrails

  • Secrets só existem no servidor e em arquivos ignorados pelo Git.
  • O token da API da plataforma Turso não é o token de acesso de um database.
  • localStorage não recebe imagens, base64, filas ou credenciais.
  • IndexedDB não é fonte durável única para pedidos confirmados.
  • Binários não trafegam na fila transacional de continuidade.
  • Fallback nunca pode transformar indisponibilidade em escrita concorrente em dois primários.
  • Free tier é restrição operacional temporária, não contrato de arquitetura.

Próximo marco verificável

Com contracts, provider registry, continuidade e device boundary criados, o próximo marco é extrair uma consulta pública de catálogo para CatalogReadStore, colocar o comportamento Supabase atual atrás do primeiro adapter de negócio e só então criar o adapter Turso de leitura para shadow reads sem afetar a resposta ao usuário. O plano detalhado está em Integração incremental com Turso.

Referências