Repository navigation
Description
Contexto
A #56 consertou o error descartado das actions de sala — inclusive o flip do finish, que
hoje devolve 500 honesto quando o update falha (src/app/api/rooms/[code]/route.ts:175-181).
Mas a persistência que roda logo depois desse flip (:186) ficou no padrão antigo, e é
justamente ela que decide se a corrida existe para o mundo:
src/app/api/rooms/[code]/route.ts:271-282
// Persist a finished match + scores for the leaderboard (best-effort).
async function persistMatch(sb: SupabaseClient, room: RoomRow, results: ResultRow[]) {
try {
const matchRow = buildMatchRow(room, results, new Date().toISOString());
if (!matchRow) return;
const { data: match } = await sb.from("matches").insert(matchRow).select("id").single(); // :276
if (!match) return;
await sb.from("scores").insert(buildScoreRows(match.id, room, results)); // :278
} catch (e) {
console.warn("[persistMatch]", (e as Error).message);
}
}Dois furos, ambos mudos:
:276descartaerror. Osupabase-jsnão lança em erro de query — devolve
{ data: null, error }. Então uma falha (constraintNOT VALIDda migration 0004, RLS,
rede, timeout) fazmatchvirarnull, cair noreturnde:277e não passar nem
pelocatch. Zero linhas de log. A partida simplesmente não aconteceu.:278descarta o retorno inteiro — nemdata, nemerror. Se o insert descores
falhar, a linha dematchesjá foi gravada: sobra uma partida órfã que aparece em
"Partidas recentes" (getRecentMatches,src/lib/supabase.ts) complayer_counte
vencedor, enquanto nenhum dos jogadores entra no ranking — porque oleaderboardé
uma view sobrescores(0001_coderacer_init.sql:40-52). O placar mente e ninguém sabe.
Em qualquer dos dois casos a rota responde ok: true (:188) e o cliente mostra a corrida
encerrada com sucesso.
Escopo honesto — o que este pedido NÃO é: o finish não deve passar a falhar por
causa da persistência. Quando persistMatch roda, a linha da sala já flipou e o broadcast já
saiu (:185-186); devolver 500 aqui faria o cliente acreditar que a corrida não terminou —
regressão pior que o bug. O pedido é observabilidade + integridade do par matches/scores,
não transação distribuída.
Acceptance criteria
- O
errordo insert dematches(:276) é lido e registrado comconsole.error
(não umwarnque nunca dispara), identificando a sala. Falhou → não tenta gravarscores. - O
errordo insert descores(:278) é lido e registrado da mesma forma. - A
matchesnão fica órfã. Se o insert descoresfalhar, o par não pode sobreviver
pela metade — o Resolvedor escolhe entre (a) deletar, na mesma requisição, a linha de
matchesque essa própria chamada acabou de criar (peloidretornado em:276), ou
(b) gravar o par de forma que a ausência descoresseja impossível. Justifique a
escolha no PR. Nota de escopo: a variante (a) é lógica de aplicação sobre uma linha
recém-criada no mesmo request — não é migração destrutiva, então não cai no §7.1; o
quórum do §7.2 (abaixo) é o crivo correto. - A resposta do
finishcontinua{ ok: true }mesmo com falha de persistência. Nenhuma
mudança de comportamento visível no cliente. - Cobertura dos caminhos de erro: a Testes/Persistência: extrair mapeamento puro de persistMatch (results→matches/scores) e cobrir com Vitest #50 já extraiu
buildMatchRow/buildScoreRows
puros e testados — o que falta é o wrapper. Cubra "matches falha" e "scores falha" em
viteste/ouscripts/validate-persistence.mjs, com umsbdublê que devolve{ error }. - Gate verde:
pnpm typecheck && pnpm build,vitest,validate-metrics,
validate-persistence.
Dica de abordagem
O arquivo já tem o padrão pronto a copiar: o finish em :175-181 mostra exatamente como
ler o error, logar com contexto e decidir. A diferença é o que se faz depois — aqui a
decisão é logar e seguir, nunca propagar 500.
Atenção a um detalhe do .single() em :276: ele devolve erro também quando a query não
retorna exatamente uma linha, então o log precisa distinguir "falhou a escrita" de "escreveu
e não voltou linha" para não virar ruído.
Classificação: §7.2 — área de quórum (toca src/app/api/rooms/**). PR non-draft com a
linha exata Solicito quórum (HANDBOOK §7).
Activity
caioross commentedon Jul 31, 2026
🏛️ Parecer do Conselho · 2026-07-31
Veredito: APROVADA PARA EXECUÇÃO — com (a) recomendado e justificado pelo schema, e uma pré-condição estrutural que a AC 5 exige sem dizer.
Auditado contra
origin/main @ 098372e(o clone do dono está atrás daorigin).Engenharia: O diagnóstico da issue está certo linha a linha; confirmei
:276/:278e ocatchque nunca dispara.Pré-condição que a AC 5 implica e não escreve: cobrir os caminhos de erro com um
sbdublê exige importarpersistMatch, e hoje ela é uma função privada dentro desrc/app/api/rooms/[code]/route.ts:271. Ovitestnão a importa sem arrastarnext/server, e exportá-la de um route file do App Router é ambíguo (o Next trata exports desses módulos como configuração de rota). Ovalidate-persistence.mjstambém não serve: ele é declaradamente um espelho sem import de TS (scripts/validate-persistence.mjs:66-69) — espelhar um wrapper de I/O ali testaria a cópia, não o código. → ExtraiapersistMatchparasrc/lib/(ex.:src/lib/persistMatch.ts), recebendosbpor parâmetro — ela já recebe. A rota passa a importar e a testabilidade cai de graça. Sem esse passo a AC 5 não fecha honestamente.AC 3 — recomendo (a), e o schema decide:
scores.match_idreferenciamatches(id)on delete cascade(0001_coderacer_init.sql:24), então apagar amatchesrecém-criada é um statement só, sem risco descoresórfão na direção oposta; e como o insert roda comservice_role(que ignora RLS), não é preciso policy de DELETE nenhuma. Já (b) — "gravar o par de forma que a ausência descoresseja impossível" — exige transação, e osupabase-jsnão tem transação sobre PostgREST: viraria função Postgres + migração nova, mais superfície e mais deploy-ordering para um problema que (a) resolve em 4 linhas. (a) é a fatia honesta.Guarde o guarda: o
deletecompensatório também pode falhar. Se falhar, a linha órfã que a AC 3 proíbe existe — esse caso precisa deconsole.errorpróprio e explícito, nunca de umcatchmudo. É o único jeito de a AC 3 ser verdadeira em vez de otimista..single()(:276): o PostgREST devolve erro também quando a contagem ≠ 1 — o código costuma serPGRST116; confirme no PR em vez de acreditar neste parecer, e ramifique o log porerror.codepara separar "não escreveu" de "escreveu e não voltou linha", como a issue pede. Não logue a linha inteira: ela carrega os nicks dos jogadores.Não é bug, não "conserte":
sb.from("scores").insert([])é inalcançável —buildMatchRowdevolvenullquandoresultsestá vazio (src/lib/room.ts:381) e a função retorna antes (route.ts:274).Segurança/Anti-cheat: Nada afrouxa; a fronteira anti-cheat é o
sanitizeResultsde:163, muito antes daqui. Vale registrar o modo de falha que este log torna visível:getServerSupabase()cai para a chave anon quandoSUPABASE_SERVICE_ROLE_KEYfalta (src/lib/supabase.ts:12-15), ematches/scorestêm RLS ligada com apenas policies de SELECT (0001:57-66) — nenhuma de INSERT. Num deploy sem service_role, todapersistMatchfalharia, sempre, e o ranking ficaria permanentemente vazio enquanto cada corrida respondeok: true. Não é o caso da produção (a Anti-cheat/Leaderboard: recorde impossível (3596 WPM) no Ranking Global — sem teto de plausibilidade #28 registra recorde no Ranking Global, logo o insert funciona lá) — mas é exatamente a classe de falha total-e-silenciosa que oerrordescartado esconde, e é o melhor argumento para o log serconsole.errorcom o código do erro junto.UX/Game-feel: A AC 4 está certa: falhar o
finishseria regressão pior. Mas o custo honesto tem nome — o jogador vê o pódio e o linkver ranking global(Results.tsx:157-162) apontando para um ranking em que ele não entrou. Esta fatia não resolve isso (resolver exige sinalizar o cliente, o que a AC 4 proíbe) e não deve tentar; fica como issue futura de UX. Sem mudança de copy, movimento oureduced-motionaqui.Performance: Fora da área sagrada —
persistMatché aguardada emroute.ts:186, depois do flip e do broadcast, nofinishdo último jogador; nenhuma tecla em voo. Odeletecompensatório adiciona um round-trip só no caminho de falha: custo zero no caminho feliz. Não introduza retry nesta fatia: o broadcast já saiu em:185, então um retry sobre uma resposta perdida cujo insert de fato gravou produziria uma segundamatchespara a mesma corrida — troca um bug silencioso por outro. Se alguém quiser idempotência de verdade, é outra issue (chave única porroom_code+finished_at).Produto: P2 correta — não há incidente conhecido e a produção persiste. O que sustenta a prioridade é a persona "O Cético Sênior", que julga "se o WPM bate com a realidade" (
docs/UI-AAA-OVERHAUL.md§0.3): o ranking é o único artefato durável do produto, e hoje ele pode perder corridas sem deixar uma linha de log. O sub-caso do órfão é pior que perder: "Partidas recentes" (supabase.ts:124-143) exibe a partida com vencedor eplayer_countenquanto nenhum jogador entra no ranking — o placar mente com aparência de verdade.Plano sugerido:
persistMatchderoute.ts:271-282parasrc/lib/persistMatch.ts, assinatura inalterada (sb, room, results). A rota só importa e chama em:186.errordo insert dematches:const { data: match, error: matchErr } = await sb....matchErr→console.error("[persistMatch:matches]", room.code, matchErr.code, matchErr.message)e return (nenhumscores). SemmatchErre semmatch→ log próprio; é o ramoPGRST116.errordo insert descores. Se falhar:console.error("[persistMatch:scores]", room.code, match.id, ...)e apague amatchesque esta chamada acabou de criar, porid—await sb.from("matches").delete().eq("id", match.id). Leia o erro desse delete também e logue comoconsole.errorse ele falhar (é o órfão que a AC 3 proíbe; ele não pode ser mudo).try/catchexterno, mas troqueconsole.warnporconsole.error— hoje ele só pegaria exceção de verdade, e essa merece o mesmo nível.finishintocado:route.ts:188continua{ ok: true }em qualquer desfecho.src/lib/persistMatch.test.tscom umsbdublê que devolve{ data: null, error: {...} }por tabela: (i) matches falha →scoresnunca é chamada; (ii) scores falha →deleteé chamado com oidcerto; (iii) delete também falha → não lança, e loga; (iv) caminho feliz → 2 inserts, 0 deletes; (v)resultsvazio → 0 inserts (regressão dobuildMatchRow → null). Espie oconsole.errorcomvi.spyOn.pnpm typecheck && pnpm build,vitest,validate-metrics,validate-persistence. §7.2 — PR non-draft comSolicito quórum (HANDBOOK §7). Na PR, justifique (a) vs (b) citando oon delete cascadede0001:24, como a AC 3 pede.caioross commentedon Aug 3, 2026
✅ Resolvida na PR #127
persistMatchsaiu do route file parasrc/lib/persistMatch.ts(a AC 5 não fecha sem isso — ovitestnão importa um module de rota do App Router), oserrordos dois inserts passaram a serlidos e logados com
console.error+ código do PostgREST, e oscoresque falha agora dispara odelete da
matchesque a mesma chamada criou — variante (a) da AC 3, justificada peloon delete cascadede0001_coderacer_init.sql:24. O erro desse delete também é logado(
[persistMatch:rollback]): a órfã que a AC 3 proíbe nunca é muda.finishintocado — segue{ ok: true }(AC 4).Gate verde: typecheck, build,
pnpm test181/181 (8 testes novos cobrindo os caminhos de erro),validate-persistence72/72,validate-metrics45/45.PR non-draft com quórum (§7.2 — toca
src/app/api/rooms/**). Segui o plano do Parecer do Conselhona íntegra; as três premissas que ele mandou reverificar foram confirmadas contra
origin/main.fix(anti-cheat): clampa errors/place ao int do Postgres antes de pers…
fix(leaderboard): persistMatch lê os erros de insert e não deixa matc…