feat(multiplayer): sinal de sala por broadcast + releitura server-side (#109) - #110
Conversation
…gres_changes (#109) Fatia 1 de 3 da #103 (parecer do Conselho, direção E): liga a fonte NOVA de estado da sala sem desligar a antiga e sem tocar em policy/grant, para que o corte da leitura anônima (fatia 2) não congele nenhuma sala viva no deploy. - Servidor (api/rooms/[code]): depois de cada `update` CONFIRMADO (settings, start, finish, kick, reset, claim-leader), publica a linha nova no canal que os membros já assinam (`coderacer:room:<CODE>`), evento `room`. `applyRoomUpdate` passa a devolver a linha (`select("*")` no lugar de `select("code")`) — a mesma linha que o `postgres_changes` já entrega. - `broadcastRealtime` (lib/supabase): POST no endpoint REST do Realtime, com timeout e best-effort — sem websocket na rota serverless. - Cliente (useRoom): as duas fontes caem no MESMO handler, com dedupe por `updated_at`; `SUBSCRIBED` dispara um resync (broadcast é at-most-once). - `reduceRoomSync`/`isNewerRoomState` (lib/room): núcleo puro do sync — dedupe, minha expulsão e quais expulsões anunciar. 16 casos novos em room.test.ts, incluindo o aviso de expulsão que não pode duplicar com duas fontes. Nenhuma mudança de migration, policy ou grant. Closes #109 Refs #103 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
…109) O canal `coderacer:room:<CODE>` é público (sem `private: true` e sem RLS em `realtime.messages`): qualquer cliente com a anon key emite `event:"room"` nele. Aplicar o payload como linha da sala reabria exatamente o que a migration 0005 fechou no #39 ("expulsão nunca por broadcast") — um jogador forjaria `kicked_ids` para derrubar qualquer um (inclusive o líder), forjaria `status`/`snippet`/`results`/`leader_id`, ou congelaria a sala para todos com um `updated_at` no futuro, que o dedupe passaria a tratar como "mais novo". - `useRoom.ts`: o handler de `event:"room"` ignora o payload e só agenda um resync pela API server-side, coalescido (`RESYNC_MIN_INTERVAL_MS`) para que um flood barato de mensagens não vire flood de GETs vezes o nº de membros. - `route.ts`: `broadcastRoom` publica só o sinal (`code` + `updated_at`), nunca a linha. - `applyRoomRow` passa a ser chamado apenas com linha do `postgres_changes` ou da API — o dedupe por `updated_at` segue valendo entre essas duas fontes. O objetivo da fatia 1 é preservado: quando a fatia 2 cortar a leitura anônima, o par sinal + releitura server-side continua entregando o estado. Gate: typecheck ✅ · test ✅ 158 · build ✅ · validate-persistence 72/72 ✅ · validate-metrics 45/45 ✅ Refs #109
🩺 Parecer do PR Doctor — quórum §7.2 em duas rodadas1ª rodada (head O canal
A lente de Domínio havia aprovado (trigger Reparo aplicado pelo PR Doctor (
A fatia 2 continua viável: cortada a leitura anônima, o par sinal + releitura server-side entrega o mesmo estado (o 2ª rodada (head
Ressalvas registradas, não bloqueantes desta PR:
O corpo original descreve o desenho da 1ª rodada ("broadcast autoritativo" aplicado no cliente) — o reparo o supera nos pontos acima; o restante (contexto, gate, critérios) segue válido. Título ajustado para refletir o mecanismo final. Mergeando (3× APROVA, §7.2). |
Fatia 1 de 3 da #103 — exatamente a direção E do 🏛️ Parecer do Conselho de 28/07, sem divergências. Segui inclusive o que o parecer pede para NÃO fazer: nada de enxugar o payload, nada de
revoke.Contexto
A única coisa que o
grant select on rooms to anonsustenta hoje é a subscriptionpostgres_changesdo cliente (useRoom.ts:248) — estado inicial e resync já vêm da API server-side. Esta PR liga a fonte NOVA (broadcast autoritativo do servidor) em paralelo à antiga. Apertar a policy no mesmo deploy que troca o cliente congelaria toda sala privada viva, e merge namainé deploy; por isso a fatia 1 não fecha nada — ela torna o fechamento (fatia 2) seguro.O que mudou
Servidor —
src/app/api/rooms/[code]/route.tsupdateconfirmado (settings,start,finish,kick,reset,claim-leader), publica a linha nova comoevent: "room"no canal que os membros já assinam (coderacer:room:<CODE>). Nunca antes nem em paralelo à escrita: um broadcast que se adiantasse a um update que falhou espalharia estado inexistente.applyRoomUpdatepassa a devolver a linha (select("*")no lugar deselect("code")). É a mesma linha que opostgres_changesjá entrega a todo assinante (replica identity full), então não expõe nada novo. Emfinish, só o cliente que efetivamente flipouracing→finishedanuncia.broadcastRealtime(src/lib/supabase.ts):POST /realtime/v1/api/broadcastcom timeout de 3s e retornoboolean. Escolhi o endpoint REST em vez de abrir canal/socket na rota serverless — sem handshake, semunsubscribeno caminho quente da resposta e sem acumular um canal por código de sala no cliente singleton. Falhar aqui é inofensivo por construção.Cliente —
src/lib/useRoom.tspostgres_changescaem no mesmoapplyRoomRow; a subscription antiga continua ligada.updated_at(triggerrooms_touchda 0002 renova o carimbo a cada UPDATE): o mesmo estado vindo pelas duas fontes é aplicado uma vez, e uma linha atrasada não reverte estado nem reanuncia expulsão.SUBSCRIBEDdispara um resync pela API — broadcast é at-most-once e sem replay, isso fecha a janela entre assinar e o primeiro evento. Inerte quando nada mudou (passa pelo mesmo dedupe).Núcleo puro —
src/lib/room.ts:reduceRoomSync(dedupe + minha expulsão + o que anunciar) eisNewerRoomState. O handler do cliente ficou só com efeitos. É o mesmo comportamento de antes, agora testável sem sala real.Gate (resultado real, neste worktree)
pnpm install --frozen-lockfilepnpm typecheckpnpm test(vitest)room.test.tspnpm buildnode scripts/validate-persistence.mjsvalidate-metrics.mjsLint: N/A — não há config de ESLint no repo (a CI roda typecheck + test + build) e não vou adicionar uma só para satisfazer o gate.
Os 16 testes novos cobrem: carimbo igual/anterior/posterior, formatos de timestamp diferentes entre as duas fontes, fail-open com carimbo ilegível, mesmo estado pelas duas fontes aplicado uma vez, aviso de expulsão não duplicado com a linha chegando duas vezes, expulsão anunciada só uma vez apesar de
kicked_idsser durável, linha atrasada que não reverte, minha própria expulsão,resetliberando anúncios da próxima rodada, payload malformado ekicked_idsausente (0005 não aplicada).Acceptance criteria
postgres_changesno mesmo handler, as duas fontes ligadasupdated_atprovado por teste da função pura, incluindo o não-duplicar do aviso de expulsãoSUBSCRIBEDdispara resyncreduceRoomSync, que é agnóstico de fonte)grant— nenhumrevoke/drop policyno diffRiscos e o que NÃO foi provado aqui
updated_atassume carimbos crescentes por UPDATE (garantido pelo trigger, transações separadas por request). Dois updates no mesmo microssegundo empatariam o carimbo e o segundo seria descartado; com round-trip HTTP entre eles, é implausível.latencyda corrida: intocada. Progresso por tecla continua em broadcast puro e nada disso roda durante a digitação — as mutações são raras (settings/start/finish/kick/reset).Solicito quórum (HANDBOOK §7)
Closes #109
Refs #103