Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,17 @@ published. The blog also feeds `/blog/rss.xml` and `/llms.txt`.
Each post is translated into Spanish, Japanese and Brazilian Portuguese when it is published:
`src/content/translations/<locale>/<slug>.md` holds the title, the description and the body in
that language, and takes the date, the kind and the draft flag from the English post, which is
the original. It is served at `/<locale>/blog/<slug>/`, with its own card, `hreflang` links to
every version and a note linking the original. The interface strings and the languages live in
the original. It is served at `/<locale>/blog/<slug>/`, with its own card and `hreflang` links to
every version, `x-default` being the original (set in `src/layouts/base.astro`). The interface strings and the languages live in
`src/i18n.ts`. A translation whose original does not exist fails the build.

No page redirects by language: search engines pick the version through `hreflang`, and each link
that is shared points at its language. The original lists its translations in one line under the
post; a translation says where it comes from above it. Spanish is reviewed by the author and says
*translated*; Japanese and Portuguese are machine translations, say so, and link to a new issue to
report a mistake. [`GLOSSARY.md`](src/content/translations/GLOSSARY.md) fixes how each language
renders Bounda's vocabulary, and how a machine translation is checked before it is published.

## Keeping the two sites coherent

[`DESIGN.md`](DESIGN.md) is the design system both sites follow: palette, type, logo, the ledger
Expand Down
78 changes: 32 additions & 46 deletions src/components/blog/post-page.astro
Original file line number Diff line number Diff line change
Expand Up @@ -72,31 +72,20 @@ const structuredData = {
<time datetime={date.toISOString()}>{formatDate(locale, date)}</time>
<span>{text.minutes(minutes)}</span>
</p>
{
locales.length > 1 && (
<nav class="languages" aria-label={text.languages}>
<span class="label">{text.languages}</span>
<ul>
{locales.map((other) => (
<li>
<a
href={postPath(other, slug)}
hreflang={LANGUAGES[other].tag}
lang={LANGUAGES[other].tag}
aria-current={other === locale ? "page" : undefined}
>
{LANGUAGES[other].name}
</a>
</li>
))}
</ul>
</nav>
)
}
{
locale !== "en" && (
<p class="translated">
{text.translated} <a href={postPath("en", slug)} hreflang="en">{text.original}</a>
{
text.report && (
<>
{" · "}
<a href={`https://github.com/bounda-dev/bounda-website/issues/new?title=${encodeURIComponent(`Translation (${locale}): ${slug}`)}`}>
{text.report}
</a>
</>
)
}
</p>
)
}
Expand All @@ -105,6 +94,23 @@ const structuredData = {
<div class="prose">
<Content />
</div>
{
locale === "en" && locales.length > 1 && (
<p class="also-in">
{text.alsoIn}{" "}
{locales
.filter((other) => other !== "en")
.map((other, index) => (
<>
{index > 0 && " · "}
<a href={postPath(other, slug)} hreflang={LANGUAGES[other].tag} lang={LANGUAGES[other].tag}>
{LANGUAGES[other].name}
</a>
</>
))}
</p>
)
}
<svg class="close" viewBox="0 0 35 65" aria-hidden="true"><path d="M25 0h10v65H25v-8H0v-6h25z"></path></svg>
</article>

Expand Down Expand Up @@ -215,40 +221,20 @@ const structuredData = {
text-decoration: underline;
}

.languages {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
align-items: baseline;
margin-top: 14px;
}

.languages ul {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
margin: 0;
padding: 0;
list-style: none;
.also-in {
margin: 40px 0 0;
font-size: 0.875rem;
color: var(--muted);
}

.languages a {
.also-in a {
color: var(--muted);
text-decoration: none;
}

.languages a:hover,
.languages a[aria-current="page"] {
.also-in a:hover {
color: var(--text);
}

.languages a[aria-current="page"] {
text-decoration: underline;
text-decoration-color: var(--accent);
text-underline-offset: 4px;
}

.translated {
margin: 14px 0 0;
font-size: 0.875rem;
Expand Down
43 changes: 43 additions & 0 deletions src/content/translations/GLOSSARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Translation glossary

How the blog's translations render Bounda's vocabulary, so every post says the same thing the same
way. Taken from the posts already published; a new term is added here the first time a post uses
it. Code, identifiers, file names and error codes are never translated.

Spanish is reviewed by the author. Japanese and Brazilian Portuguese are machine translations,
checked before publishing by translating them back to English and comparing them with the
original, and labelled as such on the page, with a link to report a mistake.

| English | es | ja | pt-BR |
|---|---|---|---|
| event sourcing | event sourcing | イベントソーシング | event sourcing |
| event store | event store | イベントストア | event store |
| event | evento | イベント | evento |
| command | comando | コマンド | comando |
| query | query | クエリ | query |
| aggregate | agregado | 集約 | agregado |
| state | estado | 状態 | estado |
| stream | stream | ストリーム | stream |
| fold (noun / verb) | fold / plegar | 畳み込み / 畳み込む | fold / fazer o fold |
| read model | read model | リードモデル | read model |
| projection | proyección | プロジェクション | projeção |
| policy | policy | ポリシー | policy |
| process | process | プロセス | process |
| handler | handler | ハンドラー | handler |
| payload | payload | ペイロード | payload |
| rejection | rechazo | 拒否 | rejeição |
| snapshot | snapshot | スナップショット | snapshot |
| idempotency key | clave de idempotencia | 冪等性キー | chave de idempotência |
| dead letter | dead letter | デッドレター | dead letter |
| port | puerto | ポート | porta |
| crypto-shredding | crypto-shredding | クリプトシュレッディング | crypto-shredding |
| in the clear | en claro | 平文で | sem criptografia |

Notes:

- **Do not add** what the original does not say: no local laws, examples or asides. A translation
that needs context for its readers gets it from the author, in the original.
- Link texts that point to English pages stay in English, followed by "(in English)" in the
language of the page when that helps: (en inglés), (英語), (em inglês).
- pt-BR uses Brazilian conventions: “ ” quotes, "você", the subjunctive after *sempre que*.
- A scene of the demo keeps its English name, with a short gloss in parentheses.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: 決して削除しないログで「忘れられる権利」を実現する
description: イベントソーシングはすべての事実を永遠に残し、GDPR は本人に消去を求める権利を与えます。クリプトシュレッディングがその両方をどう両立させるか、何が漏れるか、そしてイベントストアが取る三つのアプローチ。
description: イベントソーシングはすべての事実を永遠に残し、GDPR は本人に消去を求める権利を与えます。クリプトシュレッディングがその両方をどう両立させるか、何を取りこぼすか、そしてイベントストアが取る三つのアプローチ。
tryIt:
label: デモで購入者を忘れる
---
Expand Down
8 changes: 4 additions & 4 deletions src/content/translations/pt-br/a-command-is-a-file.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: Um comando é um arquivo
description: Uma feature inteira com event sourcing em dois arquivos pequenos, e as cinco coisas que você deixa de escrever para chegar lá. De onde vem esse formato, e quanto ele custa.
description: Uma feature inteira com event sourcing em dois arquivos pequenos, e as cinco coisas que você deixa de escrever para chegar lá. De onde vem esse formato, e o que ele custa.
tryIt:
label: Bata no limite de lugares na demo ao vivo
label: Chegue ao limite de lugares na demo ao vivo
---

Event sourcing tem fama de cerimônia. Antes de guardar o primeiro fato, você já escreveu uma classe
Expand Down Expand Up @@ -168,10 +168,10 @@ projeção trata.
Os frameworks de event sourcing já consolidados modelam o agregado como uma classe: uma classe
anotada no
[Axon](https://docs.axoniq.io/axon-framework-reference/5.4/commands/entities/event-sourced-entity/),
uma classe simples dobrada por métodos `Apply` no
uma classe simples que faz o fold com métodos `Apply` no
[Marten](https://martendb.io/events/projections/aggregate-projections.html). É um design sólido,
comprovado ao longo de anos, e é o que permite ao Axon rodar o mesmo handler numa frota de
serviços. Para um app TypeScript, porém, funções em arquivos dão três coisas que uma classe não dá:
serviços. Para um único app TypeScript, porém, funções em arquivos dão três coisas que uma classe não dá:

- **Um estado que não pode se desviar dos eventos.** Ele é o que os eventos devolvem, então não há
uma segunda declaração para manter em sincronia à mão.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: O direito ao esquecimento, num log que nunca apaga
description: Event sourcing guarda cada fato para sempre; a GDPR (e a LGPD) deixam uma pessoa pedir que seus dados sejam apagados. Como o crypto-shredding torna as duas coisas verdade, o que escapa e os três jeitos como os event stores o abordam.
description: Event sourcing guarda cada fato para sempre; a GDPR deixa uma pessoa pedir que seus dados sejam apagados. Como o crypto-shredding torna as duas coisas verdade, o que escapa e os três jeitos como os event stores o abordam.
tryIt:
label: Esqueça um comprador na demo
---
Expand All @@ -9,8 +9,8 @@ Cedo ou tarde chega um e-mail: *por favor, apaguem todos os dados que vocês tê
seu sistema guarda o estado em tabelas, é uma tarde de comandos `DELETE` e uma conferida nos
backups. Se ele usa event sourcing, é uma crise de identidade. A graça do event store é que nada
do que entra nele é alterado ou removido. É isso que torna uma reconstrução fiel, uma trilha de
auditoria completa e um bug reproduzível. E agora a lei, o artigo 17 da GDPR (no Brasil, o artigo 18 da
LGPD prevê o mesmo direito), afirma que a parte de uma pessoa precisa sumir.
auditoria completa e um bug reproduzível. E agora a lei, o artigo 17 da GDPR, afirma que a parte
de uma pessoa precisa sumir.

As equipes costumam recorrer a uma de duas respostas, e as duas estão erradas. A primeira é
reescrever a história: encontrar os eventos da pessoa e editá-los no lugar. Isso quebra todas as
Expand All @@ -25,13 +25,13 @@ Existe uma terceira resposta, e ela começa por uma pergunta que a maioria pula.
Um evento registra uma decisão. `OrderPlaced` é o fato de que o cliente `c-42` pediu dois itens.
O nome, o endereço e o telefone dele não fazem parte desse fato: são detalhes de que a
transportadora precisa e que a nota fiscal imprime. Um command handler que os copia para o evento
porque «estavam no comando» fez do evento o dono de dados que pertencem a outra parte do sistema.
porque “estavam no comando” fez do evento o dono de dados que pertencem a outra parte do sistema.

Então a primeira ferramenta não é criptografia, é uma fronteira. O evento leva o id, os detalhes
ficam atrás de uma porta cuja implementação sabe apagá-los, e apagar a pessoa é um `DELETE` nesse
armazenamento. Mathias Verraes chama isso de
[forgettable payloads](https://verraes.net/2019/05/eventsourcing-patterns-forgettable-payloads).
Basta sempre que o dado não é necessário para explicar o passado, e ainda o mantém fora de todas
Basta sempre que o dado não for necessário para explicar o passado, e ainda o mantém fora de todas
as cópias do log.

Mas às vezes o dado *é* a decisão. `AddressChanged` registra o endereço para onde alguém se mudou.
Expand All @@ -55,8 +55,8 @@ Este é o stream de um cliente, antes e depois:
| `001 204` | `PersonalDataForgotten` | | |

Nada acima da última linha foi editado. As posições são as mesmas, os tipos de evento são os
mesmos, o pedido continua sendo dobrado e os read models continuam sendo reconstruídos. Só a chave
sumiu.
mesmos, o fold do pedido continua funcionando e os read models continuam sendo reconstruídos. Só
a chave sumiu.

No [Bounda](https://docs.bounda.dev/getting-started/), marcar um campo é uma palavra no schema do
evento:
Expand All @@ -81,7 +81,7 @@ seja um fold, uma projeção ou uma policy, recebe `string | null`.
Esse `null` não é questão de estilo. É o que faz o compilador levar você a cada lugar que supunha
que o e-mail sempre estaria lá: a projeção que o copiava para uma coluna `NOT NULL`, o fold que o
passava para minúsculas, a policy que envia o e-mail de boas-vindas. Os eventos de uma pessoa
esquecida continuam sendo dobrados, projetados e reproduzidos. Eles carregam um buraco onde o dado
esquecida continuam passando pelo fold, pela projeção e pelo replay. Eles carregam um buraco onde o dado
estava, e o seu código é obrigado a ter decidido o que esse buraco significa antes de chegar o
primeiro pedido de exclusão, não depois.

Expand All @@ -95,7 +95,7 @@ Apagar a chave torna os eventos ilegíveis. Não faz nada com tudo o que foi con
deles, e num sistema com event sourcing muita coisa é construída a partir deles:

- **Read models.** O diretório de clientes projetou o e-mail numa linha.
- **Snapshots.** O estado dobrado do agregado, guardado em claro para que um stream longo carregue
- **Snapshots.** O estado do agregado após o fold, guardado sem criptografia para que um stream longo carregue
rápido.
- **Respostas lembradas.** Um comando enviado com uma chave de idempotência guarda o que respondeu,
e a mensagem de uma rejeição pode citar o estado em que se baseou.
Expand Down Expand Up @@ -157,8 +157,7 @@ dados.

- **Os backups guardam as chaves.** As chaves ficam ao lado dos eventos, então um backup do banco
é uma cópia dos dados enquanto for mantido.
- **Os logs guardam o que você imprimiu.** Se um handler registrou um payload, o log o tem em
claro.
- **Os logs guardam o que você imprimiu.** Se um handler registrou um payload, o log o tem em texto puro.
- **O que saiu do sistema continua lá fora,** a menos que uma policy vá apagá-lo.
- **Campos não marcados são texto puro.** A marca é por campo e por evento: esqueça de marcar um e
ele fica legível para sempre.
Expand Down
16 changes: 14 additions & 2 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export interface Language {
readonly languages: string;
readonly translated: string;
readonly original: string;
/** Only for machine translations, which a native reader is asked to correct. */
readonly report: string;
/** Under the original, before the translations it has. Only English uses it. */
readonly alsoIn: string;
readonly newsletter: Newsletter;
}

Expand Down Expand Up @@ -55,6 +59,8 @@ export const LANGUAGES: Record<Locale, Language> = {
languages: "Read in",
translated: "",
original: "",
report: "",
alsoIn: "Also in",
newsletter: {
heading: "Newsletter",
lead: "Each new post by email. Nothing else.",
Expand All @@ -79,6 +85,8 @@ export const LANGUAGES: Record<Locale, Language> = {
languages: "Leer en",
translated: "Traducido del original en inglés.",
original: "Leer el original",
report: "",
alsoIn: "",
newsletter: {
heading: "Newsletter",
lead: "Cada post nuevo por email. Nada más.",
Expand All @@ -101,8 +109,10 @@ export const LANGUAGES: Record<Locale, Language> = {
more: "ほかの記事",
seeItRun: "ライブデモで動きを見る",
languages: "言語",
translated: "英語の原文からの翻訳です。",
translated: "英語の原文からの機械翻訳です。",
original: "原文を読む",
report: "誤りを報告する",
alsoIn: "",
newsletter: {
heading: "ニュースレター",
lead: "新しい記事をメールで。それ以外は送りません。",
Expand All @@ -125,8 +135,10 @@ export const LANGUAGES: Record<Locale, Language> = {
more: "Mais posts",
seeItRun: "Veja funcionando na demo",
languages: "Ler em",
translated: "Traduzido do original em inglês.",
translated: "Tradução automática do original em inglês.",
original: "Ler o original",
report: "Avisar de um erro",
alsoIn: "",
newsletter: {
heading: "Newsletter",
lead: "Cada post novo por email. Nada mais.",
Expand Down
Loading