Skip to content

Confiabilidade/Leaderboard: persistMatch descarta os erros de insert — corrida terminada some do ranking em silêncio e deixa matches órfã (rooms/[code]/route.ts:271-282) #112

Description

@caioross

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:

  1. :276 descarta error. O supabase-js não lança em erro de query — devolve
    { data: null, error }. Então uma falha (constraint NOT VALID da migration 0004, RLS,
    rede, timeout) faz match virar null, cair no return de :277 e não passar nem
    pelo catch
    . Zero linhas de log. A partida simplesmente não aconteceu.
  2. :278 descarta o retorno inteiro — nem data, nem error. Se o insert de scores
    falhar, a linha de matches já foi gravada: sobra uma partida órfã que aparece em
    "Partidas recentes" (getRecentMatches, src/lib/supabase.ts) com player_count e
    vencedor, enquanto nenhum dos jogadores entra no ranking — porque o leaderboard é
    uma view sobre scores (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

  1. O error do insert de matches (:276) é lido e registrado com console.error
    (não um warn que nunca dispara), identificando a sala. Falhou → não tenta gravar scores.
  2. O error do insert de scores (:278) é lido e registrado da mesma forma.
  3. A matches não fica órfã. Se o insert de scores falhar, o par não pode sobreviver
    pela metade — o Resolvedor escolhe entre (a) deletar, na mesma requisição, a linha de
    matches que essa própria chamada acabou de criar (pelo id retornado em :276), ou
    (b) gravar o par de forma que a ausência de scores seja 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.
  4. A resposta do finish continua { ok: true } mesmo com falha de persistência. Nenhuma
    mudança de comportamento visível no cliente.
  5. 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
    vitest e/ou scripts/validate-persistence.mjs, com um sb dublê que devolve { error }.
  6. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Prioridade normalarea:infraCI/CD, build, tooling, deploy, depsarea:multiplayerSalas, Realtime, presença, leaderboard, persistência

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions