Migração para Cache Components
Plano de adoção do Cache Components (Next 16) para cache de leituras de banco e invalidação correta.
Plano de migração do modelo de cache atual (sem cacheComponents) para o Cache Components do Next 16, para que as leituras feitas pela camada de service fiquem cacheadas com tempo de vida explícito e as mutações invalidem exatamente o que mudou.
Decisões confirmadas:
- Migração conduzida manualmente, fase por fase, sem instalar o skill oficial da Vercel (
next-cache-components-adoption). - Para tudo que envolve permissões/membros/papéis/planos/empresa ativa: sempre
updateTag(invalidação imediata, read-your-own-writes), nuncarevalidateTag(stale-while-revalidate) — correção acima de performance de cache.
Inventário (levantado antes de iniciar)
- Nenhuma página/layout chama Prisma diretamente; tudo passa por
lib/services/*.service.ts(empresaService,roleService,planoService,userService,agendamentoService). Cliente Prisma:lib/db.ts, exportado comodb. - Único uso de
cookies()/headers()em caminho de renderização:getAccessContext()emlib/access-control/context.ts(lê cookieempresa_ativa_id, fazdb.user.findUniquecom memberships, e chamaplanoService.buscarMatriz, hojeReact.cache()). getAccessContext()/auth()são chamados sem<Suspense>no topo de ~11 layouts/pages:app/dashboard/layout.tsx,app/dashboard/empresa/layout.tsx,app/agenda/layout.tsx,app/(admin)/admin/layout.tsx,app/dashboard/docs/layout.tsx,app/dashboard/emails/layout.tsx,components/sidebar/layout.tsx, e as pagesapp/dashboard/page.tsx,app/dashboard/empresa/page.tsx,app/dashboard/empresa/membros/page.tsx,app/dashboard/empresa/papeis/page.tsx,app/dashboard/exemplos/page.tsx,app/(admin)/admin/planos/page.tsx, além deapp/(auth)/*eapp/page.tsxcomauth().- Server Actions com
revalidatePathque vão ganharupdateTagcomplementar:app/dashboard/_components/actions.ts,app/dashboard/empresa/membros/_components/actions.ts(×5),app/dashboard/empresa/papeis/_components/actions.ts(×4),app/agenda/_components/engine/agendamento.actions.ts,app/(admin)/admin/planos/_components/actions.ts(×2),components/sidebar/actions.ts. - Nenhum
export const dynamic/revalidate/fetchCachehoje. - Serviços retornam
DataBaseResponse(ver banco de dados) em vez de dado cru — a compatibilidade desse wrapper com a serialização exigida por"use cache"precisa ser verificada no início da Fase 1.
Fase 0 — Habilitar a flag e estabilizar o build
- Adicionar
cacheComponents: trueemnext.config.ts. - Rodar
npx @next/codemod@canary cache-components-instant-false ./app(opt-out mecânico de todas as rotas viaexport const instant = false, 32 arquivos). -
tsc --noEmit+next devpara confirmar ausência de erros de IO síncrono. Encontrado um caso real:app/page.tsxchamavaauth()(que fazcrypto.getRandomValues()internamente ao decodificar a sessão) no topo da página, bloqueando o prerender mesmo cominstant = false(esse erro não é suprimido pelo opt-out). Corrigido movendo a leitura de sessão para um componenteAccountNavActionsdentro de<Suspense fallback={<GuestNavActions />}>, removendo oinstant = falsedessa rota — ela já está migrada. - Commit isolado, sem mudança de comportamento visível (pendente de commit pelo usuário).
Fase 1 — Cachear a camada de serviço
- Confirmado:
DataBaseResponseé instância de classe (DataBaseSuccess/DataBaseErrordentro), não serializável como retorno de"use cache". Solução adotada: a query Prisma crua vira uma função"use cache"module-level (não exportada), e o método público do service continua chamandoDataBaseResponse.fromPromise(() => funcaoCached(...))— a API pública do service não muda. - Tags centralizadas em
lib/services/config/cache-tags.ts(uma função por entidade:empresaTag,membrosTag,convitesTag,empresasDoUsuarioTag,planoMatrizTag,eventosAgendaTag) — evita divergência entre a tag usada na leitura (cacheTag) e na invalidação (updateTag). -
empresa.service.ts: cacheadasbuscarPorId(cacheLife('hours')),listarDoUsuario,listarMembros,listarConvitesPendentes(cacheLife('minutes')).updateTagadicionado emcriarEmpresa,atualizarConfiguracao,alterarPapelMembro,inativarMembro,criarConviteEEnviar,revogarConvite,aceitarConvite. -
plano.service.ts:buscarMatrizconvertido deReact.cache()(memoização por request) para"use cache"+cacheTag('plano-matriz:...'),cacheLife('hours')— agora persiste entre requests, não só dentro de um.updateTagemsalvarMatriz. -
agendamento.service.ts:buscarEventosNaJanelacacheado (cacheLife('minutes'), tag por empresa).updateTagemcriarEvento,atualizarEventoeexcluirEvento. - Decisão de design (diverge do texto original do plano): o
updateTagfoi colocado dentro do próprio método do service, logo após a escrita, em vez de nasactions.ts(que é onderevalidatePathjá vive hoje). Motivo: a tag é definida no mesmo arquivo que a lê (cacheTag), então a invalidação correspondente fica ao lado, reduzindo o risco de esquecer de invalidar ou digitar uma tag diferente numaactions.tsdistante.revalidatePathnas actions continua como estava, sem mudança. - Resolvido:
role.service.tsestendiaBaseService(que resolveempresaIdautomaticamente viagetUsuarioAtualContexto()→getAccessContext()→cookies()), impedindo"use cache"emlistarTodos/listarComContagemDeMembros. Solução: os dois métodos passaram a receberempresaIdexplícito como parâmetro (igual ao padrão já usado em todoempresaService), em vez de resolvê-lo sozinhos por cookie —BaseServicenão foi alterado, e os métodos de escrita (atualizar/remover) continuam com o auto-scoping de sempre. As duas pages que chamavam esses métodos (app/dashboard/empresa/membros/page.tsx,app/dashboard/empresa/papeis/page.tsx) já tinham oempresaIddisponível viagetAccessContext(), então só precisaram passar o valor adiante.criarPapel,atualizarPermissoes,renomeareremoverPapelagora chamamupdateTag(papeisTag(empresaId)).userServicenão precisou de mudança (seus métodos usados em render não passam porBaseService), mas também não foi cacheado nesta fase. - Achado ao rodar a suíte de testes:
tests/services/plano-matriz.test.tsimporta oplano.service.tsreal (sem mock) e quebrou, porquecacheLife()/cacheTag()só funcionam dentro do runtime do Next comcacheComponentshabilitado — fora dele (Vitest) essas funções lançam. Corrigido mockandonext/cachenesse teste (cacheLife/cacheTagcomo no-op). Os demais testes que tocam os services alterados (papeis.test.ts,empresas.test.ts,convite-membro-empresa.test.ts,agenda-empresa.test.ts) já mockavam o service inteiro, então não foram afetados. Atenção para daqui pra frente: qualquer teste novo que exercite a implementação real (não mockada) de um service com"use cache"precisa mockarnext/cacheincluindocacheLife/cacheTag/updateTag, senão quebra do mesmo jeito.pnpm test(82 testes) epnpm lintseguem passando. - Extrair a parte de banco de
getAccessContext()(db.user.findUniquecom memberships) para uma função interna cacheada, recebendouserIdcomo argumento — fica para a Fase 2, junto da extração do gate de autenticação (fazem parte do mesmo componente).
Fase 2 — Cache de getAccessContext() + gate de autenticação
-
getAccessContext()decomposto em 3 camadas cacheáveis independentes, cada uma invalidada pelo que realmente a afeta (evita ter que invalidar o cache de cada usuário quando só a permissão de um papel muda):buscarUsuarioComMembrosCached(usuarioId)— vínculos de empresa do usuário, sem permissões do papel. Taguser:${usuarioId}.buscarPapelParaAccessContextCached(roleId, empresaId)— nome/permissões do papel. ReusapapeisTag(empresaId)(já invalidado porcriarPapel/atualizarPermissoes/renomear/removerPapel).planoService.buscarMatriz(planoId)— já cacheado desde a Fase 1.- A leitura do cookie
empresa_ativa_idcontinua fora de cache (é sempre lida fresca, só usada pra escolher qual membership da lista já cacheada é a "ativa").
-
updateTag(userTag(usuarioId))adicionado em todo lugar que muda o vínculo de um usuário específico:empresaService.criarEmpresa,alterarPapelMembro,inativarMembro,aceitarConvite, epromoverAAdminAction(app/dashboard/_components/actions.ts). -
tsc --noEmit,pnpm lintepnpm test(82 testes) passando. - Validado de ponta a ponta com login real: rodado
pnpm db:seed(idempotente, upsert) e testado via browser — login como[email protected], troca do papel de[email protected]de Administrador para Secretário em/dashboard/empresa/membros(lista atualiza na hora, toast "Perfil atualizado"), depois login como[email protected]: sidebar já mostra "Secretário" imediatamente, confirmando queupdateTag(userTag(...))invalidou o cache degetAccessContext()do próprio usuário afetado, não só do administrador que fez a mutação. Revertido o papel de volta para Administrador ao final do teste, sem deixar dado de teste divergente do seed.
Fase 2 (continuação) — decisão: não extrair o gate de autenticação
- Decidido com o usuário: não fazer.
components/sidebar/layout.tsxe os layouts que fazemgetAccessContext()/auth()(dashboard,agenda,admin,empresa,docs,emails) são 100% autenticados — não existe conteúdo público que viraria "shell estático" ali. Extrair isso para trás de<Suspense>exigiria criar skeletons de loading só para satisfazer a validação do Cache Components, sem ganho real de performance (a área é sempre dinâmica por natureza). - Mantido
instant = falsepermanentemente nessas rotas (não é um estado temporário a "arrumar depois" — o próprio Next trata isso como opção válida para rotas que nunca produziriam shell estático). O ganho de cache de dados (Fases 1 e 2 anteriores) já foi entregue independente disso. - Rotas públicas/auth (
app/page.tsx, já migrado na Fase 0) continuam candidatas a essa extração se algum dia ganharem mais conteúdo estático, mas isso não faz parte deste plano.
Importante para não confundir as duas coisas: instant = false desliga só a pré-renderização do shell da rota (o HTML estático que um CDN poderia servir sem tocar no servidor) — algo que nunca faria sentido no dashboard, já que toda página ali depende da sessão do usuário. Isso é independente do cache de dados feito nas Fases 1 e 2: as funções marcadas com "use cache" nos services (empresaService.listarMembros, roleService.listarComContagemDeMembros, as camadas de getAccessContext(), etc.) continuam cacheando o resultado da query, não a renderização da página. Ou seja: ao acessar uma rota do dashboard, o servidor ainda renderiza a página a cada request (isso nunca teria como mudar, sendo tudo autenticado), mas as queries ao banco dentro dela só rodam de fato quando o cache daquela função ainda não tem entrada válida — caso contrário reaproveita o resultado. O dashboard não ganhou "shell estático" (nunca poderia ganhar), mas ganhou cache real de banco de dados nas leituras, que é o objetivo original desta migração.
Fase 3 — Convertida para as rotas públicas restantes; demais rotas ficam com instant = false
Já feito: app/page.tsx (Fase 0). Demais páginas públicas (app/(auth)/login, app/(auth)/cadastro, app/fale-conosco) e todas as rotas autenticadas permanecem com instant = false por decisão da Fase 2 acima — não há trabalho adicional de conversão de shell estático planejado.
Fase 4 — Auditoria de invalidação nas Server Actions
- Feito como parte das Fases 1 e 2:
updateTagfoi colocado direto nos services (empresa.service.ts,plano.service.ts,agendamento.service.ts,role.service.ts) e empromoverAAdminAction, ao invés de nasactions.ts— decisão registrada na Fase 1.revalidatePathdas actions não foi alterado. - Nenhuma invalidação nova precisa rodar fora de Server Action (webhook/route handler) — todos os pontos de
updateTagadicionados vivem em Server Actions ou em métodos de service chamados só a partir delas.
Fase 5 — Validação
-
pnpm typecheckepnpm linta cada fase. - Testes automatizados das áreas tocadas; adicionar cobertura para leitura cacheada + invalidação imediata pós-mutação onde não existir.
- Smoke test manual: login → dashboard → trocar empresa ativa → CRUD de membro → CRUD de papel/matriz → matriz de plano (admin) → CRUD de evento de agenda.
-
pnpm buildao final de todas as fases (pedir confirmação explícita antes de rodar, por ser exceção à regra padrão do projeto de não rodar build).
Observação sobre escopo
Migração grande (~30 arquivos entre services, layouts, pages e actions) num app multi-tenant com permissões. Cada fase é independentemente testável e deve ser validada antes de avançar para a próxima.