[{"data":1,"prerenderedAt":394},["ShallowReactive",2],{"paleta":3,"caso-blippy":28,"viz-blippy":387},{"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":25,"body":30,"contexto":26,"cor":357,"description":358,"destaque":359,"extension":360,"link":361,"meta":362,"navigation":363,"numeros":364,"ordem":137,"papel":371,"path":24,"periodo":372,"resumo":373,"seo":374,"stack":375,"stem":385,"__hash__":386},"projetos\u002Fprojetos\u002Fblippy.md",{"type":31,"value":32,"toc":344},"minimark",[33,38,42,45,48,52,57,60,63,67,70,73,248,252,255,259,262,266,274,278,289,295,301,307,311,314,317,320,324,327,334,337,340],[34,35,37],"h2",{"id":36},"o-problema","O problema",[39,40,41],"p",{},"Queria estudar de perto o que sustenta um chat em tempo real: entregar a mensagem rápido, não perdê-la quando algo cai e não duplicá-la quando o cliente tenta de novo. O Blippy é uma plataforma de comunidades no estilo Discord, com servidores, canais de texto e voz, permissões, anexos e compartilhamento de tela.",[39,43,44],{},"Os problemas difíceis estão no meio do caminho. Se a API grava a mensagem no banco e morre antes de avisar o broker, ninguém recebe. Se publica antes de gravar, anuncia algo que não existe. Se o cliente reenvia um POST que deu timeout, a mensagem aparece duas vezes.",[39,46,47],{},"Desenhei o contrato pensando em escala grande, mas implementei só o que um MVP pede, em fatias verificáveis.",[34,49,51],{"id":50},"o-que-eu-fiz","O que eu fiz",[53,54,56],"h3",{"id":55},"dois-planos","Dois planos",[39,58,59],{},"Separei o sistema em control plane e data plane. A API em NestJS (Fastify), com PostgreSQL e Redis, decide e persiste. Um gateway de tempo real em Go mantém os WebSockets e empurra os eventos. NATS JetStream em cluster liga os dois. Se a API cai, o sistema degrada. Se o gateway cai, o tempo real para. Há uma checagem de CI que falha se o gateway importar um driver de banco.",[39,61,62],{},"O gateway em Go foi escrito com forte apoio de IA. O contrato Protobuf, as decisões e a revisão são minhas.",[53,64,66],{"id":65},"envio-idempotente-e-outbox","Envio idempotente e outbox",[39,68,69],{},"O cliente gera um nonce e mostra a mensagem como pendente. A API grava mensagem, nonce e evento de saída (outbox) na mesma transação. Só depois publica no NATS. Se o processo morrer entre o commit e a publicação, um reconciliador republica.",[71,72],"viz-chat",{},[74,75,80],"pre",{"className":76,"code":77,"language":78,"meta":79,"style":79},"language-ts shiki shiki-themes github-light github-dark-dimmed","\u002F\u002F Dentro da transação de envio (pseudocódigo)\ntry {\n  await tx.query(\n    `INSERT INTO message_nonces (channel_id, author_id, nonce, message_id)\n     VALUES ($1, $2, $3, $4)`, [canal, autor, nonce, id]);\n} catch (err) {\n  \u002F\u002F Retry do mesmo envio: devolve o id que já existe\n  if (isUniqueViolation(err)) return { duplicate: true, id: await buscarPorNonce(...) };\n  throw err;\n}\nawait tx.query('INSERT INTO messages ...');\nawait tx.query('INSERT INTO outbox ...'); \u002F\u002F evento na mesma transação\n","ts","",[81,82,83,92,103,119,126,135,147,153,196,205,211,228],"code",{"__ignoreMap":79},[84,85,88],"span",{"class":86,"line":87},"line",1,[84,89,91],{"class":90},"sDN9O","\u002F\u002F Dentro da transação de envio (pseudocódigo)\n",[84,93,95,99],{"class":86,"line":94},2,[84,96,98],{"class":97},"s7YZ4","try",[84,100,102],{"class":101},"sYgZi"," {\n",[84,104,106,109,112,116],{"class":86,"line":105},3,[84,107,108],{"class":97},"  await",[84,110,111],{"class":101}," tx.",[84,113,115],{"class":114},"sPO5f","query",[84,117,118],{"class":101},"(\n",[84,120,122],{"class":86,"line":121},4,[84,123,125],{"class":124},"s-HuK","    `INSERT INTO message_nonces (channel_id, author_id, nonce, message_id)\n",[84,127,129,132],{"class":86,"line":128},5,[84,130,131],{"class":124},"     VALUES ($1, $2, $3, $4)`",[84,133,134],{"class":101},", [canal, autor, nonce, id]);\n",[84,136,138,141,144],{"class":86,"line":137},6,[84,139,140],{"class":101},"} ",[84,142,143],{"class":97},"catch",[84,145,146],{"class":101}," (err) {\n",[84,148,150],{"class":86,"line":149},7,[84,151,152],{"class":90},"  \u002F\u002F Retry do mesmo envio: devolve o id que já existe\n",[84,154,156,159,162,165,168,171,174,178,181,184,187,190,193],{"class":86,"line":155},8,[84,157,158],{"class":97},"  if",[84,160,161],{"class":101}," (",[84,163,164],{"class":114},"isUniqueViolation",[84,166,167],{"class":101},"(err)) ",[84,169,170],{"class":97},"return",[84,172,173],{"class":101}," { duplicate: ",[84,175,177],{"class":176},"sviXB","true",[84,179,180],{"class":101},", id: ",[84,182,183],{"class":97},"await",[84,185,186],{"class":114}," buscarPorNonce",[84,188,189],{"class":101},"(",[84,191,192],{"class":97},"...",[84,194,195],{"class":101},") };\n",[84,197,199,202],{"class":86,"line":198},9,[84,200,201],{"class":97},"  throw",[84,203,204],{"class":101}," err;\n",[84,206,208],{"class":86,"line":207},10,[84,209,210],{"class":101},"}\n",[84,212,214,216,218,220,222,225],{"class":86,"line":213},11,[84,215,183],{"class":97},[84,217,111],{"class":101},[84,219,115],{"class":114},[84,221,189],{"class":101},[84,223,224],{"class":124},"'INSERT INTO messages ...'",[84,226,227],{"class":101},");\n",[84,229,231,233,235,237,239,242,245],{"class":86,"line":230},12,[84,232,183],{"class":97},[84,234,111],{"class":101},[84,236,115],{"class":114},[84,238,189],{"class":101},[84,240,241],{"class":124},"'INSERT INTO outbox ...'",[84,243,244],{"class":101},"); ",[84,246,247],{"class":90},"\u002F\u002F evento na mesma transação\n",[53,249,251],{"id":250},"contrato-único-e-ids","Contrato único e IDs",[39,253,254],{},"O contrato entre TypeScript e Go é Protobuf gerado, com checagem na CI. Os IDs são Snowflake, com lease do node-id no Redis. As tabelas de mensagens são particionadas no Postgres.",[53,256,258],{"id":257},"voz-tela-e-desktop","Voz, tela e desktop",[39,260,261],{},"Voz e tela usam LiveKit, um SFU self-hosted. O app desktop é Electron, com pipeline de release multiplataforma. Os recursos de mídia avançada, como supressão de ruído e múltiplas lives, são de um colaborador do projeto.",[53,263,265],{"id":264},"qualidade","Qualidade",[39,267,268,269,273],{},"Gates com semgrep, gitleaks, actions fixadas por SHA e ",[270,271,272],"termo",{"id":272},"mutation testing",". O outbox tem teste com injeção de falha: a API \"morre\" depois do commit e o teste confere que o reconciliador entrega o evento.",[34,275,277],{"id":276},"decisões-e-trade-offs","Decisões e trade-offs",[39,279,280,284,285,288],{},[281,282,283],"strong",{},"NATS com JetStream em vez de Kafka, RabbitMQ ou Redis Pub\u002FSub."," Os subjects hierárquicos com curinga, como ",[81,286,287],{},"guild.*.channel.*.msg",", casam com o roteamento por servidor e canal. Kafka pesa para tópicos tão granulares. Redis Pub\u002FSub não persiste nada. O custo: hoje o gateway assina o modo simples do NATS e ninguém consome o stream persistido, que só acumula mensagens. É preparação para consumidores futuros, e precisa de limite de retenção.",[39,290,291,294],{},[281,292,293],{},"Outbox transacional em vez de publicar e torcer."," Garante que nenhum evento se perde. O custo é aceitar entrega pelo menos uma vez, com possível duplicata. O consumidor precisa tolerar isso.",[39,296,297,300],{},[281,298,299],{},"Postgres particionado em vez de Scylla."," O modelo de dados segue o formato do Scylla, com chave por canal e janela de tempo, mas roda em Postgres. Evita custo operacional prematuro e deixa o caminho de migração aberto. O custo: o schema é criado em código, no boot, sem migrations versionadas.",[39,302,303,306],{},[281,304,305],{},"Payload opaco no gateway."," O gateway numera os eventos sem decodificar o conteúdo. Fica mais barato por mensagem e eventos novos não exigem mudar o gateway. O custo é perder visibilidade do conteúdo naquela camada.",[34,308,310],{"id":309},"resultado","Resultado",[39,312,313],{},"O Blippy roda de ponta a ponta: servidores, canais de texto e voz, anexos, compartilhamento de tela e app desktop. Sou o autor principal, com cerca de 94% dos commits. O restante é, em sua maior parte, de um colaborador, nos recursos de mídia.",[39,315,316],{},"Teve incidente real: o LiveKit chegou a anunciar a porta ICE errada, e corrigi.",[39,318,319],{},"Não há números de usuários, e não vou inventá-los. O valor aqui está no desenho e nas garantias que consegui testar.",[34,321,323],{"id":322},"o-que-eu-faria-diferente","O que eu faria diferente",[39,325,326],{},"Usaria migrations versionadas desde o começo. Schema criado no boot não altera nada e ainda pode colidir quando várias réplicas sobem juntas.",[39,328,329,330,333],{},"Colocaria ",[81,331,332],{},"FOR UPDATE SKIP LOCKED"," no reconciliador do outbox. Com mais de uma réplica da API, todas varreriam as mesmas linhas e publicariam duplicado.",[39,335,336],{},"Resolveria o resume de sessão fora da memória do processo. Hoje, depois de um deploy, o cliente refaz a sessão do zero.",[39,338,339],{},"Trocaria o mutex único do gateway por mapas particionados e definiria retenção para o stream do JetStream e para a tabela de nonces.",[341,342,343],"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 .s-HuK, html code.shiki .s-HuK{--shiki-default:#032F62;--shiki-dark:#96D0FF}html pre.shiki code .sviXB, html code.shiki .sviXB{--shiki-default:#005CC5;--shiki-dark:#6CB6FF}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":79,"searchDepth":94,"depth":105,"links":345},[346,347,354,355,356],{"id":36,"depth":94,"text":37},{"id":50,"depth":94,"text":51,"children":348},[349,350,351,352,353],{"id":55,"depth":105,"text":56},{"id":65,"depth":105,"text":66},{"id":250,"depth":105,"text":251},{"id":257,"depth":105,"text":258},{"id":264,"depth":105,"text":265},{"id":276,"depth":94,"text":277},{"id":309,"depth":94,"text":310},{"id":322,"depth":94,"text":323},"oklch(0.78 0.12 210)","Plataforma de comunidades em tempo real, estilo Discord: API NestJS, gateway em Go, NATS JetStream, outbox transacional e voz com LiveKit.","Mensagens em tempo real sem perder nem duplicar","md",null,{},true,[365,368],{"valor":366,"rotulo":367},"3","nós no cluster NATS JetStream",{"valor":369,"rotulo":370},"~94%","dos commits, como autor principal","Autor principal (arquitetura e implementação)","2026 · em desenvolvimento","Plataforma de comunidades com servidores, canais de texto e voz, anexos e tela. Sou o autor principal; o projeto tem um colaborador nos recursos de mídia avançada.",{"title":25,"description":358},[376,377,378,379,380,381,382,383,384],"NestJS","PostgreSQL","Redis","Go","NATS JetStream","Protobuf","LiveKit","Vue 3","Electron","projetos\u002Fblippy","F73k892LR5sWNm4mU9lfKAz37AtBfUAYmAu09SdyOdA",[388,389,390,391,392,393],{"path":6,"title":7,"ordem":87},{"path":10,"title":11,"ordem":94},{"path":14,"title":15,"ordem":105},{"path":17,"title":18,"ordem":121},{"path":21,"title":22,"ordem":128},{"path":24,"title":25,"ordem":137},1791915291204]