Hora de construir, não só de ler. Neste lab você escreve um NoteStore minúsculo do zero — um store limitado, persistido em arquivo, com um FsPort injetado, validado na borda com Zod, que devolve Result<T, Error> e nunca lança. É o mesmo esqueleto sobre o qual o MemoryStore e o SkillStore de produção foram construídos (lições 5, 7, 12), reduzido à menor coisa que ainda merece o padrão. Ao fim, você terá escrito cada camada que o motor exige de um subsistema — e um exercício "sua vez" o estende. Fonte: packages/hermes/src/memory/memory-store.ts + packages/etl/src/fs-port.ts.
Os tipos deste lab são reais: FsPort vem de @alembic/etl (fs-port.ts) e Result/ok/err/tryCatchAsync vêm de @alembic/contracts. Cada propriedade que construímos espelha uma linha do MemoryStore de produção — o lab é um destilado do código que já roda no monorepo, não invenção.
FsPort entra pelo construtor — e como isso deixa o teste rodar em memória, sem disco.tryCatchAsync transforma uma exceção do node:fs em um valor err, honrando a promessa Result<…>.pop() de rollback mantém memória e disco coerentes.remove(needle) fail-closed na ambiguidade (a regra do locateUnique real).Result nunca lança (ADR-0009) e que um port é uma interface injetada (lição 5). Se não, recapitulamos aqui.Vamos construir uma coisa pequena e honesta: um caderno de notas que vive num arquivo. Você adiciona uma nota, ela é guardada; abre de novo, ela está lá. Simples — mas vamos construí-la do jeito que o motor exige toda peça de software ser construída. As "Regras para mudanças seguras" do CLAUDE.md dizem que um subsistema deve ser mínimo, testável e fail-closed. Em concreto, nosso NoteStore tem as quatro propriedades de qualquer subsistema já entregue.
FsPort no construtor — sem import 'node:fs'. Testável em memória; store-agnóstico (invariante 2).
Zod safeParse em toda entrada. Input não-confiável não pode corromper o estado.
devolve Result<T, Error>; embrulha o IO em tryCatchAsync. A falha é um valor (ADR-0009).
um teto de entradas, forçado antes da escrita. Sem crescimento ilimitado — espelha o budget de chars da memória.
| Propriedade | Como obtemos | Por quê |
|---|---|---|
| IO injetado | FsPort no construtor — sem import 'node:fs' | Testável em memória; store-agnóstico (invariante 2) |
| Validado na borda | Zod safeParse em toda entrada | Input não-confiável não corrompe o estado |
| Nunca lança | devolve Result<T, Error>; embrulha IO em tryCatchAsync | A falha é um valor (ADR-0009) |
| Limitado | um teto de entradas, forçado antes da escrita | Sem crescimento ilimitado — espelha o budget de chars da memória |
CLAUDE.md — "Regras para mudanças seguras" (mínimo, testável, Result em vez de exceções).
Das quatro propriedades, qual é a que torna o store testável sem tocar um disco real?
FsPort — e não importa node:fs —, o teste passa um fake feito de um Map em memória. O mesmo código que rodaria contra o disco roda contra o fake; a classe não sabe a diferença. É a invariante 2 pagando dividendos direto.O schema é o contrato de o que uma nota válida é. Validamos na borda porque, em produção, esta entrada pode vir de um modelo ou de uma chamada de rede — ela é não-confiável até ser parseada. Isso espelha o memoryActionSchema do store de memória e o reviewProposalSchema do loop de aprendizado.
// notes/schema.ts
// Uma nota é uma string não-vazia, com trim, abaixo de 280 chars. import { z } from 'zod'; export const noteSchema = z.string().trim().min(1).max(280); export type Note = z.infer<typeof noteSchema>;
O pipeline inteiro do add(): validar, limitar, mutar, persistir — nessa ordem. Só o último passo toca o disco.
safeParse e não parsez.string().parse(x) lança um ZodError se o input for inválido — e lançar é exatamente o que estamos proibindo. safeParse devolve um objeto discriminado { success: true, data } ou { success: false, error }, que convertemos em ok/err sem nenhuma exceção. O store de memória faz idêntico: parseia a ação na borda e devolve err num input malformado, nunca um throw.
parse) lança e quebra a invariante; o de baixo (safeParse) devolve um valor que vira err.packages/contracts — schemas Zod parseados com safeParse na borda de cada subsistema.
safeParse. Depois da borda, o dado é Note (tipado e válido) e o resto do código pode confiar nele.
Uma operação que muta o estado devolve um pequeno resultado terminal que reflete o estado vivo — exatamente como o MemoryOpOutcome do store de memória. Nomear o payload de sucesso (em vez de só devolver void) é o que deixa quem chamou observar o que aconteceu sem reler o store inteiro.
// notes/note-store.ts (topo)
import { ok, err, tryCatchAsync, type Result } from '@alembic/contracts'; import type { FsPort } from '@alembic/etl'; import { noteSchema } from './schema.js'; export const DEFAULT_MAX_NOTES = 50; const FILENAME = 'NOTES.md'; const DELIMITER = '\n---\n'; export interface NoteOutcome { readonly message: string; readonly count: number; // contagem viva após a escrita readonly max: number; }
void? Se add() devolvesse void, quem chamou teria que reler o store para saber se a nota entrou, se já existia, ou quantas notas há agora. O NoteOutcome carrega isso de volta no próprio valor de sucesso — { message, count, max } — então a chamada é auto-explicativa. O MemoryStore devolve o mesmo formato de outcome em cada mutação.void obriga uma releitura; à direita, o outcome nomeado entrega o estado vivo já no retorno.O store persiste as entradas como um único arquivo de texto, com \n---\n entre elas — o mesmo separador de blocos do Markdown. Ler de volta é split(DELIMITER); escrever é entries.join(DELIMITER). Simples, legível por humano, e sem dependência de formato binário. O store de memória usa a mesma estratégia de arquivo de texto append-friendly.
packages/hermes/src/memory/memory-store.ts — formato de arquivo de texto, delimitado.
fs diretoO construtor recebe o FsPort e um diretório-base. Esse é o truque inteiro. A classe nunca importa node:fs; ela só conhece o formato de um filesystem (readText, writeFileAtomic, stat, joinPath, ensureDir). Nos testes você passa um fake feito de Map; em produção você passa createNodeFsPort() — e a classe não consegue distinguir, que é justamente o ponto.
export class NoteStore { private entries: string[] = []; constructor( private readonly fs: FsPort, // ← injetado. sem `import fs`. private readonly baseDir: string, private readonly max: number = DEFAULT_MAX_NOTES, ) {} private path(): string { return this.fs.joinPath(this.baseDir, FILENAME); }
Uma porta, dois fornecedores. A classe conhece só o shape de um filesystem — por isso o teste roda em memória.
FsPort expõeA interface FsPort em packages/etl/src/fs-port.ts declara os métodos que um subsistema precisa: stat (metadados ou undefined se não existe), readText, writeFileAtomic (escreve num temp e renomeia, para nunca deixar arquivo pela metade), ensureDir, joinPath e os de stream (appendLine, openLineStream, readDir). O NoteStore usa só um subconjunto — e é por isso que o fake de teste pode stubar o resto.
packages/etl/src/fs-port.ts — interface FsPort: stat/readText/writeFileAtomic/ensureDir/joinPath.
load(): ler pelo port, fail-closedToda chamada de IO é embrulhada em tryCatchAsync, que transforma uma exceção lançada (ENOENT, permissão, erro de decode) num valor err. Um arquivo ausente não é um erro — é um store vazio —, então checamos stat primeiro, exatamente como o MemoryStore faz ao ler suas entradas.
async load(): Promise<Result<void, Error>> { const ensured = await tryCatchAsync(() => this.fs.ensureDir(this.baseDir)); if (!ensured.ok) return ensured; // short-circuit em falha de IO const meta = await tryCatchAsync(() => this.fs.stat(this.path())); if (!meta.ok) return meta; if (!meta.value) { this.entries = []; return ok(undefined); } // sem arquivo ⇒ vazio const read = await tryCatchAsync(() => this.fs.readText(this.path())); if (!read.ok) return read; this.entries = read.value.split(DELIMITER).map((s) => s.trim()).filter(Boolean); return ok(undefined); }
Na primeira execução, o arquivo NOTES.md ainda não existe. load() devolve err?
ok com um store vazio. O stat retorna undefined para um arquivo ausente; checamos isso e tratamos como "zero notas", não como falha. Um arquivo que não existe ainda é um estado normal. err fica reservado para falhas de verdade (permissão negada, disco corrompido) — que o tryCatchAsync captura.err do tryCatchAsync.tryCatchAsync faz, exatamentetryCatchAsync(fn) roda fn() dentro de um try/catch e devolve ok(valor) em sucesso ou err(e) se algo for lançado. É a ponte do mundo que lança (o fs do Node) para o mundo de valores (Result). Sem ela, um erro de disco escaparia como exceção e quebraria a invariante "nunca lança" (ADR-0009). O store de memória embrulha cada operação de arquivo do mesmo jeito.
packages/contracts/src/result.ts — tryCatchAsync, ok, err.
add(): validar, limitar, mutar, persistirEste é o coração. Quatro guardas, em ordem, cada uma devolvendo err em falha — e só o último passo toca o disco. Repare que o cap é checado antes da escrita, então um add acima do limite nunca muta pela metade: estado e disco ficam coerentes.
async add(input: unknown): Promise<Result<NoteOutcome, Error>> { const parsed = noteSchema.safeParse(input); // ① Zod de borda if (!parsed.success) return err(new Error(`Nota inválida: ${parsed.error.message}`)); const note = parsed.data; if (this.entries.includes(note)) { // ② dedup (sucesso no-op) return ok({ message: 'exists', count: this.entries.length, max: this.max }); } if (this.entries.length >= this.max) { // ③ limita ANTES de escrever return err(new Error(`Na capacidade (${this.max}). Remova uma nota antes.`)); } this.entries.push(note); // ④ muta, depois persiste const saved = await tryCatchAsync(() => this.fs.writeFileAtomic(this.path(), this.entries.join(DELIMITER))); if (!saved.ok) { this.entries.pop(); return saved; } // rollback em falha de IO return ok({ message: 'added', count: this.entries.length, max: this.max }); } list(): readonly string[] { return this.entries; } }
MemoryStore de produção dá push e depois salva; se o save falha, o estado em memória ficaria à frente do disco. Neste lab fazemos pop() em falha, para que memória e disco nunca divirjam — um pequeno endurecimento que você pode levar de volta. De qualquer forma, o método público ainda devolve um Result e nunca lança.Clique em cada camada para ver o que ela faz — e por que está nessa posição:
add('comprar pão') → safeParse passa, não é dup, length (0) < max (2) → push, writeFileAtomic, ok({message:'added', count:1}).add('comprar pão') de novo → safeParse passa, mas includes é verdadeiro → ok({message:'exists', count:1}). Nenhuma escrita, nenhum erro: dedup é sucesso no-op.add('ligar pro dentista') → passa, não é dup, length (1) < 2 → entra. Agora count=2, no limite.add('regar plantas') — length (2) >= max (2). O que add() devolve, e o array em memória muda? (Resposta: devolve err("Na capacidade (2)…") no guard ③; o push nunca acontece, então o array continua com 2 — estado e disco coerentes.)push?err e o array fica intacto — estado e disco coerentes. O MemoryStore faz idêntico: monta o array de teste e checa o tamanho contra o limite antes de commitar.tryCatchAsync dá à assinatura do método público?err, então add() pode honestamente prometer Result<…> e nunca lançar. É a ponte do mundo que lança para o mundo de valores.ok, não err?ok({message:'exists'}). Reservar err só para falhas reais mantém o contrato honesto.O MemoryStore de produção monta o array candidato, junta tudo numa string e compara o tamanho contra o budget de caracteres antes de commitar a escrita — exatamente o mesmo princípio do nosso guard ③, só que medindo chars em vez de contar entradas. A ordem é o invariante: nenhuma mutação acontece até que o limite seja respeitado, então um overflow nunca produz uma escrita parcial.
packages/hermes/src/memory/memory-store.ts — add: bound-before-write.
FsPort fakeComo o IO é injetado, o teste não precisa de diretório temporário nem de disco real — um fake feito de Map satisfaz o port. Esta é a invariante 2 pagando direto: o mesmo código é exercitado em memória.
// notes/note-store.test.ts — o fake tem ~10 linhas const makeFakeFs = (): FsPort => { const store = new Map<string, string>(); return { joinPath: (...p) => p.join('/'), stat: async (p) => (store.has(p) ? { size: 0, mtimeMs: 0, isFile: true, isDirectory: false } : undefined), readText: async (p) => store.get(p) ?? '', writeFileAtomic: async (p, c) => { store.set(p, c); }, ensureDir: async () => {}, // readDir / appendLine / openLineStream: não usados aqui — stub conforme precisar } as FsPort; }; it('rejeita uma nota vazia e nunca lança', async () => { const s = new NoteStore(makeFakeFs(), '/x'); expect((await s.add(' ')).ok).toBe(false); // err, não um throw });
O fake não usa fs, não cria pasta, não limpa nada depois. Um Map é o "disco". O teste roda em microssegundos, é determinístico (nunca flaky por estado de filesystem) e custa $0. Trocar o fake por createNodeFsPort() seria a única diferença num teste de integração — o NoteStore não muda uma linha.
as FsPort é aceitável aquiO fake só implementa os métodos que o NoteStore chama; o as FsPort diz ao TypeScript "confie, o resto não é exercitado neste teste". É um atalho honesto dentro de um teste — se um caminho novo chamar appendLine, o teste quebra com um TypeError claro, e você stuba o método. Em código de produção você usaria a implementação real completa (createNodeFsPort), nunca um cast parcial.
packages/etl/src/fs-port.ts — createNodeFsPort (a implementação real e completa).
Você escreveu cada camada que o motor exige. Agora estenda o store com uma operação nova — e cuide para que ela herde as mesmas quatro propriedades.
remove(needle)remove(needle: string): Promise<Result<NoteOutcome, Error>>. Ele apaga a única entrada que contém needle como substring. Requisitos tirados direto do MemoryStore.remove + locateUnique reais.err("Nenhuma nota casou com …").err("Várias notas casaram … Seja mais específico.") — a ambiguidade é fail-closed, não "primeiro ganha".writeFileAtomic, devolva ok({message:'removed', …}). Nunca lance; todo caminho devolve um Result.Map: semeie duas notas, remova uma por uma substring única, afirme que list() tem uma só; semeie duas notas que compartilham uma substring e afirme que o remove ambíguo devolve err. É o mesmo formato do teste real "Várias entradas casaram" do memory-store.test.ts.renderSnapshot() que captura as entradas uma vez (congelado) e as devolve inalteradas mesmo após add() posteriores — o truque do cache de prefixo de prompt da Lição 7. O store de produção fixa o snapshot no load() e nunca o muta no meio da sessão.Um slide por ideia. Use as setas do teclado (← →) ou os botões para navegar.
FsPort entra pelo construtor; a classe nunca importa node:fs.noteSchema.safeParse — safeParse, não parse.tryCatchAsync: exceção vira valor err.pop() de rollback mantém memória e disco coerentes.Map prova tudo em memória — $0, determinístico, sem disco.NoteStore recebe um FsPort no construtor em vez de chamar fs.writeFile direto?Map exercita exatamente o mesmo caminho que o disco real — sem pastas temp, sem flakiness.add() checa o limite de capacidade antes de dar push e persistir. Por que a ordem importa?MemoryStore de produção faz o mesmo — monta o array de teste e checa o tamanho contra o limite antes de commitar.tryCatchAsync. O que isso dá à assinatura do método público?tryCatchAsync de @alembic/contracts é a ponte do mundo que lança (o fs do Node) para o mundo de valores (Result). Sem ela, um erro de disco escaparia como exceção e quebraria a invariante "nunca lança" (ADR-0009).