🤔 Convex vs Supabase vs Postgres puro
A escolha do banco no dia zero define o quanto você vai sofrer nos próximos 6 meses. Não tem "melhor" — tem certo pro perfil. Pra o InboxAI da Trilha 4, vamos com Convex; mas você precisa saber comparar pra defender a escolha quando o cliente perguntar.
| Critério | Convex | Supabase | Postgres puro |
|---|---|---|---|
| Curva de setup | 10 min | 15 min | 2h |
| Reatividade nativa | ✓ built-in | realtime extra | ✗ |
| SQL puro | ✗ TS | ✓ Postgres | ✓ total |
| RLS / segurança | code-level | SQL policy | você implementa |
| Free tier real | generoso | generoso | depende host |
| Lock-in | alto | médio | baixo |
| Ecossistema | novo | amplo | universal |
📌 Regra de bolso
Convex pra MVP em 1 fim de semana e produto reativo (chat ao vivo, dashboard). Supabase pra quando seu cliente é técnico e exige Postgres com RLS clássico. Postgres puro só quando você tem time DevOps ou previsão de escala extrema. Pro InboxAI, Convex destrava velocidade — e a Trilha 6 ensina migrar se um dia precisar.
📋 Schema do InboxAI
O schema é a coluna do produto. Toda tabela aqui tem motivo operacional: cada campo justificado por uma decisão real da clínica, imobiliária ou agência que vai pagar.
🗺️ Diagrama de Entidades
📄 convex/schema.ts
TypeScriptimport { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
organizations: defineTable({
name: v.string(),
plan: v.union(v.literal("free"), v.literal("pro"), v.literal("enterprise")),
createdAt: v.number(),
}),
users: defineTable({
orgId: v.id("organizations"),
clerkId: v.string(),
email: v.string(),
name: v.string(),
role: v.union(v.literal("owner"), v.literal("admin"), v.literal("agent")),
}).index("by_org", ["orgId"]).index("by_clerk", ["clerkId"]),
contacts: defineTable({
orgId: v.id("organizations"),
phone: v.string(),
name: v.optional(v.string()),
email: v.optional(v.string()),
tags: v.array(v.string()),
lastSeenAt: v.number(),
}).index("by_org_phone", ["orgId", "phone"])
.index("by_org_lastSeen", ["orgId", "lastSeenAt"]),
conversations: defineTable({
orgId: v.id("organizations"),
contactId: v.id("contacts"),
status: v.union(v.literal("open"), v.literal("snoozed"), v.literal("closed")),
assignedTo: v.optional(v.id("users")),
aiEnabled: v.boolean(),
lastMessageAt: v.number(),
}).index("by_org_status", ["orgId", "status"])
.index("by_contact", ["contactId"]),
messages: defineTable({
orgId: v.id("organizations"),
conversationId: v.id("conversations"),
direction: v.union(v.literal("inbound"), v.literal("outbound")),
sender: v.union(v.literal("contact"), v.literal("agent"), v.literal("ai")),
body: v.string(),
mediaUrl: v.optional(v.string()),
mediaType: v.optional(v.string()),
sentAt: v.number(),
}).index("by_conversation", ["conversationId", "sentAt"]),
deals: defineTable({
orgId: v.id("organizations"),
contactId: v.id("contacts"),
title: v.string(),
stage: v.union(v.literal("qualified"), v.literal("proposal"),
v.literal("won"), v.literal("lost")),
value: v.number(),
ownerId: v.id("users"),
}).index("by_org_stage", ["orgId", "stage"])
.index("by_owner", ["ownerId"]),
});
💡 A regra do orgId
Toda tabela tem orgId. Sempre. É a base do multi-tenant da Trilha 4.4 — sem isso, RLS é placebo. Acostume agora: cliente A nunca consegue, sob nenhuma circunstância, ver dado do cliente B.
🔗 Relacionamentos e Índices
90% dos travamentos em SaaS são query sem índice rodando full table scan. Aqui você vê quais colunas indexar no InboxAI e por que cada uma.
contact → messages
Um contato tem várias mensagens. Index by_conversation permite carregar últimas 50 em ms.
contacts ↔ tags
Tags como array no contact resolve N:N sem tabela pivô — Convex lida nativo. Em Postgres, vire join table.
📊 Índices que importam no InboxAI
⚠️ Sintoma de índice faltando
Se uma tela demorar mais de 500ms com banco quase vazio, é índice. Se piora linearmente conforme entra dado, é índice errado. Adicione índice ao perceber, não em "otimização futura" — Convex faz hot-reload do schema sem downtime.
🚧 Migrations sem quebrar dado
Cliente pagando R$ 2k/mês não aceita 10 minutos fora. Padrão expand-then-contract deixa a migração invisível: usuário nem percebe.
🔄 Fluxo Expand-then-Contract
EXPAND — adiciona o novo
Cria a coluna nova (ex: stageV2). Schema aceita as duas. Código antigo ignora; código novo já lê e escreve nas duas.
BACKFILL — popula histórico
Mutation em batches que copia stage → stageV2. Roda em background, não bloqueia ninguém.
SWITCH — leitura passa pra nova
Deploy onde a UI lê só de stageV2. Escrita ainda dupla. Se der ruim, rollback no painel da Vercel = volta tudo.
CONTRACT — remove o velho
Depois de 7 dias estável, remove stage do schema. Última migration limpa o legado.
💡 Como pedir ao agente
"Quero renomear stage para pipelineStage em deals seguindo expand-then-contract. Crie 4 PRs separadas, cada uma com um passo, e me explique o que verificar antes de mergear cada uma." O Codex executa isso direitinho — você só revisa o diff.
🌱 Seed: dados de teste
Demo pra cliente com banco vazio é tiro no pé. Seed bom faz a UI parecer "cheia de gente usando" no primeiro screenshot. Aqui está o script que popula 50 contatos, 200 mensagens e 10 deals em segundos.
📄 convex/seed.ts
TypeScriptimport { internalMutation } from "./_generated/server";
const NOMES_BR = ["Ana Silva","João Santos","Maria Oliveira","Carlos Pereira",
"Beatriz Costa","Rafael Souza","Juliana Lima","Pedro Rocha"];
const TAGS = ["lead-quente","cliente","follow-up","perdido","vip"];
const FRASES = [
"Oi, tenho interesse no plano Pro.",
"Bom dia, qual o valor?",
"Você consegue passar a apresentação?",
"Vou falar com meu sócio e retorno.",
"Fechado, manda o boleto."
];
const rand = <T>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
const phone = () => "55119" + Math.floor(10000000 + Math.random() * 89999999);
export const seed = internalMutation({
args: {},
handler: async (ctx) => {
const orgId = await ctx.db.insert("organizations", {
name: "Clínica Demo", plan: "pro", createdAt: Date.now(),
});
const ownerId = await ctx.db.insert("users", {
orgId, clerkId: "demo_owner", email: "demo@inbox.ai",
name: "Demo Owner", role: "owner",
});
const contactIds = [];
for (let i = 0; i < 50; i++) {
const id = await ctx.db.insert("contacts", {
orgId, phone: phone(), name: rand(NOMES_BR),
tags: [rand(TAGS)], lastSeenAt: Date.now() - i * 3600_000,
});
contactIds.push(id);
}
for (const contactId of contactIds.slice(0, 40)) {
const convId = await ctx.db.insert("conversations", {
orgId, contactId, status: "open", aiEnabled: true,
lastMessageAt: Date.now(),
});
for (let j = 0; j < 5; j++) {
await ctx.db.insert("messages", {
orgId, conversationId: convId,
direction: j % 2 === 0 ? "inbound" : "outbound",
sender: j % 2 === 0 ? "contact" : "ai",
body: rand(FRASES), sentAt: Date.now() - (5 - j) * 60_000,
});
}
}
for (let i = 0; i < 10; i++) {
await ctx.db.insert("deals", {
orgId, contactId: rand(contactIds), title: "Deal #" + (i + 1),
stage: rand(["qualified","proposal","won","lost"] as const),
value: 1000 + Math.random() * 9000, ownerId,
});
}
return { contacts: 50, conversations: 40, deals: 10 };
},
});
🚀 Rodar o seed
# dev local npx convex run seed:seed # limpar e re-seedar npx convex run reset:wipe && npx convex run seed:seed
⚠️ LGPD no seed
Não use número de telefone real, mesmo de "amigo seu". Use prefixo 5511 9 + dígitos aleatórios. Se cair em produção, ninguém recebe SMS. Mesma lógica pra email — sempre demo+id@inbox.test.
💾 Backups e ambiente prod
Perder dado de cliente é fim de relacionamento e processo no horizonte. Backup só vale se você já testou restore — senão é placebo digital.
✓ Setup mínimo
- ✓Convex Cloud: backup automático a cada 24h, retenção 30 dias
- ✓Snapshot manual antes de cada deploy crítico
- ✓Export semanal pra storage externo (S3 ou R2)
- ✓Drill de restore a cada 30 dias em ambiente staging
- ✓Documento curto explicando como restaurar (passo a passo)
✗ Erros que destroem cliente
- ✗"Tem backup automático" sem testar restore
- ✗Backup no mesmo provedor que pode cair junto
- ✗Sem snapshot pré-deploy de migration arriscada
- ✗Senha do backup junto com a do banco — ransomware leva os dois
- ✗Confiar que "rollback do deploy" desfaz dado
📋 Checklist antes de cada deploy de migration
💡 RTO e RPO traduzidos
RTO (Recovery Time Objective): em quanto tempo você volta ao ar — meta sã pra micro-SaaS BR é 4h. RPO (Recovery Point Objective): quanto dado pode perder no pior cenário — meta sã é 1h. Vender SLA mais agressivo sem infra adequada é cilada que cobra caro depois.
✅ O que Aprendemos
Próximo Módulo:
4.2 — Integrando Evolution API: Docker, QR code, webhook recebendo mensagem real do WhatsApp e função de envio com texto, áudio e imagem.