# Spike — Migrar o backend para Firebase (fase 2)

**Data:** 07/08/2026 · **Escopo:** substituir NestJS + Prisma + PostgreSQL por
Firestore + Cloud Functions, preservando as regras de domínio do bolão.

**Decisão do dono (07/08):** Firestore + Cloud Functions, com **migração antes do
desligamento** — as duas implementações coexistem até os 234 casos e2e passarem na
nova.

---

## O que está sendo substituído

| Peça | Tamanho hoje |
| ---- | ------------ |
| Rotas HTTP | 38, em 8 controllers |
| Regra de negócio | 17 services, 5.139 linhas |
| Modelo de dados | 12 tabelas |
| Jobs recorrentes | 3 (`@Cron`) |
| Prova de que funciona | **71 testes de unidade + 234 e2e** |

O código não é o problema: 5 mil linhas de TypeScript migram para Functions quase
como estão, porque a regra já vive em services puros e não nos controllers. **O
caro são os 234 e2e**, que são a única prova de que prazo, sigilo e apuração
valem. Sem reexecutá-los na stack nova, a migração é fé.

Os **planos de caso continuam válidos** — foram escritos a partir das stories, não
do código (`docs/testes/`). O que precisa de re-execução é a evidência.

---

## Descoberta que muda o sequenciamento

**Dá para construir e testar a migração inteira com custo zero e sem cartão.** O
Firebase Emulator Suite (Firestore + Auth + Functions) roda local, e é nele que os
234 casos devem rodar. O plano **Blaze só é necessário para publicar** as
Functions em produção.

Verificado nesta máquina em 07/08:

| Requisito | Estado |
| --------- | ------ |
| `firebase-tools` | não instalado; disponível na v15.26.0 |
| Node 26 | ✅ presente |
| JDK (o emulador do Firestore exige) | ✅ **já instalado** via Homebrew, mas *keg-only* — o binário está em `/opt/homebrew/opt/openjdk/bin/java` e não está no `PATH` |

Consequência prática: o desenvolvimento começa hoje, sem decisão financeira. O
cartão entra só no dia do deploy.

⚠️ **Blaze exige cartão cadastrado**, mesmo com fatura esperada em R$ 0 pelo free
tier. Isso contraria a restrição de 03/08 ("é uma PoC, não vou investir dinheiro").
A decisão de habilitar é do dono e **não pode ser tomada por mim** — enquanto não
for, a migração fica pronta e verificada nos emuladores, sem publicar.

---

## O problema central: sigilo do palpite

É a regra mais difícil de levar para o Firestore, e define o desenho todo.

**Security rules não filtram resultado de query.** Uma listagem passa inteira ou
falha inteira — não existe "devolve só os documentos que este usuário pode ver".
Então `GET /matches/:id/predictions`, que hoje um service resolve com um `where`,
não tem tradução direta.

Três caminhos, e por que escolhi o terceiro:

1. **Toda leitura por Function.** Seguro e simples de raciocinar, mas paga latência
   de cold start em cada abertura de tela e desperdiça o que o Firestore faz bem
   (leitura direta com cache offline, que é justamente o que `PP-4` precisa).
2. **Rule com `get()` no jogo.** A rule busca `matches/{id}.status` para decidir. Custa
   uma leitura extra por documento avaliado e o limite de `get()` por avaliação é
   baixo — inviável para listar 10 palpites de uma rodada.
3. **Estado do jogo desnormalizado no palpite** *(escolhido)*. Cada documento de
   palpite carrega `jogoTerminado: bool`. A rule fica barata e local:

   ```
   allow read: if resource.data.userId == request.auth.uid
               || resource.data.jogoTerminado == true;
   ```

   Uma Function disparada na mudança de status do jogo vira a chave de todos os
   palpites daquele jogo. **O risco muda de lugar:** deixa de ser "a rule vaza" e
   passa a ser "a Function não rodou" — um palpite ficaria secreto além do
   necessário (falha segura) ou, se a ordem invertesse, exposto antes (falha
   grave). Por isso o gatilho tem de escrever o status **e** o flag na mesma
   transação, e o caso `PP-7-0x` passa a verificar essa transição, não só o
   resultado.

## Prazo: o relógio continua sendo do servidor

Duas metades, e as duas existem no Firestore:

- **Escrita:** a rule compara `request.time` (relógio do Google, não do celular)
  com `resource.data.kickoffAt` do jogo. Palpite fora do prazo é recusado pelo
  banco, não pelo app.
- **Exibição:** a contagem na tela continua usando o offset entre o horário do
  servidor e o do aparelho — já implementado e verificado em device com o relógio
  adulterado (`PP-3-11`). O Firestore expõe isso via `serverTimestamp()`.

## Mapa de dados

| Hoje (12 tabelas) | Firestore | Observação |
| ----------------- | --------- | ---------- |
| `User` | Firebase Auth + `usuarios/{uid}` | Auth passa a guardar credencial; o perfil (nome de exibição) fica no doc |
| `Session` | — | **desaparece**: rotação de refresh token é do Firebase Auth |
| `Pool` | `boloes/{poolId}` | regras de pontuação e desempate como campos |
| `PoolMember` | `boloes/{poolId}/membros/{uid}` | subcoleção: a rule de participação fica local |
| `PoolMatch` | `boloes/{poolId}/jogos/{matchId}` | vínculo + desnormalização do jogo |
| `Match` | `jogos/{matchId}` | coleção global, escrita só por Function |
| `Prediction` | `boloes/{poolId}/palpites/{matchId_uid}` | com `jogoTerminado` desnormalizado |
| `PredictionRevision` | `.../palpites/{id}/revisoes/{n}` | auditoria de disputa |
| `ScoreCorrection` | `jogos/{matchId}/correcoes/{n}` | trilha de intervenção |
| `Notification` | `usuarios/{uid}/avisos/{n}` | leitura só do dono |
| `PredictionReminder` | `usuarios/{uid}/lembretes/{matchId}` | idempotência do lembrete |
| `Invite` | `convites/{hash}` | id **é** o hash do token, como hoje |

**O ranking é o ponto de atenção de custo.** Hoje é uma agregação SQL (`groupBy`)
que roda em duas consultas independentemente do número de participantes — tem teste
travando isso (`PR-3-11`). No Firestore, somar pontos de 20 pessoas × 38 rodadas
seria centenas de leituras por abertura de tela. O desenho é **agregado
materializado**: `boloes/{poolId}/classificacao/{uid}` atualizado pela Function de
apuração. Uma leitura por abertura, e o gate de N+1 continua fazendo sentido — muda
de "não cresce em consultas" para "não cresce em leituras".

## Jobs

| Hoje | Firebase |
| ---- | -------- |
| `@Cron` horário — sincroniza com a `football-data.org` | Cloud Scheduler → Function |
| `@Cron` 10 min — lembretes de prazo | Cloud Scheduler → Function |
| Apuração ao detectar placar | Function no gatilho de escrita do jogo |

Isso **resolve `PP-10 OPS-1`**, a task aberta de "hospedagem que não hiberne": o
Scheduler não dorme. E a trava de concorrência de 07/08 (duas sincronizações
simultâneas duplicando correção) precisa ser reimplementada — em processo não
serve mais, porque cada invocação é um processo novo. O lugar passa a ser um
documento de lock no Firestore com transação.

## O que a migração destrava e o que ela devolve

**Destrava:** FCM é Firebase (as 5 tasks de `PP-8` e `PR-7 QA-2`), Cloud Scheduler
(`PP-10 OPS-1`), e o Firebase Auth traz **recuperação de senha** — o que faria a
`AC-4` voltar do cancelamento e apagaria a consequência mais dura do produto hoje
("esquecer a senha custa a conta em definitivo").

**Devolve para o backlog:** rate limit de login (hoje explícito, passa a ser do
Auth — precisa reverificar `AC-2-03`/`AC-2-04`), a paginação com teto
(`src/common/paginacao.ts` não tem equivalente automático) e os 234 e2e, que
precisam rodar em emulador.

## Como isso vai ser verificado

Sem isso, é reescrita no escuro:

1. **Emulator Suite como ambiente de teste**, no lugar do banco `bolao_test`.
2. **Os 234 casos e2e reescritos contra as Functions e as rules**, mantendo os IDs
   dos casos — é o que permite comparar linha a linha com a execução do NestJS.
3. **Testes de security rule** (`@firebase/rules-unit-testing`) para o que hoje é
   teste de autorização: sigilo, participação, papel de admin. São 40+ casos que
   hoje vivem em e2e e passam a ser testes de rule.
4. **Nada é desligado** antes de a suíte nova estar verde e o app rodar em device
   contra o emulador.

## Riscos que ainda não têm resposta

- **Transação distribuída na apuração.** Hoje é `$transaction` do Prisma; no
  Firestore, escrever pontuação de 20 participantes + classificação + avisos passa
  do limite de 500 operações por transação em bolão grande. Precisa de lote.
- **Consulta sem `join`.** O extrato hoje casa palpite com jogo numa consulta. Vai
  virar leitura de duas coleções + composição em memória, e é onde o custo de
  leitura pode surpreender.
- **Custo real do free tier** (Spark: 50 mil leituras/dia) só dá para medir com o
  agregado materializado pronto. Estimativa grosseira: 20 pessoas abrindo o app 10
  vezes ao dia com ranking materializado ficam na casa de 3 mil leituras/dia —
  folgado. Sem materializar, estoura.
- **Cold start** na Function de palpite, na hora do apito, quando todo mundo
  palpita ao mesmo tempo. Mitigação conhecida: escrita direta no Firestore com
  rule (sem Function no caminho crítico).

## Próximo passo

Stories de migração, no mesmo formato das outras (`docs/stories/`), com task por
disciplina e plano de caso reaproveitando os IDs existentes. A ordem proposta
segue o risco, não a facilidade: modelo e rules primeiro (sigilo e prazo), depois
apuração e agregado, depois jobs, e por último o corte do NestJS.

---

## Projeto real (atualizado em 07/08)

**`bolao-raro-4382d`**, na conta `igorengcomp12@gmail.com` — o dono trocou a conta
do Firebase durante a configuração; o `bolao-raro-ad0fc` da conta corporativa foi
abandonado antes de receber qualquer dado.

| Item | Estado |
| ---- | ------ |
| Firestore | provisionado, modo nativo, **`southamerica-east1`** (São Paulo) |
| Região | escolha **irreversível** — e é a certa: o produto é um bolão do Brasileirão, e o momento crítico é o minuto antes do apito |
| Apps registrados | nenhum ainda |
| *Delete protection* | **desligada** |
| *Point-in-time recovery* | **desligado** |

Dois pontos para tratar antes de existir dado que importe:

- **Ligar delete protection e PITR.** Ambos vêm desligados. Sem o primeiro, um
  comando errado apaga o banco; sem o segundo, não há de onde restaurar.
- **O projeto está numa conta pessoal (`@gmail.com`), não na corporativa.** Para
  uma PoC não muda nada técnico, mas a titularidade fica com a pessoa, não com a
  empresa: cobrança, transferência e acesso de outra pessoa passam por essa conta.
  Se o bolão virar produto, isso precisa migrar — e migrar projeto entre
  organizações é bem mais trabalhoso que criar no lugar certo.

O `default` do `.firebaserc` **continua** sendo `demo-bolao`: o projeto real entra
como alias `raro`, e só por comando explícito.

## Deploy de fumaça, 07/08 — o que ele pegou

O dono habilitou o **Blaze** e autorizou publicar a função trivial `saude` da
`MG-1`, só para exercitar o caminho inteiro antes de a `MG-6` depender dele. O
deploy funcionou — o que **prova** o Blaze ativo, já que Functions não sobem no
Spark — e a função respondeu `200` em 0,5 s.

Achou dois problemas que teste local nenhum pegaria:

1. **A Function foi para `us-central1`**, com o Firestore em `southamerica-east1`.
   É o padrão do Firebase, e ninguém teria notado: no emulador tudo é localhost.
   Em produção, cada leitura da apuração e da sincronização cruzaria o continente
   e voltaria — nas duas operações que fazem **mais** leituras. Corrigido com
   `setGlobalOptions({ region: 'southamerica-east1' })`, redeployado, e a função de
   Iowa removida (confirmado: responde 404).
2. **Nenhuma política de limpeza no Artifact Registry.** Cada deploy guarda uma
   imagem de container, e elas acumulam para sempre — fatura crescendo em silêncio,
   exatamente o que a restrição de custo não tolera. Configurada limpeza de imagens
   com mais de 3 dias, nas duas regiões.

Também ficaram ligados **delete protection** e **point-in-time recovery** no
Firestore (retenção de 1 h → 7 dias), com o banco ainda vazio.

Custo residual conhecido: armazenamento das imagens dentro da janela de 3 dias.
Centavos de dólar por mês, não zero — e é honesto dizer isso em vez de "é grátis".
