[{"data":1,"prerenderedAt":502},["ShallowReactive",2],{"paleta":3,"caso-kravva":28,"viz-kravva":495},{"projetos":4,"posts":27},[5,9,13,16,20,23],{"path":6,"title":7,"contexto":8},"\u002Fprojetos\u002Fkravva","Kravva","Produto próprio",{"path":10,"title":11,"contexto":12},"\u002Fprojetos\u002Fonboarding-erp","Onboarding digital de um ERP financeiro","Consultoria PJ",{"path":14,"title":15,"contexto":12},"\u002Fprojetos\u002Fpainel-erp","Painel administrativo do ERP",{"path":17,"title":18,"contexto":19},"\u002Fprojetos\u002Fligafest","Ingresso em grupo da LigaFest","LigaMagic · CLT",{"path":21,"title":22,"contexto":19},"\u002Fprojetos\u002Fcircuitos-ligamagic","Circuitos LigaMagic e LigaPokémon",{"path":24,"title":25,"contexto":26},"\u002Fprojetos\u002Fblippy","Blippy","Projeto pessoal",[],{"id":29,"title":7,"body":30,"contexto":8,"cor":460,"description":461,"destaque":462,"extension":463,"link":464,"meta":465,"navigation":229,"numeros":466,"ordem":127,"papel":479,"path":6,"periodo":480,"resumo":481,"seo":482,"stack":483,"stem":493,"__hash__":494},"projetos\u002Fprojetos\u002Fkravva.md",{"type":31,"value":32,"toc":447},"minimark",[33,38,42,50,53,57,62,65,69,72,76,89,92,96,113,333,341,345,348,352,363,369,375,381,385,388,391,424,427,431,434,437,440,443],[34,35,37],"h2",{"id":36},"o-problema","O problema",[39,40,41],"p",{},"Queria um produto meu, com back-end de verdade. O Kravva é uma plataforma de palpites de futebol: o usuário palpita placares, entra em bolões, disputa rankings, acompanha jogos ao vivo e compra passes via PIX. Tem loja, afiliados e uma camada social.",[39,43,44,45,49],{},"Isso parece simples até o jogo começar. O placar muda e todo mundo quer ver na hora. A pontuação precisa ser recalculada no apito final. A API de dados esportivos tem cota diária. E o dinheiro entra por ",[46,47,48],"termo",{"id":48},"webhook",", que o provedor reenvia quando quer.",[39,51,52],{},"Eu era a única pessoa para resolver tudo isso, do produto ao deploy.",[34,54,56],{"id":55},"o-que-eu-fiz","O que eu fiz",[58,59,61],"h3",{"id":60},"back-end-modular","Back-end modular",[39,63,64],{},"A API é um monólito modular em NestJS 11 com PostgreSQL 16: 31 módulos, 82 entidades e 96 migrations. As migrations são SQL escrito à mão e a CI aplica todas num Postgres efêmero antes de qualquer deploy. Auth com JWT e refresh token, SSO Google e RBAC.",[58,66,68],{"id":67},"filas-para-tudo-que-não-cabe-numa-requisição","Filas para tudo que não cabe numa requisição",[39,70,71],{},"São 16 filas BullMQ: sincronização de partidas ao vivo, pontuação, notificações, e-mail e retenção de dados para a LGPD. Os jobs usam IDs determinísticos. Assim, o mesmo gol ou o mesmo apito final não é processado duas vezes.",[58,73,75],{"id":74},"tempo-real-em-cluster","Tempo real em cluster",[39,77,78,79,83,84,88],{},"A API roda ",[46,80,82],{"id":81},"cluster","em cluster"," Node, com mais de um worker. O Socket.io usa ",[46,85,87],{"id":86},"redis adapter","Redis adapter"," e autenticação JWT no handshake. Quando uma compra é confirmada ou o placar muda, o evento sai do worker que processou e chega ao socket do usuário, esteja ele em qual worker estiver.",[90,91],"viz-tempo-real",{},[58,93,95],{"id":94},"pix-com-webhook-e-ledger","PIX com webhook e ledger",[39,97,98,99,103,104,107,108,112],{},"O webhook chega com assinatura ",[46,100,102],{"id":101},"hmac","HMAC"," sobre o corpo cru, que valido com comparação em tempo constante. A confirmação roda numa transação com ",[46,105,106],{"id":106},"lock pessimista"," na linha do pagamento e grava num ",[46,109,111],{"id":110},"ledger","ledger financeiro append-only",". Estorno é lançamento novo, nunca update.",[114,115,120],"pre",{"className":116,"code":117,"language":118,"meta":119,"style":119},"language-ts shiki shiki-themes github-light github-dark-dimmed","\u002F\u002F Confirmação idempotente (pseudocódigo)\nawait db.transaction(async (tx) => {\n  const pagamento = await tx.selectForUpdate(pagamentos, { id }); \u002F\u002F trava a linha\n  if (pagamento.status === 'pago') return; \u002F\u002F reentrega do webhook: nada a fazer\n\n  pagamento.status = 'pago';\n  await tx.save(pagamento);\n  await ledger.registrarSeAusente(tx, pagamento); \u002F\u002F lançamento append-only\n  await acessos.concederSeAusente(tx, pagamento);\n  await comissao.creditar(tx, pagamento); \u002F\u002F só na primeira confirmação\n});\n\u002F\u002F O evento de tempo real só sai depois do commit\nemitirParaUsuario(pagamento.usuarioId, 'acesso_liberado');\n","ts","",[121,122,123,132,169,197,224,231,245,259,276,290,306,312,318],"code",{"__ignoreMap":119},[124,125,128],"span",{"class":126,"line":127},"line",1,[124,129,131],{"class":130},"sDN9O","\u002F\u002F Confirmação idempotente (pseudocódigo)\n",[124,133,135,139,143,147,150,153,156,160,163,166],{"class":126,"line":134},2,[124,136,138],{"class":137},"s7YZ4","await",[124,140,142],{"class":141},"sYgZi"," db.",[124,144,146],{"class":145},"sPO5f","transaction",[124,148,149],{"class":141},"(",[124,151,152],{"class":137},"async",[124,154,155],{"class":141}," (",[124,157,159],{"class":158},"stnAF","tx",[124,161,162],{"class":141},") ",[124,164,165],{"class":137},"=>",[124,167,168],{"class":141}," {\n",[124,170,172,175,179,182,185,188,191,194],{"class":126,"line":171},3,[124,173,174],{"class":137},"  const",[124,176,178],{"class":177},"sviXB"," pagamento",[124,180,181],{"class":137}," =",[124,183,184],{"class":137}," await",[124,186,187],{"class":141}," tx.",[124,189,190],{"class":145},"selectForUpdate",[124,192,193],{"class":141},"(pagamentos, { id }); ",[124,195,196],{"class":130},"\u002F\u002F trava a linha\n",[124,198,200,203,206,209,213,215,218,221],{"class":126,"line":199},4,[124,201,202],{"class":137},"  if",[124,204,205],{"class":141}," (pagamento.status ",[124,207,208],{"class":137},"===",[124,210,212],{"class":211},"s-HuK"," 'pago'",[124,214,162],{"class":141},[124,216,217],{"class":137},"return",[124,219,220],{"class":141},"; ",[124,222,223],{"class":130},"\u002F\u002F reentrega do webhook: nada a fazer\n",[124,225,227],{"class":126,"line":226},5,[124,228,230],{"emptyLinePlaceholder":229},true,"\n",[124,232,234,237,240,242],{"class":126,"line":233},6,[124,235,236],{"class":141},"  pagamento.status ",[124,238,239],{"class":137},"=",[124,241,212],{"class":211},[124,243,244],{"class":141},";\n",[124,246,248,251,253,256],{"class":126,"line":247},7,[124,249,250],{"class":137},"  await",[124,252,187],{"class":141},[124,254,255],{"class":145},"save",[124,257,258],{"class":141},"(pagamento);\n",[124,260,262,264,267,270,273],{"class":126,"line":261},8,[124,263,250],{"class":137},[124,265,266],{"class":141}," ledger.",[124,268,269],{"class":145},"registrarSeAusente",[124,271,272],{"class":141},"(tx, pagamento); ",[124,274,275],{"class":130},"\u002F\u002F lançamento append-only\n",[124,277,279,281,284,287],{"class":126,"line":278},9,[124,280,250],{"class":137},[124,282,283],{"class":141}," acessos.",[124,285,286],{"class":145},"concederSeAusente",[124,288,289],{"class":141},"(tx, pagamento);\n",[124,291,293,295,298,301,303],{"class":126,"line":292},10,[124,294,250],{"class":137},[124,296,297],{"class":141}," comissao.",[124,299,300],{"class":145},"creditar",[124,302,272],{"class":141},[124,304,305],{"class":130},"\u002F\u002F só na primeira confirmação\n",[124,307,309],{"class":126,"line":308},11,[124,310,311],{"class":141},"});\n",[124,313,315],{"class":126,"line":314},12,[124,316,317],{"class":130},"\u002F\u002F O evento de tempo real só sai depois do commit\n",[124,319,321,324,327,330],{"class":126,"line":320},13,[124,322,323],{"class":145},"emitirParaUsuario",[124,325,326],{"class":141},"(pagamento.usuarioId, ",[124,328,329],{"class":211},"'acesso_liberado'",[124,331,332],{"class":141},");\n",[39,334,335,336,340],{},"Se o webhook atrasa, o front consulta a transação. Depois de um tempo pendente, a API pergunta ao provedor e chama o mesmo caminho ",[46,337,339],{"id":338},"idempotencia","idempotente",".",[58,342,344],{"id":343},"operação","Operação",[39,346,347],{},"Rotinas em background precisam rodar em um único processo, então elegi um líder por lease no PostgreSQL. O deploy é automatizado com GitHub Actions e SSH numa VPS com Docker e Nginx. O front fica no Cloudflare Pages e os arquivos no R2. Há ambiente de produção e de homologação.",[34,349,351],{"id":350},"decisões-e-trade-offs","Decisões e trade-offs",[39,353,354,358,359,362],{},[355,356,357],"strong",{},"Idempotência em camadas no pagamento."," Histórico de eventos, ",[121,360,361],{},"SELECT ... FOR UPDATE",", checagem de estado e índices únicos como última barreira. Escolhi lock pessimista porque provedores de pagamento reenviam em rajada e a seção crítica é curta. O custo: entregas concorrentes do mesmo pagamento esperam umas pelas outras.",[39,364,365,368],{},[355,366,367],{},"BullMQ com jobId determinístico."," A fila garante entrega pelo menos uma vez, então o processamento precisa tolerar repetição. O ID do job impede duplicatas. O custo: a deduplicação só vale enquanto o job está retido. Já tive de pensar em incluir o placar no ID para que uma correção de VAR gerasse um job novo.",[39,370,371,374],{},[355,372,373],{},"SQL à mão nas migrations."," Dá controle sobre índices parciais e backfills, que o gerador do TypeORM faz mal. O custo: nada garante que a entidade continue igual à coluna real. Isso depende de disciplina e de teste.",[39,376,377,380],{},[355,378,379],{},"Cluster em vez de orquestrador."," Dois workers aproveitam a máquina sem a complexidade de Kubernetes. O custo é visível no próximo ponto: estado em memória por processo.",[34,382,384],{"id":383},"resultado","Resultado",[39,386,387],{},"O Kravva está em produção, com deploy automatizado, testes unitários com Jest, testes de integração com Postgres real e CI que faz dry-run das migrations. Não divulgo o número de usuários.",[39,389,390],{},"Três incidentes reais viraram correção:",[392,393,394,409,415],"ul",{},[395,396,397,400,401,404,405,408],"li",{},[355,398,399],{},"WebSocket caindo no cluster."," O front recebia \"Session ID unknown\" (HTTP 400). A causa: o Nginx não repassava o upgrade de WebSocket, o cliente caía para ",[46,402,403],{"id":403},"long polling"," e as requisições do mesmo cliente chegavam a workers diferentes. Adicionei o bloco do Socket.io no Nginx com ",[121,406,407],{},"Upgrade",", e o erro sumiu. Foi um problema separado do Redis adapter, que adicionei no mesmo ajuste para resolver uma falha silenciosa: um evento emitido num worker não chegava a sockets conectados em outro.",[395,410,411,414],{},[355,412,413],{},"Cota da API de futebol estourada na Copa."," Passei a usar um snapshot ao vivo compartilhado entre workers e um rate limiter global no Redis.",[395,416,417,420,421,340],{},[355,418,419],{},"PWA servindo arquivos velhos após deploy."," Tratei a recuperação de chunk e o ",[121,422,423],{},"skipWaiting",[39,425,426],{},"Sobre IA: boa parte do código foi escrita com apoio de Cursor e Claude. As decisões, a revisão e a operação são minhas, e sei explicar cada uma delas.",[34,428,430],{"id":429},"o-que-eu-faria-diferente","O que eu faria diferente",[39,432,433],{},"Começaria com observabilidade: logs estruturados, métricas das filas e alertas. Essa é uma lacuna que conheço.",[39,435,436],{},"Quebraria o serviço de sincronização de jogos, que cresceu demais.",[39,438,439],{},"Fecharia o fallback de long polling com WebSocket puro ou sticky sessions. Sem isso, o polling continua frágil com mais de um worker.",[39,441,442],{},"Testaria as migrations contra um dump anonimizado, não só contra um banco vazio. E padronizaria o acesso a dados, que hoje mistura repositórios próprios com o repositório padrão do TypeORM.",[444,445,446],"style",{},"html pre.shiki code .sDN9O, html code.shiki .sDN9O{--shiki-default:#6A737D;--shiki-dark:#768390}html pre.shiki code .s7YZ4, html code.shiki .s7YZ4{--shiki-default:#D73A49;--shiki-dark:#F47067}html pre.shiki code .sYgZi, html code.shiki .sYgZi{--shiki-default:#24292E;--shiki-dark:#ADBAC7}html pre.shiki code .sPO5f, html code.shiki .sPO5f{--shiki-default:#6F42C1;--shiki-dark:#DCBDFB}html pre.shiki code .stnAF, html code.shiki .stnAF{--shiki-default:#E36209;--shiki-dark:#F69D50}html pre.shiki code .sviXB, html code.shiki .sviXB{--shiki-default:#005CC5;--shiki-dark:#6CB6FF}html pre.shiki code .s-HuK, html code.shiki .s-HuK{--shiki-default:#032F62;--shiki-dark:#96D0FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":119,"searchDepth":134,"depth":171,"links":448},[449,450,457,458,459],{"id":36,"depth":134,"text":37},{"id":55,"depth":134,"text":56,"children":451},[452,453,454,455,456],{"id":60,"depth":171,"text":61},{"id":67,"depth":171,"text":68},{"id":74,"depth":171,"text":75},{"id":94,"depth":171,"text":95},{"id":343,"depth":171,"text":344},{"id":350,"depth":134,"text":351},{"id":383,"depth":134,"text":384},{"id":429,"depth":134,"text":430},"oklch(0.7 0.17 150)","Plataforma de palpites de futebol que construí e opero sozinho: NestJS, filas, tempo real em cluster e PIX idempotente.","Back-end completo em produção, operado por uma pessoa só","md","https:\u002F\u002Fkravva.com.br",{},[467,470,473,476],{"valor":468,"rotulo":469},"16","filas BullMQ",{"valor":471,"rotulo":472},"31","módulos no back-end",{"valor":474,"rotulo":475},"96","migrations",{"valor":477,"rotulo":478},"2","ambientes: produção e homologação","Fundador, produto e engenharia","2026 · em produção","Plataforma de palpites de futebol com rankings, bolões, loja com PIX e placar ao vivo. Construo e opero sozinho, do produto à infraestrutura.",{"title":7,"description":461},[484,485,486,487,488,489,490,491,492],"Vue 3","Quasar","NestJS","PostgreSQL","Redis","BullMQ","Socket.io","Docker","GitHub Actions","projetos\u002Fkravva","PL5tIm8fdOuruEkpYXlXFC9yDooGzEYgFgQ7oZSOObY",[496,497,498,499,500,501],{"path":6,"title":7,"ordem":127},{"path":10,"title":11,"ordem":134},{"path":14,"title":15,"ordem":171},{"path":17,"title":18,"ordem":199},{"path":21,"title":22,"ordem":226},{"path":24,"title":25,"ordem":233},1791915291201]