Na Aula 11 fechamos o CRUD de eventos ponta a ponta: Vue chamando services/, Express validando e persistindo no MySQL, Firebase autenticando. Hoje mudamos de fornecedor: o mesmo recurso evento, agora falando direto com o Supabase — sem API própria no meio. É a mesma pergunta de arquitetura de sempre ("onde mora a lógica?"), respondida de um jeito diferente.
Checklist antes de começar:
[ ] unieventos-web funcionando com o CRUD da Aula 11 (Express+MySQL+Firebase).
[ ] Conta no supabase.com (login com GitHub é o mais rápido).
[ ] Node.js 22.22.2 e npm 10.9.7 instalados.
[ ] Confortável com SQL básico (SELECT, INSERT, CREATE TABLE) — revisado na Aula 09 no contexto do MySQL.
O UniEventos já tem back-end funcionando: Express + MySQL, com autenticação Firebase por cima. Por que aprender mais uma abordagem?
Porque na vida profissional você vai escolher — e a escolha tem trade-offs reais, não é só gosto. Comparação honesta:
Critério
API própria (Express+MySQL)
Firebase
Supabase
O que resolve
Controle total sobre lógica e dados
Auth + Firestore/Storage prontos, sem servidor próprio
Postgres gerenciado + Auth + Storage, sem servidor próprio
Banco de dados
Você escolhe e administra (MySQL aqui)
Firestore (NoSQL, documentos)
Postgres (SQL relacional, o mesmo paradigma do MySQL)
Onde mora a regra de negócio
No seu back-end, você escreve tudo
Cloud Functions (custo extra) ou no front (arriscado)
SQL/policies no banco, ou funções Postgres, ou API própria por cima
Curva de aprendizado
Alta (você monta tudo)
Média (SDK, mas modelo de dados diferente)
Baixa se já sabe SQL
Vendor lock-in
Nenhum — seu código, seu servidor
Alto — Firestore não é portável
Médio — é Postgres puro por baixo, mais fácil de migrar
Quando escolher
Regra de negócio complexa, controle fino, já tem back-end
Protótipo rápido, app mobile-first, tempo real nativo
Precisa de SQL relacional gerenciado, quer Postgres sem administrar servidor
💡 Dica
Não existe "o melhor" fora de contexto. O UniEventos usa MySQL porque a disciplina precisa ensinar SQL relacional e arquitetura em camadas. Se o requisito fosse "app mobile com sincronização offline automática", Firebase seria mais natural. Se o requisito fosse "preciso de Postgres gerenciado sem administrar servidor, com auth pronta", Supabase entra bem. Custo de saída (trocar de fornecedor depois) também pesa: Postgres é um padrão aberto, então uma base Supabase se exporta e migra com muito menos atrito que uma base Firestore.
Os três caminhos têm modelos de cobrança bem diferentes, e vale entender isso antes de escolher, não depois que a fatura chegar:
API própria (Express+MySQL): você paga o servidor (VM, container, PaaS) e o banco, direto, independente de quantas requisições ou quanto tráfego passa. Previsível, mas você também é responsável por escalar, fazer backup e manter tudo no ar.
Firebase: camada gratuita generosa para protótipos, mas cobra por leituras/escritas no Firestore e por armazenamento e tráfego de saída (egress) — em produtos com alto volume de leitura (uma lista que recarrega toda hora, por exemplo), o custo pode crescer rápido e de forma menos previsível.
Supabase: também tem camada gratuita (com o projeto "pausando" após um tempo sem uso no plano free), e cobra por armazenamento de banco, egress e por hora de computação do banco nos planos pagos. Como é Postgres puro por baixo, migrar para um Postgres autogerenciado depois (se o custo justificar) é factível sem reescrever o modelo de dados.
"Custo de saída" (egress) é o valor cobrado por dados que saem do provedor em direção ao seu usuário — toda resposta de select, toda imagem baixada do Storage, conta. É um item fácil de esquecer ao estimar custo de um app com uso intenso de leitura, como uma lista de eventos que recarrega a cada navegação.
📌 Vale gravar
Os três modelos resolvem "onde guardar e servir dados", mas com contratos de responsabilidade diferentes: API própria = você administra tudo, custo previsível, controle total. Firebase = NoSQL gerenciado, ótimo para tempo real e mobile, lock-in alto. Supabase = Postgres gerenciado, SQL relacional, lock-in menor por ser padrão aberto.
Em supabase.com, New project. Escolha organização, nome (unieventos), senha do banco (guarde — é a senha do Postgres, usada em conexões diretas) e região (mais próxima do Brasil, ex. São Paulo/sa-east-1 se disponível).
Aguarde o provisionamento (1–2 minutos).
No painel do projeto, vá em Project Settings → API. Anote:
- Project URL — algo como https://xxxxxxxxxxxx.supabase.co.
- anon / public key — chave longa, começando com eyJ... (é um JWT também). Pode ir no front.
- service_role key — outra chave longa. Nunca vai para o front.
⚠️ Atenção
A chave anon é pública por design — ela vai no bundle JavaScript do seu front, qualquer pessoa que abrir o DevTools consegue vê-la. Isso é esperado e seguro desde que o Row Level Security esteja configurado corretamente (seção 4): a chave anon só consegue fazer o que as policies permitirem. Já a service_roleignora RLS completamente — com ela, qualquer requisição lê e escreve qualquer linha de qualquer tabela, sem checagem nenhuma. Se ela vazar no front, é o mesmo que vazar acesso total ao banco. Use service_role só em ambiente de servidor (scripts administrativos, back-end próprio), nunca em código que roda no navegador.
No menu lateral: SQL Editor (para rodar comandos SQL diretamente, o que faremos agora) e Table Editor (interface visual tipo planilha, útil para inspecionar dados rapidamente — mas hoje vamos criar tudo por SQL, para reforçar o que você já sabe da Aula 09).
-- Tabela de eventos. uuid como PK (padrão do Supabase/Postgres,-- gerado automaticamente, sem depender de auto-incremento sequencial).createtableeventos(iduuidprimarykeydefaultgen_random_uuid(),titulotextnotnull,descricaotextnotnull,categoriatextnotnullcheck(categoriain('palestra','minicurso','workshop')),data_horatimestamptznotnull,localtextnotnull,vagasintegernotnullcheck(vagas>0),imagem_urltext,usuario_iduuidnotnullreferencesauth.users(id),criado_emtimestamptznotnulldefaultnow());-- Tabela de inscrições, referenciando eventos e o usuário autenticado.createtableinscricoes(iduuidprimarykeydefaultgen_random_uuid(),evento_iduuidnotnullreferenceseventos(id)ondeletecascade,usuario_iduuidnotnullreferencesauth.users(id),criado_emtimestamptznotnulldefaultnow(),unique(evento_id,usuario_id)-- um usuário não se inscreve duas vezes no mesmo evento);
🔎 Por baixo do capôtimestamptz (timestamp with time zone) guarda o instante em UTC internamente e converte na leitura/escrita conforme o fuso da sessão — é o tipo certo para datas que cruzam fusos horários, diferente de um timestamp sem fuso, que é ambíguo. auth.users é uma tabela que o próprio Supabase Auth já cria e mantém — é para lá que signUp/signInWithPassword gravam. references auth.users(id) garante, no nível do banco, que todo evento pertence a um usuário real.
Rode o SQL (botão Run ou Ctrl+Enter). Confirme no Table Editor que as duas tabelas apareceram.
Toda tabela criada pelo SQL Editor (o caminho que usamos na §3) nasce sem RLS habilitado — o que na prática significa "qualquer um com a chave anon lê e escreve tudo", porque o Postgres do Supabase é acessado via API REST autogerada por cima do banco. Isso é perigoso, então o primeiro passo depois de criar uma tabela de verdade é:
Rode isso agora e tente buscar eventos do front (ou do próprio SQL Editor simulando a role anon) — o retorno vai ser uma lista vazia, sem nenhum erro:
JSON
{"data":[],"error":null}
⚠️ Atenção — a armadilha nº1 do Supabase
Uma tabela com RLS habilitado e sem nenhuma policy não gera erro de permissão — ela simplesmente se comporta como se estivesse vazia para quem não é dono/service_role. É a causa mais comum de "meu código está certo mas não retorna nada" com Supabase. Sempre que você habilitar RLS numa tabela nova, o próximo passo, sem exceção, é escrever as policies dela.
Row Level Security é um recurso nativo do Postgres: em vez de controlar acesso só por tabela (você pode ou não fazer SELECT em eventos), ele controla acesso linha por linha, com uma condição SQL avaliada para cada linha. O Supabase se apoia nisso porque expõe o banco diretamente via API para o front — sem RLS, qualquer chave anon vazada (e ela É pública) daria acesso irrestrito. RLS é o que torna seguro o front conversar direto com o banco.
🧠 Você sabia?
A "API REST autogerada" do Supabase não é código deles: é o PostgREST, projeto de código aberto criado por Joe Nelson em 2014 que transforma qualquer schema Postgres em uma API HTTP. Cada supabase.from('eventos').select('*').eq('categoria', 'palestra') vira, na prática, um GET /rest/v1/eventos?select=*&categoria=eq.palestra — e é o PostgREST que repassa o seu JWT ao Postgres para que auth.uid() funcione dentro das policies. O RLS, por sua vez, existe no Postgres desde a versão 9.5, lançada em 2016 — bem antes de o Supabase existir.
Policies: leitura pública, inserção autenticada, edição/exclusão só do dono¶
SQL
-- LEITURA: qualquer pessoa (mesmo não autenticada) pode ver eventos.createpolicy"eventos_leitura_publica"oneventosforselectusing(true);-- INSERÇÃO: só usuários autenticados podem criar evento, e o evento-- criado precisa pertencer a quem está criando (não dá para criar-- em nome de outro usuário).createpolicy"eventos_insercao_autenticada"oneventosforinserttoauthenticatedwithcheck(auth.uid()=usuario_id);-- EDIÇÃO: só o dono do evento pode editar.createpolicy"eventos_edicao_dono"oneventosforupdatetoauthenticatedusing(auth.uid()=usuario_id)withcheck(auth.uid()=usuario_id);-- EXCLUSÃO: só o dono pode excluir.createpolicy"eventos_exclusao_dono"oneventosfordeletetoauthenticatedusing(auth.uid()=usuario_id);
SQL
-- Inscrições: leitura pública (para mostrar vagas ocupadas),-- inserção só autenticado e só em nome de si mesmo,-- exclusão só de si mesmo (cancelar a própria inscrição).createpolicy"inscricoes_leitura_publica"oninscricoesforselectusing(true);createpolicy"inscricoes_insercao_propria"oninscricoesforinserttoauthenticatedwithcheck(auth.uid()=usuario_id);createpolicy"inscricoes_exclusao_propria"oninscricoesfordeletetoauthenticatedusing(auth.uid()=usuario_id);
As duas cláusulas parecem sinônimos, mas checam momentos diferentes:
USING filtra quais linhas existentes a operação pode enxergar/afetar. Vale para SELECT, UPDATE e DELETE — é a condição "essa linha, que já está no banco, pode ser vista/alterada/apagada por você?".
WITH CHECK valida os dados da linha depois da operação (ou os dados que vão ser inseridos). Vale para INSERT e UPDATE — é a condição "o resultado desta escrita é permitido?".
Em um UPDATE, as duas coexistem e respondem perguntas diferentes: USING decide se você pode tocar naquela linha específica (ex.: só se usuario_id já era seu); WITH CHECK decide se o novo valor que você está tentando gravar é aceitável (ex.: impedir que você mude usuario_id da linha para outra pessoa, "roubando" o evento).
📌 Vale gravarUSING = filtro sobre a linha que já existe (quem pode ver/mexer). WITH CHECK = validação sobre o dado que está sendo escrito (o resultado é permitido?). INSERT só tem WITH CHECK (não existe linha "antes"). SELECT/DELETE só têm USING. UPDATE tem os dois.
O padrão Adapter (estrutural) permite que duas interfaces incompatíveis trabalhem juntas, criando uma camada intermediária que traduz uma para a outra. É exatamente o que vamos construir na seção 8: duas implementações de eventosRepo — uma fala com a API Express (Aula 11), outra fala direto com o Supabase — mas as duas expõem a mesma interface (listar(), buscarPorId(), criar(), atualizar(), remover()). O resto do front (store, telas) não sabe, e não precisa saber, qual das duas está em uso. Trocar de fornecedor de dados vira uma linha de variável de ambiente, não uma reescrita de tela.
5. @supabase/supabase-js: cliente e operações básicas¶
// consultas de exemplo — cole no console do navegador ou num componente de teste// Selecionar colunas específicasconst{data,error}=awaitsupabase.from('eventos').select('id, titulo, categoria, data_hora, vagas')// Filtros: eq, neq, gt, lt, like, ilike, inconst{data:palestras}=awaitsupabase.from('eventos').select('*').eq('categoria','palestra')const{data:buscaPorTitulo}=awaitsupabase.from('eventos').select('*').ilike('titulo','%semana%')// ilike = LIKE case-insensitiveconst{data:futuros}=awaitsupabase.from('eventos').select('*').gt('data_hora',newDate().toISOString())const{data:algumasCategorias}=awaitsupabase.from('eventos').select('*').in('categoria',['palestra','workshop'])// Ordenaçãoconst{data:ordenados}=awaitsupabase.from('eventos').select('*').order('data_hora',{ascending:true})// Paginação: range(inicio, fim), ambos inclusive, base 0constpagina=1constporPagina=10constinicio=(pagina-1)*porPaginaconstfim=inicio+porPagina-1const{data:pagina1,count}=awaitsupabase.from('eventos').select('*',{count:'exact'})// pede o total de linhas junto.order('data_hora',{ascending:true}).range(inicio,fim)// Buscar um único registro (lança erro se vier mais de uma linha// ou se nenhuma linha for encontrada)const{data:evento,error:erroUnico}=awaitsupabase.from('eventos').select('*').eq('id','algum-uuid-aqui').single()
⚠️ Atençãosingle()estoura em erro se a consulta não retornar exatamente uma linha — nem zero, nem duas ou mais. Se o id pode não existir (ex.: usuário editou a URL na mão), trate o error em vez de assumir que data sempre vem preenchido. Para o caso "pode não existir, e tudo bem", use .maybeSingle() no lugar de .single() — ele devolve data: null sem erro quando não encontra.
🔬 Investigue
Rode a consulta paginada acima (com count: 'exact') com a aba Network aberta e filtre por rest/v1. Abra a requisição: repare na URL (/rest/v1/eventos?select=*&order=data_hora.asc), nos cabeçalhos apikey (a chave anon) e Authorization: Bearer ... (a mesma chave anon quando você está deslogado; o token de sessão do usuário quando está logado), no cabeçalho de requisição Range: 0-9 e no de resposta Content-Range: 0-9/34. Agora peça .range(500, 509) numa tabela com poucas linhas e anote o status e o Content-Range que voltam — eles explicam por que uma página "além do fim" merece tratamento na sua store.
{ data, error }: por que try/catch sozinho não basta¶
O supabase-jsnão lança exceção para a maioria dos erros de banco (violação de policy, coluna inexistente, check constraint falhando). Em vez disso, ele sempre resolve a Promise com sucesso e devolve um objeto { data, error } — se error não for null, a operação falhou, mas nenhuma exceção foi lançada e um try/catch ao redor não pega nada:
JavaScript
// ERRADO — o try/catch aqui nunca vê o erro de RLS/validaçãotry{const{data}=awaitsupabase.from('eventos').insert({titulo:'X'})console.log('Criado:',data)// data pode ser null e o código nem percebe}catch(e){console.error('Nunca chega aqui para erros de policy/validação')}// CORRETO — sempre desestruture e cheque error explicitamenteconst{data,error}=awaitsupabase.from('eventos').insert({titulo:'X'})if(error){console.error('Falha ao criar evento:',error.message)// trate aqui: mostrar mensagem, não seguir o fluxo, etc.}else{console.log('Criado:',data)}
🔎 Por baixo do capô
Isso é uma escolha de design da biblioteca: erros de banco de dados (RLS negou, constraint violada, coluna não existe) são tratados como resultado esperado da operação, não como falha excepcional do programa — parecido com como uma função de parsing pode devolver null em vez de lançar. try/catch continua útil para erros de rede (sem internet, timeout), mas a lógica de negócio do Supabase sempre passa pelo error do objeto retornado. Esqueça isso e você vai debugar "por que meu insert não fez nada" sem nunca ver a mensagem real.
// INSERT — .select() no final devolve a linha criada (senão, data vem null)const{data:novoEvento,error:erroInsert}=awaitsupabase.from('eventos').insert({titulo:'Minicurso de Docker',descricao:'Introdução prática a containers',categoria:'minicurso',data_hora:'2030-12-10T14:00:00-04:00',local:'Laboratório 3',vagas:30,usuario_id:(awaitsupabase.auth.getUser()).data.user.id,}).select().single()// UPDATE — sempre com .eq() para não atualizar a tabela inteiraconst{data:eventoAtualizado,error:erroUpdate}=awaitsupabase.from('eventos').update({vagas:40}).eq('id',novoEvento.id).select().single()// DELETEconst{error:erroDelete}=awaitsupabase.from('eventos').delete().eq('id',novoEvento.id)
⚠️ Atenção
Um update() ou delete()sem .eq(...) (ou outro filtro) tenta afetar a tabela inteira. O RLS te protege de estragos globais (a policy usuario_id = auth.uid() limita às suas próprias linhas), mas mesmo dentro das suas linhas isso é raramente o que você quer. Sempre filtre pelo identificador específico.
O Supabase entende as foreign keys que você declarou e permite buscar dados relacionados dentro do mesmo select, sem escrever JOIN manualmente:
JavaScript
// Buscar eventos já trazendo as inscrições relacionadasconst{data:eventosComInscritos,error}=awaitsupabase.from('eventos').select('*, inscricoes(*)')// eventosComInscritos[0].inscricoes é um array com as inscrições daquele evento// Contagem de relacionados sem trazer todas as linhasconst{data:eventosComContagem}=awaitsupabase.from('eventos').select('*, inscricoes(count)')
// src/services/supabaseAuthService.jsimport{supabase}from'./supabase'exportasyncfunctioncadastrar(email,senha){const{data,error}=awaitsupabase.auth.signUp({email,password:senha})if(error)thrownewError(error.message)returndata.user}exportasyncfunctionentrar(email,senha){const{data,error}=awaitsupabase.auth.signInWithPassword({email,password:senha})if(error)thrownewError(error.message)returndata.user}exportasyncfunctionsair(){const{error}=awaitsupabase.auth.signOut()if(error)thrownewError(error.message)}exportasyncfunctionobterSessaoAtual(){const{data}=awaitsupabase.auth.getSession()returndata.session}// Observa login/logout/renovação de token, igual ao onAuthStateChanged// do Firebase que vimos na Aula 10.exportfunctionobservarAutenticacao(callback){const{data:assinatura}=supabase.auth.onAuthStateChange((_evento,sessao)=>{callback(sessao)})// devolvemos uma função que chama o método NO objeto — passar// `assinatura.subscription.unsubscribe` solto perderia o `this`return()=>assinatura.subscription.unsubscribe()}
A ligação entre Auth e RLS é direta: quando o front faz uma chamada autenticada, o supabase-js anexa automaticamente o token de sessão, e as policies usam auth.uid() para saber quem está pedindo. É o mesmo princípio do middleware autenticar da Aula 10 (ler o token, extrair a identidade) — só que aqui a checagem acontece dentro do banco, não numa camada de middleware que você escreve.
A store de autenticação segue exatamente a mesma forma da Aula 10 — Pinia, estado usuario/carregando/inicializado, Promise resolvida no primeiro evento do observador, guard de rota aguardando essa Promise. Só troca o serviço por baixo: observarAutenticacao do Supabase no lugar de onAuthStateChanged do Firebase.
JavaScript
// src/stores/authStoreSupabase.jsimport{defineStore}from'pinia'import{ref,computed}from'vue'import{obterSessaoAtual,observarAutenticacao}from'@/services/supabaseAuthService'exportconstuseAuthStore=defineStore('auth',()=>{constusuario=ref(null)constcarregando=ref(false)constinicializado=ref(false)letpromessaInicializacao=nullfunctioninicializar(){if(promessaInicializacao)returnpromessaInicializacaopromessaInicializacao=newPromise((resolve)=>{// Primeiro, lê a sessão já persistida (ex.: recarregou a página).obterSessaoAtual().then((sessao)=>{usuario.value=sessao?.user??null})// Depois, mantém o estado sincronizado com login/logout/renovação.observarAutenticacao((sessao)=>{usuario.value=sessao?.user??nullif(!inicializado.value){inicializado.value=trueresolve()}})})returnpromessaInicializacao}constestaLogado=computed(()=>usuario.value!==null)return{usuario,carregando,inicializado,inicializar,estaLogado}})
JavaScript
// src/router/index.js — guard idêntico em espírito ao da Aula 10,// trocando authStore de Firebase pela variante Supabase.router.beforeEach(async(to)=>{constauthStore=useAuthStore()awaitauthStore.inicializar()if(to.meta.requerAuth&&!authStore.estaLogado){return{name:'login',query:{redirect:to.fullPath}}}returntrue})
💡 Dica
Repare que a forma do problema — "aguardar a primeira resolução do observador antes de deixar o guard decidir" — é idêntica entre Firebase e Supabase, mesmo os dois SDKs sendo de fornecedores diferentes. É um sinal de que o problema (evitar redirecionamento indevido no F5) é estrutural do padrão "autenticação assíncrona no cliente", não uma peculiaridade de um SDK específico.
Antes do código, um passo no painel: o Realtime só emite eventos de tabelas incluídas na publicação de replicação lógica, e nenhuma tabela entra nela por padrão. Vá em Database → Replication, abra a publicação supabase_realtime e marque a tabela eventos. Sem isso, o subscribe() conecta, não dá erro nenhum, e simplesmente nada acontece — é o motivo nº 1 de "meu Realtime não funciona".
JavaScript
// trecho de EventosListaView.vue (variante Supabase)import{onMounted,onUnmounted}from'vue'import{supabase}from'@/services/supabase'letcanal=nullonMounted(()=>{canal=supabase.channel('eventos-mudancas').on('postgres_changes',{event:'*',schema:'public',table:'eventos'},(payload)=>{console.log('Mudança recebida:',payload.eventType,payload.new??payload.old)eventosStore.carregar()// recarrega a lista quando algo muda},).subscribe()})onUnmounted(()=>{if(canal)supabase.removeChannel(canal)})
Abra o UniEventos em duas abas lado a lado. Crie um evento em uma; a lista da outra atualiza sozinha, sem F5. É o tipo de momento que mais impressiona quando demonstrado ao vivo — vale ver isso funcionando antes de explicar o código.
🔎 Por baixo do capô
Realtime do Supabase se apoia na replicação lógica do Postgres (logical replication): o banco publica um fluxo de mudanças (postgres_changes), e o supabase-js mantém um WebSocket assinando esse fluxo filtrado pela tabela/evento que você configurou. Não é polling — é o próprio banco avisando o cliente quando algo muda.
💻 Mão na massa — CRUD direto com Supabase e, depois, o Adapter¶
Passo 1 — Testando a conexão no console do navegador¶
Antes de montar telas, confirme que o cliente conecta e que as policies estão certas. Com unieventos-web rodando (npm run dev), abra o DevTools no navegador, importe o cliente e rode uma consulta:
JavaScript
// cole no console do navegador, na página do seu app rodando com Viteconst{supabase}=awaitimport('/src/services/supabase.js')const{data,error}=awaitsupabase.from('eventos').select('*')console.log({data,error})
Se data vier [] e error vier null, e você já cadastrou alguma linha pelo Table Editor, é a armadilha da seção 4: falta a policy de leitura. Se error trouxer uma mensagem sobre coluna ou relação inexistente, revise o SQL de criação da tabela.
Passo 2 — Tela de listagem consumindo o Supabase diretamente¶
Antes de introduzir o Adapter, vale montar a versão mais direta — a store chamando o supabase-js sem nenhuma camada de repositório no meio. É o ponto de partida mais simples, e o que a maioria dos tutoriais mostra.
JavaScript
// src/stores/eventosStoreSupabase.jsimport{defineStore}from'pinia'import{ref}from'vue'import{supabase}from'@/services/supabase'exportconstuseEventosStore=defineStore('eventos',()=>{constlista=ref([])constitemAtual=ref(null)constcarregando=ref(false)consterro=ref(null)constpaginacao=ref({pagina:1,porPagina:10,total:0,totalPaginas:0})asyncfunctioncarregar({pagina=1,porPagina=10}={}){carregando.value=trueerro.value=nullconstinicio=(pagina-1)*porPaginaconstfim=inicio+porPagina-1const{data,error,count}=awaitsupabase.from('eventos').select('*',{count:'exact'}).order('data_hora',{ascending:true}).range(inicio,fim)if(error){erro.value=error.message}else{lista.value=datapaginacao.value={pagina,porPagina,total:count,totalPaginas:Math.ceil(count/porPagina)}}carregando.value=false}asyncfunctioncarregarUm(id){carregando.value=trueerro.value=nullconst{data,error}=awaitsupabase.from('eventos').select('*').eq('id',id).maybeSingle()if(error){erro.value=error.message}elseif(!data){erro.value='Evento não encontrado.'}else{itemAtual.value=data}carregando.value=false}asyncfunctioncriar(evento){carregando.value=trueerro.value=nullconst{data:sessao}=awaitsupabase.auth.getUser()const{data,error}=awaitsupabase.from('eventos').insert({...evento,usuario_id:sessao.user.id}).select().single()carregando.value=falseif(error){erro.value=error.messagethrownewError(error.message)}lista.value=[data,...lista.value]returndata}asyncfunctionatualizar(id,evento){carregando.value=trueerro.value=nullconst{data,error}=awaitsupabase.from('eventos').update(evento).eq('id',id).select().single()carregando.value=falseif(error){erro.value=error.messagethrownewError(error.message)}constindice=lista.value.findIndex((e)=>e.id===id)if(indice!==-1)lista.value[indice]=datareturndata}asyncfunctionremover(id){carregando.value=trueerro.value=nullconst{error}=awaitsupabase.from('eventos').delete().eq('id',id)carregando.value=falseif(error){erro.value=error.messagethrownewError(error.message)}lista.value=lista.value.filter((e)=>e.id!==id)}return{lista,itemAtual,carregando,erro,paginacao,carregar,carregarUm,criar,atualizar,remover}})
⚠️ AtençãoauthStore.usuario?.id === evento.usuario_id no template controla só a exibição do botão — é UX, igual ao guard de rota da Aula 10. A garantia de verdade é a policy eventos_edicao_dono (seção 4): mesmo que alguém forje uma requisição de update para um evento alheio direto contra a API do Supabase, o banco recusa porque auth.uid() não bate com usuario_id.
Com o CRUD direto funcionando, damos o passo seguinte: extrair uma interface comum que permita alternar entre a API Express (Aula 11) e o Supabase sem tocar em store nem em tela.
Repare numa diferença que a tela do Passo 2 deixou passar de propósito: falando direto com o Supabase, o front recebe as colunas cruas do Postgres (evento.data_hora, evento.imagem_url) e o template se adaptou a elas. Do lado da API Express, o mesmo evento chega em camelCase (dataHora, imagemUrl), porque o repositório do back-end traduz. Duas implementações da "mesma" interface devolvendo nomes de campo diferentes não é um Adapter — é um vazamento. No Passo 4, a conversão passa a ser responsabilidade explícita do adaptador Supabase, e a tela volta a falar um vocabulário só.
Passo 3 — Interface comum e implementação para a API Express¶
Passo 4 — Mesma interface, implementação Supabase¶
JavaScript
// src/repositories/eventosRepoSupabase.jsimport{supabase}from'@/services/supabase'// mesma tradução snake_case → camelCase que o repositório MySQL faz na Aula 11functionlinhaParaEvento(linha){if(!linha)returnnullreturn{id:linha.id,titulo:linha.titulo,descricao:linha.descricao,categoria:linha.categoria,dataHora:linha.data_hora,local:linha.local,vagas:linha.vagas,imagemUrl:linha.imagem_url,usuarioId:linha.usuario_id,}}// e o caminho inverso, para insert/updatefunctioneventoParaLinha(evento){return{titulo:evento.titulo,descricao:evento.descricao,categoria:evento.categoria,data_hora:evento.dataHora,local:evento.local,vagas:evento.vagas,imagem_url:evento.imagemUrl||null,}}exportconsteventosRepoSupabase={asynclistar({pagina=1,porPagina=10}={}){constinicio=(pagina-1)*porPaginaconstfim=inicio+porPagina-1const{data,error,count}=awaitsupabase.from('eventos').select('*',{count:'exact'}).order('data_hora',{ascending:true}).range(inicio,fim)if(error)thrownewError(error.message)// Formato devolvido igual ao da API Express — é isso que faz o// Adapter funcionar: a FORMA da resposta precisa ser a mesma.// Inclusive o NOME DOS CAMPOS: as colunas do Postgres são snake_case// (data_hora, imagem_url), mas o contrato do front é camelCase desde a// Aula 06. Do lado Express quem traduz é o repositório do back-end; aqui,// como não existe back-end nosso no meio, a tradução é obrigação do Adapter.return{dados:data.map(linhaParaEvento),paginacao:{pagina,porPagina,total:count,totalPaginas:Math.ceil(count/porPagina)},}},asyncbuscarPorId(id){const{data,error}=awaitsupabase.from('eventos').select('*').eq('id',id).single()if(error)thrownewError(error.message)returnlinhaParaEvento(data)},asynccriar(evento){const{data:sessao}=awaitsupabase.auth.getUser()const{data,error}=awaitsupabase.from('eventos').insert({...eventoParaLinha(evento),usuario_id:sessao.user.id}).select().single()if(error)thrownewError(error.message)returnlinhaParaEvento(data)},asyncatualizar(id,evento){const{data,error}=awaitsupabase.from('eventos').update(eventoParaLinha(evento)).eq('id',id).select().single()if(error)thrownewError(error.message)returnlinhaParaEvento(data)},asyncremover(id){const{error}=awaitsupabase.from('eventos').delete().eq('id',id)if(error)thrownewError(error.message)},}
Passo 5 — Trocando a implementação por variável de ambiente¶
JavaScript
// src/repositories/eventosRepo.jsimport{eventosRepoExpress}from'./eventosRepoExpress'import{eventosRepoSupabase}from'./eventosRepoSupabase'// VITE_BACKEND=express ou VITE_BACKEND=supabase no .envconstbackendEscolhido=import.meta.env.VITE_BACKEND??'express'exportconsteventosRepo=backendEscolhido==='supabase'?eventosRepoSupabase:eventosRepoExpress
JavaScript
// src/services/eventosService.js — reescrito para usar o Adapterimport{eventosRepo}from'@/repositories/eventosRepo'exportfunctionlistarEventos(params){returneventosRepo.listar(params)}exportfunctionbuscarEvento(id){returneventosRepo.buscarPorId(id)}exportfunctioncriarEvento(evento){returneventosRepo.criar(evento)}exportfunctionatualizarEvento(id,evento){returneventosRepo.atualizar(id,evento)}exportfunctionremoverEvento(id){returneventosRepo.remover(id)}
Terminal
# .env — uma linha decide qual back-end o front usaVITE_BACKEND=supabase
Nenhuma linha da store (eventosStore.js) ou das telas (EventosListaView.vue, EventoFormView.vue) precisa mudar. Isso é o Adapter cumprindo sua função: a store continua chamando eventosService.listarEventos(...), que continua chamando eventosRepo.listar(...) — só a implementação por trás mudou, escolhida por uma variável de ambiente.
📌 Vale gravar
Facade (Aula 11) simplifica uma interface complexa. Adapter (esta aula) traduz uma interface para outra, permitindo trocar a implementação sem o cliente perceber. A camada services/ do UniEventos usa os dois: é Facade em relação às telas (esconde detalhes de HTTP/Supabase) e se apoia num Adapter (eventosRepo) para trocar de fornecedor por baixo.
O teste do Adapter é o mesmo roteiro executado duas vezes, com uma linha de .env de diferença. Faça assim:
Com VITE_BACKEND=express no .env (e a unieventos-api + MySQL rodando), abra o UniEventos: liste, crie, edite e exclua um evento. Anote o que aparece na aba Network: requisições para http://localhost:3000/api/eventos.
Pare o npm run dev, troque para VITE_BACKEND=supabase, suba de novo e repita exatamente os mesmos quatro passos. Agora as requisições saem para https://<seu-projeto>.supabase.co/rest/v1/eventos.
Resultado esperado: as duas rodadas se comportam igual na tela — mesma lista, mesmo formulário preenchido na edição, mesma data formatada (sinal de que a tradução data_hora → dataHora do Passo 4 está funcionando), mesmo comportamento do botão de excluir. Nenhum arquivo dentro de stores/ ou views/ foi tocado entre uma rodada e outra: confirme com git status.
Dois testes negativos fecham a verificação:
RLS de verdade — deslogado, tente criar um evento pelo console do navegador: await supabase.from('eventos').insert({ titulo: 'teste' }). Resultado esperado: error de violação de policy, data: null — a tela nem precisa impedir, o banco impede.
Realtime — com a replicação habilitada, abra o UniEventos em duas abas e crie um evento numa delas. Resultado esperado: a lista da outra aba se atualiza sozinha, sem F5.
A1. A tabela eventos está com RLS habilitado e tem só a policy eventos_leitura_publica. Um usuário autenticado roda supabase.from('eventos').insert({ ... }).select(). Preveja o que vem em { data, error } — e compare com o que vem num select quando não existe policy nenhuma. Por que os dois casos se comportam de forma diferente?
Resultado esperado: o insert devolve data: null e um error de violação de policy (código 42501), porque não existe policy de insert; o select sem policy nenhuma devolve data: [] e error: null — silêncio, não erro. RLS nega por padrão, e negar uma leitura é simplesmente não devolver linhas.
A2. Complete as lacunas da policy que permite a um usuário alterar apenas as próprias inscrições, sem poder transferi-las para outra pessoa. Depois diga qual das duas cláusulas impede o "roubo" de uma inscrição.
Resultado esperado: using (auth.uid() = usuario_id) e with check (auth.uid() = usuario_id). É o with check que impede o roubo: ele valida a linha depois da alteração, barrando um update que tente trocar o usuario_id para outra pessoa.
A3. Verdadeiro ou falso, com justificativa: "Um try/catch ao redor de await supabase.from('eventos').delete().eq('id', id) captura a violação de policy quando o usuário tenta apagar um evento alheio."
Resultado esperado: falso — o supabase-js não lança exceção nesses casos; ele resolve a Promise com { data, error }. Um delete que não casa com a policy nem chega a ser erro: afeta zero linhas e volta error: null. Só olhando o retorno (e o count) é que você descobre o que aconteceu.
A4. Uma tabela tem 15 eventos. Preveja data.length e count para .select('*', { count: 'exact' }).range(10, 19). E para .range(0, 4)? Que combinação de pagina/porPagina da store produz cada chamada?
Resultado esperado: .range(10, 19) devolve data.length === 5 (só existem 15 linhas) e count === 15 — count é sempre o total da consulta, não o da fatia; corresponde a pagina: 2, porPagina: 10. .range(0, 4) devolve data.length === 5 e count === 15, correspondendo a pagina: 1, porPagina: 5.
A5. Em duas linhas: a chave anon vai para o bundle público do front e isso é seguro por design; a service_role não pode ir. O que exatamente cada uma "respeita" ou "ignora" dentro do Postgres?
Resultado esperado: a anon entra como o papel anon/authenticated e respeita todas as policies de RLS — por isso pode ser pública. A service_roleignora o RLS por completo (é BYPASSRLS): com ela, qualquer pessoa lê e escreve qualquer linha de qualquer tabela. Ela só existe para código de servidor.
A6.carregarUm(id) da store usa .maybeSingle(); buscarPorId do eventosRepoSupabase usa .single(). Para um id inexistente, preveja o { data, error } de cada um e diga qual dos dois comportamentos o eventosRepoExpress (que devolve 404) espelha melhor.
Resultado esperado: .maybeSingle() devolve data: null, error: null; .single() devolve data: null e um error (PGRST116 — 0 linhas onde se esperava exatamente 1). O .single() espelha melhor o Express, porque também transforma "não encontrei" em erro, que é o que a store precisa para exibir "Evento não encontrado".
B1. Projeto e tabelas. Crie seu projeto no Supabase e as tabelas da sua entidade principal (autoral), com uuid como PK, timestamptz para datas e RLS habilitado desde o início.
Resultado esperado: as tabelas aparecem no Table Editor e um select feito do front devolve data: [] sem erro — o sinal de que o RLS está ligado e ainda não há policy.
Dica
Habilite RLS na mesma migração/script SQL em que cria a tabela — não deixe para depois, é fácil esquecer.
B2. Policies completas. Escreva as quatro policies (leitura pública, inserção autenticada, edição e exclusão só do dono) para sua tabela principal.
Resultado esperado: deslogado, select funciona e insert devolve error de policy; logado como A, editar uma linha de B afeta zero linhas.
Dica
Teste cada uma isoladamente: logado como usuário A, tente editar uma linha do usuário B — deve falhar silenciosamente (nenhuma linha afetada), não com erro.
B3. CRUD com supabase-js. Implemente select, insert, update, delete da sua entidade, sempre desestruturando { data, error } e tratando o erro.
Resultado esperado: cada operação passa por um if (error); um insert que viola uma check constraint mostra a mensagem real do Postgres na tela, não um null silencioso.
Dica
Se data vier vazio sem erro nenhum, sua primeira suspeita deve ser RLS sem policy — releia a seção 4 antes de procurar bug no seu código.
B4. Realtime funcionando. Assine mudanças na sua tabela principal e demonstre, em duas abas, uma lista atualizando sozinha.
Resultado esperado: criar um registro numa aba faz a lista da outra atualizar em menos de um segundo, sem F5, e sair da tela remove o canal (removeChannel).
Dica
Confirme que o Realtime está habilitado para a tabela em Database → Replication no painel do Supabase — em alguns planos/tabelas ele vem desligado por padrão.
C1. Adapter comparativo. Implemente as duas versões do repositório (Repo...Express e Repo...Supabase) para sua entidade principal, com a mesma interface, e alterne entre elas por variável de ambiente. Atenção a dois detalhes que costumam quebrar. (1) Os id são INT no MySQL e uuid no Supabase — a store e as rotas precisam funcionar com os dois. (2) A tabela do Supabase tem uma coluna de dono (usuario_id, exigida pelas policies de RLS) que a tabela MySQL das Aulas 09/11 não tem: decida se o Adapter devolve esse campo só num dos lados (e a tela lida com undefined) ou se você acrescenta criado_por também no MySQL — e escreva a decisão em uma linha no README.
Resultado esperado: trocar VITE_BACKEND no .env e reiniciar o npm run dev mantém a tela funcionando sem tocar em nenhuma linha de store ou view, inclusive o formulário de edição.
Dica
O ponto de verificação: você deve conseguir trocar VITE_BACKEND no .env, reiniciar o npm run dev, e a tela continuar funcionando sem tocar em nenhuma linha de store ou view. Se a edição quebrar só num dos back-ends, procure um Number(id) que não deveria estar na store.
Um colega criou a tabela de comentários de eventos com o SQL abaixo e reclamou de dois "bugs do Supabase": visitantes deslogados não veem comentário nenhum (a página fica vazia, sem erro), e um usuário conseguiu "roubar" o comentário de outro. Os dois problemas estão nas policies — o Supabase está fazendo exatamente o que foi mandado.
Você reproduz os dois problemas antes de corrigir: um select deslogado devolvendo data: [] e um update({ usuario_id: '<uuid de outro usuário>' }) bem-sucedido feito pelo dono original.
As policies corrigidas: leitura pública de verdade e edição que não permite trocar o dono.
Um teste manual documentado em docs/policies-comentarios.md: quatro chamadas (select deslogado, insert deslogado, update do próprio texto, update tentando mudar usuario_id) com o { data, error } observado em cada uma.
A policy de delete para o dono, que estava faltando.
Pistas
Releia "USING × WITH CHECK" na seção 4: uma das policies só tem metade do que precisa.
to authenticated exclui explicitamente o papel anon — e qual é o papel de quem nunca fez login?
Para testar como outro usuário sem trocar de conta, procure "Testing policies" na documentação de RLS do Supabase: a ideia é trocar o papel (set role authenticated) e injetar o sub do JWT antes da consulta no SQL Editor.
Rode npm run build e procure eyJ dentro de dist/assets/*.js: a sua chave anon está lá, legível para qualquer pessoa que abrir o site. Isso é esperado — mas o que exatamente alguém consegue fazer com ela e um terminal? Descubra usando só curl contra a API REST do seu projeto, sem supabase-js, e mostre que a única coisa entre a chave pública e os seus dados são as policies.
Critérios de pronto
Um script docs/anon-vs-rls.sh com pelo menos quatro chamadas curl a https://<projeto>.supabase.co/rest/v1/eventos: GET sem token, POST sem token, POST com o token de sessão de um usuário logado e DELETE de um evento de outro usuário com esse mesmo token.
Cada chamada tem, em comentário, o status HTTP e o corpo observados — e a policy (ou a falta dela) que explica o resultado.
O token de sessão é obtido pela própria API de auth (/auth/v1/token?grant_type=password), não copiado do DevTools.
Um parágrafo final responde: se você desabilitasse o RLS de eventos por um minuto, o que o segundo curl passaria a fazer?
Pistas
A API REST espera dois cabeçalhos: apikey: <anon> e Authorization: Bearer <anon ou token de sessão>. Sem Authorization, a resposta já é reveladora.
Para o login por curl: POST /auth/v1/token?grant_type=password com Content-Type: application/json e { "email": "...", "password": "..." } — o access_token vem na resposta.
Um DELETE barrado por policy não devolve erro: devolve 204 e não apaga nada. Confira com um GET em seguida — ou peça Prefer: return=representation para ver o que foi afetado.
Nunca cole a service_role nesse script — o objetivo é provar o que a chave pública consegue.
⭐⭐
⭐⭐ Vagas ao vivo: inscrições com join, contagem e Realtime¶
supabasecrudvuebanco-de-dados
O card de evento mostra vagas, mas não quantas já foram ocupadas — e o botão "Inscrever-se" nem existe na versão Supabase. Construa o fluxo completo usando só o que a aula ensinou: contagem de relacionados no select, insert/delete em inscricoes respeitando as policies, e Realtime para que "12/40 vagas" mude na tela de todo mundo quando alguém se inscreve.
Critérios de pronto
O card mostra ocupadas/vagas vindo de um único select com inscricoes(count) — sem uma segunda consulta por evento.
"Inscrever-se" vira "Cancelar inscrição" quando o usuário logado já tem inscrição naquele evento; deslogado, o botão leva ao login.
Tentar se inscrever duas vezes mostra a mensagem da constraint unique (evento_id, usuario_id) traduzida para o usuário, não o texto cru do Postgres.
Com duas abas abertas, inscrever-se em uma faz o contador da outra mudar sem F5, assinando a tabela inscricoes (não eventos).
Um comentário no código explica por que o contador pode, por alguns instantes, estar errado numa aba que perdeu a conexão WebSocket.
Pistas
select('*, inscricoes(count)') devolve inscricoes: [{ count: 12 }] — um array com um objeto, não um número.
Para saber se "eu" estou inscrito sem uma consulta por card, busque as inscrições do usuário logado uma vez (.eq('usuario_id', id)) e guarde os evento_id num Set na store.
O código de violação de unique no Postgres é 23505; error.code chega intacto no supabase-js.
O Realtime precisa estar habilitado para inscricoes em Database → Replication; o .on('postgres_changes', { table: 'inscricoes' }, ...) pode simplesmente recarregar a lista.
⭐⭐⭐
⭐⭐⭐ A última vaga, sem condição de corrida — dentro do banco¶
supabasebanco-de-dadossegurancaapi
Na Aula 11, a última vaga foi protegida com uma transação e FOR UPDATE dentro do Express. Com Supabase não existe "seu servidor" para colocar essa lógica — e um select de contagem seguido de insert no front deixa a porta aberta: vinte pessoas clicando ao mesmo tempo num evento com cinco vagas podem gerar vinte inscrições. Resolva onde o Supabase espera que você resolva: numa função Postgres chamada por supabase.rpc(), que confere e insere de forma atômica.
Critérios de pronto
Existe a função inscrever(p_evento_id uuid) no schema public, que lê o evento com trava de linha, conta as inscrições, recusa com uma exceção clara quando não há vaga e insere a inscrição para auth.uid() — tudo na mesma transação.
O front chama supabase.rpc('inscrever', { p_evento_id }) e mostra "Evento lotado" quando error.message indicar isso.
Um script no console dispara 20 chamadas simultâneas com Promise.all (com 20 usuários de teste, ou temporariamente sem a unique) contra um evento de 5 vagas; o count final é 5.
Um ADR curto (docs/adr/000X-inscricao-por-rpc.md, no formato que a Aula 14 apresenta) registra por que a regra foi para o banco e o que se perde com isso (testes unitários em JS, portabilidade).
A função não pode ser usada para inscrever outra pessoa: usuario_id vem de auth.uid(), nunca de parâmetro.
Pistas
Leia "Database Functions" e a parte sobre security definer na documentação de RLS do Supabase; decida se a função roda como security invoker (respeita RLS) ou security definer (ignora RLS e, por isso, precisa checar auth.uid() sozinha).
O esqueleto é create or replace function inscrever(p_evento_id uuid) returns uuid language plpgsql as $$ declare ... begin ... end $$; — dentro, select vagas into v_vagas from eventos where id = p_evento_id for update; é a trava.
raise exception 'SEM_VAGAS' aborta a transação inteira; o texto chega em error.message no front.
Para o teste de concorrência, Promise.all(Array.from({ length: 20 }, () => supabase.rpc('inscrever', { p_evento_id }))) no console — e conte com select count(*) from inscricoes where evento_id = '...' no SQL Editor.
Recrie o CRUD da sua entidade principal usando Supabase (se ainda não completou no laboratório) e escreva uma análise comparativa de 1 página entre a abordagem Express+MySQL (Aula 11) e a abordagem Supabase (hoje), cobrindo: quantidade de código escrito em cada uma, onde ficou a validação e a regra de negócio em cada caso, o que foi mais rápido de implementar, o que você confiaria menos sem testes automatizados, e qual você escolheria para o seu projeto autoral final — com justificativa. Guarde este texto: é exatamente o tipo de comparação técnica que volta na retrospectiva de padrões da Aula 15.
Critério de pronto: CRUD Supabase funcionando (RLS + policies + operações básicas) e o texto comparativo entregue, com pelo menos os cinco pontos acima abordados.
Plano de curso, Unidade 3: banco de dados em nuvem e padrões estruturais.
A Aula 13 muda de foco: em vez de mais um fornecedor, vamos refatorar e consolidar o back-end do UniEventos — revisando a arquitetura em camadas, aplicando injeção de dependência e organizando tudo o que construímos nas Aulas 07 a 12 num back-end coeso e defensável.