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/dbjá 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 emsrc/lib/db/providers/supabase;src/lib/supabaseé a fachada temporária.GET /api/db/capabilitiesoferece 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
| Documento | Pergunta respondida |
|---|---|
| Dados no dispositivo | O que fica em localStorage, IndexedDB ou, futuramente, OPFS? |
| Integração incremental com Turso | Como adicionar o primeiro banco remoto alternativo com baixo risco? |
| Continuidade local-primary | Como operar Postgres local com Supabase como Auth, snapshots e fila de continuidade? |
| Mídia local-first | Como impedir que rascunhos de imagem consumam tráfego remoto? |
| Perfil Supabase local-first | Qual é a visão executiva do perfil híbrido local + Supabase? |
| Storage e egress | Como separar binários do banco e migrar um ObjectStore? |
| Chave de serviço do Supabase | Como 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.
| Capacidade | Contrato da aplicação | Implementação atual ou candidata |
|---|---|---|
| Dados transacionais canônicos | CanonicalStore | Supabase/Postgres hoje; Postgres local no perfil local-primary |
| Identidade e sessão | IdentityProvider | Supabase Auth |
| Consultas e projeções | ProjectionStore | Supabase hoje; Turso como primeiro experimento |
| Continuidade durante falha | ContinuityTransport | snapshots e comandos no Supabase |
| Arquivos duráveis | ObjectStore | Supabase Storage; R2/RustFS planejados |
| Preferências pequenas do navegador | DevicePreferenceStore | localStorage |
| Dados estruturados e blobs locais | DeviceDataStore | IndexedDB (WebappAI_DB) |
| Arquivos locais grandes | DeviceFileStore | OPFS, 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:
| Perfil | Escrita canônica | Leitura | Auth | Continuidade | Uso |
|---|---|---|---|---|---|
supabase-full | Supabase Postgres | Supabase | Supabase | não ativa | estado predominante atual |
local-primary | Postgres local | local + snapshots | Supabase | Supabase | alvo para operação no homelab |
turso-projection | permanece no provider atual | Turso para uma projeção | Supabase | inalterada | experimento incremental |
managed-postgres | PostgreSQL gerenciado | mesmo Postgres/projeções | configurável | configurável | portabilidade 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
- Inventário e fronteira: localizar imports, variáveis, tabelas, RPCs, buckets e rotas Supabase.
- Leituras públicas: extrair
CatalogReadStore; é a fatia de menor risco para validar o Turso. - Dados no dispositivo: centralizar
localStoragee IndexedDB, mantendo o nome físicoWebappAI_DBdurante a transição. - ObjectStore: separar bytes, metadados e URLs públicas; rascunhos não são enviados automaticamente.
- Escritas simples: migrar somente depois de definir idempotência, transações e política de conflito.
- 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ério | Pergunta |
|---|---|
| Runtime | Funciona no Cloudflare/edge sem socket TCP persistente? |
| Modelo | Relacional, documento, vetorial ou arquivo? |
| Transações | Quais garantias e limites existem? |
| Auth/RLS | É nativo, externo ou responsabilidade da aplicação? |
| Portabilidade | Exportação e restauração são automatizáveis? |
| Quotas | O que ocorre ao atingir limite de armazenamento, requests ou egress? |
| Operação | Há observabilidade, backups, regiões e recuperação adequados? |
| Lock-in | Quanto 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.
localStoragenã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.