Lab: construa um subsistema
Hora de construir, não só ler. Neste lab você escreve um pequeno NoteStore do zero — um store limitado, persistido em arquivo, com um FsPort injetado, validado na fronteira com Zod, que retorna Result<T, Error> e nunca lança exceção. É o mesmo esqueleto sobre o qual o MemoryStore e o SkillStore entregues foram construídos (lições 5, 7, 12), reduzido à menor coisa que ainda justifica o padrão. No final você terá escrito todas as camadas que o motor exige de um subsistema — e um exercício "sua vez" o estende.
packages/etl/src/fs-port.ts (a interface FsPort) + packages/hermes/src/memory/memory-store.ts (o store entregue) + packages/contracts/src/result.ts (Result/ok/err/tryCatchAsync) + CLAUDE.md ("Rules for safe changes"). Por que importa pra missão: todo subsistema do Alembic nasce desta mesma forma — quem domina o esqueleto domina o motor.
FsPort vem de @alembic/etl (packages/etl/src/fs-port.ts:61) e Result/ok/err vêm de @alembic/contracts. Tudo compila contra o workspace de verdade.- Construir um subsistema com as quatro propriedades de todo store entregue: IO injetado, validado na fronteira, nunca-lança, limitado.
- Injetar um
FsPortno construtor em vez de importarnode:fs— e enxergar por que isso torna o store testável em memória. - Escrever o
add()com quatro guardas em ordem, limitando antes de gravar e fazendo rollback em falha de IO. - Provar tudo com um
FsPortfake de ~10 linhas — sem disco real, sem dir temporário.
import fs na classe01 · O alvo: um store com quatro propriedades
As "Regras para mudanças seguras" do motor (CLAUDE.md) dizem que um subsistema deve ser mínimo, testável e fail-closed. Antes de escrever uma linha, fixe o alvo: o NoteStore deve ter as quatro propriedades que todo subsistema entregue tem. Pense nelas como uma analogia simples — um cofre de recados:
| Propriedade | Como obtemos | Por quê |
|---|---|---|
| IO é injetado | FsPort no construtor — sem import 'node:fs' | Testável em memória; agnóstico de store (invariante 2) |
| Validado na fronteira | Zod safeParse em toda entrada | Entrada não confiável não pode corromper o estado |
| Nunca lança | retorna Result<T, Error>; envolve IO em tryCatchAsync | Falha é um valor (ADR-0009) |
| Limitado | um teto de entradas, imposto antes de gravar | Sem crescimento ilimitado — espelha o orçamento de chars da memória |
02 · O esqueleto de ports (a forma antes do código)
Antes dos passos, veja a forma do subsistema — quem depende de quem. O segredo está numa única seta: a classe aponta para a interface FsPort, não para o node:fs. Quem fornece o fs de verdade é quem constrói o store (produção ou teste). Esse é o esqueleto de ports-e-injeção:
NoteStore → interface FsPort. A classe nunca conhece o concreto. Trocar disco real por fake-em-memória é só trocar o argumento do construtor — o corpo da classe não muda uma vírgula.node:fs (o efeito colateral) vive fora, atrás da porta; o miolo do store fica testável e determinístico.A interface FsPort (de packages/etl/src/fs-port.ts:61) é maior do que o NoteStore precisa. Você só consome cinco dos seus métodos; o resto (readDir, appendLine, openLineStream) você nem importa. Depender de uma interface deixa isso explícito — você usa só a fatia que precisa, e o fake de teste só precisa implementar essa fatia:
E aqui está o pagamento da injeção, lado a lado: o mesmo corpo de classe recebe a porta de produção ou o fake de teste — só muda o argumento do construtor. A classe não tem um if (test), não conhece qual recebeu; é por isso que o teste exercita o caminho real:
03 · Os seis passos de construção
Construir o subsistema é um processo de seis passos, sempre na mesma ordem: do contrato de entrada até a prova. Veja o mapa antes de codar — cada caixa é um arquivo/bloco que você vai escrever na seção 04:
FsPort apoiado num Map em memória — nada toca o disco real, então não há dir temporário, nem limpeza, nem flakiness. Se você chutou "preciso de os.tmpdir() e rm -rf no final", esse é exatamente o custo que a injeção elimina. O fake completo aparece no passo 6 (seção 07) e cabe em ~10 linhas.04 · Construa, passo a passo (depois é a sua vez)
Agora escreva o subsistema, um passo de cada vez. Cada passo abaixo traz o código real (compila contra o workspace) e o porquê. No fim, um passo é seu. Recuperar o procedimento — não só ver o resultado pronto — é o que fixa o padrão.
memoryActionSchema do memory store e o reviewProposalSchema do loop de aprendizado.
// Uma nota é uma string não-vazia, trimada, com menos de 280 chars. import { z } from 'zod'; export const noteSchema = z.string().trim().min(1).max(280); export type Note = z.infer<typeof noteSchema>;
MemoryOpOutcome (memory-store.ts:71). Nomear o payload de sucesso (em vez de só retornar void) é o que permite ao chamador observar o que aconteceu sem reler o store.
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 de entradas após a gravação readonly max: number; }
fs direto. O construtor recebe o FsPort e um diretório base. Esse é o truque inteiro. A classe nunca importa node:fs; ela só conhece a forma de um filesystem (readText, writeFileAtomic, stat, joinPath, ensureDir). Nos testes você passa um fake apoiado em Map; em produção você passa createNodeFsPort() — a classe não percebe a diferença, e esse é 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); }
load(): ler pela porta, fail-closed. Toda chamada de IO é envolvida em tryCatchAsync, que converte 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 MemoryStore.readEntries faz (memory-store.ts:306).
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); }
add(): validar, limitar, mutar, persistir. Este é o coração (dissecado na seção 05). Quatro guardas, em ordem, cada uma retornando err na falha — e só o último passo toca o disco. Note que o teto é checado antes da gravação, então um add acima do limite nunca muta pela metade: estado e disco ficam consistentes.
async add(input: unknown): Promise<Result<NoteOutcome, Error>> { const parsed = noteSchema.safeParse(input); // ① Zod na fronteira if (!parsed.success) return err(new Error(`Invalid note: ${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 gravar return err(new Error(`At capacity (${this.max}). Remove a note first.`)); } 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; } }
FsPort fake. Porque o IO é injetado, o teste não precisa de disco real (código completo na seção 07). Um fake apoiado em Map satisfaz a porta, e o mesmo caminho de código roda em memória — invariante 2 rendendo direto.count(): ____ que devolve quantas notas existem — e justifique se ele precisa (ou não) de tryCatchAsync. Pense antes de revelar.
count(): number — síncrono e sem Result/tryCatchAsync. Por quê: ele só lê o array this.entries em memória, não toca o filesystem, então não há exceção de IO para converter em valor. A regra: só os caminhos que cruzam a porta (tocam fs) precisam de tryCatchAsync + Promise<Result>; leitura pura de estado é como o list() — retorno direto. Aplicar Result onde nada pode falhar só polui a assinatura.MemoryStore entregue dá push e depois salva (memory-store.ts:214-217); se o save falhar, o estado em memória ficaria à frente do disco. Neste lab fazemos pop() na 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 retorna um Result e nunca lança.Vale isolar o passo 4 num diagrama, porque ele esconde uma sutileza fail-closed: um arquivo ausente não é um erro. O load() checa stat antes de ler — se o arquivo não existe, o store é simplesmente vazio (ok), não um err. Só um IO que de fato falha (permissão, decode) vira err. Siga as três chamadas pela porta:
05 · Dentro do add(): as quatro guardas em ordem
O add() parece simples, mas cada linha tem um motivo. Pense nele como uma esteira de inspeção: a nota passa por quatro postos, em ordem, e qualquer reprovação a tira da esteira com um err — só quem passa nos quatro chega ao disco. Veja a esteira:
Quer o porquê em duas profundidades? Alterne abaixo entre a explicação simples e a técnica:
err; nenhum estado é mutado antes do posto ③ aprovar (a ordem garante consistência estado↔disco). O posto ④ embrulha o efeito colateral em tryCatchAsync (de @alembic/contracts), convertendo a exceção do fs num Result e fazendo this.entries.pop() para reverter a mutação otimista se a gravação falhar (ADR-0009).O posto ④ depende de uma peça que merece um desenho próprio: o tryCatchAsync. Ele é a ponte entre dois mundos — o mundo do node:fs, que lança exceções, e o mundo do Result, onde falha é um valor. Sem essa ponte, um ENOENT escaparia como exceção e quebraria a promessa "nunca lança" da assinatura:
E o ramo de falha do posto ④ esconde a decisão mais sutil do lab: o pop() de rollback. Veja-o como uma linha do tempo de duas trilhas — memória e disco — durante um add() cuja gravação falha. Sem o pop(), as trilhas divergem para sempre; com ele, voltam a coincidir antes do err sair:
06 · O caminho de uma chamada (fluxograma de decisão)
Agora siga uma única chamada add() através das decisões, do começo ao fim. Cada losango é uma pergunta que escolhe o caminho; só um caminho chega ao ok({message:'added'}). Repare em quantas saídas são err ou ok — e que nenhuma é uma exceção:
err (inválido, no teto, falha de IO), dois ok (no-op "exists", "added"). Nenhuma é um throw. É assim que a assinatura Promise<Result<…>> diz a verdade ao chamador.push, então o pop() desfaz a mutação otimista antes de devolver o err. Sem ele, memória e disco divergiriam — o endurecimento da seção 04.07 · Prove com um FsPort fake (passo 6)
Como o IO é injetado, o teste não precisa de diretório temporário nem disco real — um fake apoiado em Map satisfaz a porta. É o invariante 2 rendendo diretamente: 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 necessário } as FsPort; }; it('rejects an empty note and never throws', async () => { const s = new NoteStore(makeFakeFs(), '/x'); expect((await s.add(' ')).ok).toBe(false); // err, não um throw });
os.tmpdir(), sem afterEach(rm), sem corrida de disco. O fake é determinístico e roda em microssegundos — e exercita exatamente o caminho que createNodeFsPort() rodaria em produção. Quando o passo 6 é trivial assim, é sinal de que os passos 1–5 respeitaram a injeção.Uma pergunta fecha o desenho do store: quais métodos precisam de Result + tryCatchAsync e quais não? A regra é uma só pergunta — "este método cruza a porta (toca o fs)?". Se cruza, ele pode lançar e precisa da assinatura async-Result; se só lê estado em memória, retorna direto. É a justificativa da resposta "sua vez" sobre o count():
08 · NoteStore vs MemoryStore: o que é igual, o que reduzimos
O NoteStore não é um brinquedo paralelo — é o MemoryStore entregue, podado ao mínimo. Veja lado a lado o que foi preservado (o esqueleto que define um subsistema) e o que foi reduzido (recursos que o lab não precisa para ensinar o padrão):
| Aspecto | NoteStore (este lab) | MemoryStore (entregue) | Igual ou reduzido? |
|---|---|---|---|
| IO | FsPort injetado no construtor | FsPort injetado (memory-store.ts:105-112) | Igual — o esqueleto |
| Validação | noteSchema.safeParse | memoryActionSchema na fronteira | Igual — o padrão |
| Falha | Result + tryCatchAsync, nunca lança | Result + tryCatchAsync (ADR-0009) | Igual — o invariante |
| Limite | teto de entradas (DEFAULT_MAX_NOTES) | orçamento de chars, checado antes de gravar (memory-store.ts:201-212) | Igual — bound-before-write |
| Remoção | exercício "sua vez" (seção 09) | remove + locateUnique (memory-store.ts:261-276, 369-392) | Reduzido — você adiciona |
| Snapshot | desafio extra (seção 09) | this.snapshot congelado em load() (memory-store.ts:132-136) | Reduzido — desafio |
O gráfico abaixo torna a poda visível: a área verde é o esqueleto compartilhado (idêntico nos dois); a área clara são os recursos que o store entregue tem a mais — e que viram seus exercícios:
A fórmula do subsistema — calcule a superfície
Toda classe-store custa um piso fixo de linhas (o esqueleto: construtor + load + list + os imports) mais um custo por método de domínio que cruza a porta. Mexa os sliders e veja a superfície (e a barra de "quanto é esqueleto vs domínio") mudar — é uma estimativa de guardanapo para dimensionar um subsistema novo:
09 · Sua vez — estenda o store
remove(needle)Implemente remove(needle: string): Promise<Result<NoteOutcome, Error>> que apaga a única entrada contendo needle como substring. Requisitos, tirados direto do MemoryStore.remove + locateUnique entregues (memory-store.ts:261-276, 369-392):
- Zero correspondências ⇒
err("No note matched …"). - Duas ou mais correspondências distintas ⇒
err("Multiple notes matched … Be more specific.")— ambiguidade é fail-closed, não primeiro-vence. - Exatamente uma correspondência (ou N idênticas) ⇒ faça splice, persista via
writeFileAtomic, retorneok({message:'removed', …}). - Nunca lance; todo caminho retorna um
Result.
Depois teste com o fake apoiado em Map: semeie duas notas, remova uma por uma substring única, afirme que list() tem uma restante; semeie duas notas compartilhando uma substring e afirme que o remove ambíguo retorna err. É a mesma forma do teste real "Multiple entries matched" em memory-store.test.ts.
renderSnapshot() que captura as entradas uma vez (congelado) e as retorna inalteradas mesmo após chamadas posteriores de add() — o truque de cache do prefixo do prompt da Lição 7. O store entregue define this.snapshot em load() e nunca o muta no meio da sessão (memory-store.ts:132-136).10 · Como isso se encaixa
Este lab não constrói uma peça avulsa: ele aplica o padrão de ports-e-injeção das lições de deep-dive para erguer um subsistema novo que pluga no mesmo motor que o MemoryStore e o SkillStore já plugam. O NoteStore que você escreveu tem a mesma forma — IO injetado, validado na fronteira, nunca-lança, limitado — então o que vale para ele vale para qualquer store que o motor execute. Veja onde a peça entra: o que alimenta a construção (a montante) e o que ela passa a alimentar (a jusante) depois de pronta.
As peças com que este lab se conecta — cada uma é uma lição que abre uma face deste mesmo padrão:
FsPort, nunca do node:fs concreto.NoteStore reduz ao mínimo; você espelha seu esqueleto e poda o resto.NoteStore (passo 1 dele: "construa o store, reuse o Lab 1") para ligar três portas juntas.Onde você está na metodologia. O motor do Alembic é um loop — aprender → analisar → executar uma unidade → verificar na fronteira real → decidir — e cada subsistema é uma peça que esse loop executa e verifica. Este lab é a oficina: você fabrica uma peça nova com a forma exata que o motor exige, para que ela atravesse os gates (Lição 17) e plugue no loop do agente (Lição 19) sem nenhum caso especial. Aprender o esqueleto aqui é o que torna a Lição 29 ("a receita") aplicável a qualquer subsistema futuro. Para ver a esteira inteira em movimento, abra o mapa interativo da metodologia.
11 · Na prática
Este é um lab de construção: a prova não é um comando de runtime, é o subsistema compilando e o teste passando. O ciclo de verdade tem três comandos — andaime o pacote, rode só o teste dele enquanto itera, e feche com a baseline completa do repositório. São os mesmos comandos que o CLAUDE.md manda manter verdes a cada mudança de código.
1) Rode só o teste do pacote em que o store vive enquanto você itera no add() — o ciclo de feedback curto:
# roda apenas o pacote do store (substitua <pkg> pelo seu, ex.: hermes) pnpm --filter @alembic/<pkg> test # saída esperada (forma — Vitest): # ✓ src/notes/note-store.test.ts (3) # ✓ rejects an empty note and never throws # ✓ adds a valid note and bumps the count # ✓ refuses past the cap with an err # Test Files 1 passed (1) # Tests 3 passed (3)
2) Antes de dar a unidade por pronta, feche com a baseline do repositório inteiro — tipos, build e todos os testes. É o portão do CLAUDE.md ("Every code change must keep these green"):
# a baseline canônica — precisa terminar com exit 0 pnpm -r typecheck && pnpm -r build && pnpm -w test # o que observar: # - typecheck: 0 erros (se quebrar após mexer num pacote, rode `pnpm -r build` # para os dependentes verem o novo .d.ts) # - build: todos os pacotes compilam # - test: a contagem total sobe ou fica igual — nunca cai
@alembic/* novo precisa do próprio vitest.config.ts (com setupFiles em caminho absoluto) para que pnpm --filter <pkg> test rode isolado; sem ele o filtro não acha o teste. [uncertain] isto vale ao criar um pacote do zero — se o seu NoteStore mora num pacote que já tem testes, o config já existe e você não precisa criar outro.- Clone e entre no monorepo:
git clone <repo> alembic && cd alembic, depoispnpm install. O que observar: o workspace resolve os pacotes@alembic/*sem erro. - Crie a pasta do store dentro de um pacote que já tenha testes — p.ex.
packages/hermes/src/notes/— e cole os três arquivos das seções 04 e 07 (schema.ts,note-store.ts,note-store.test.ts). - Rode o teste só desse pacote:
pnpm --filter @alembic/<pkg> test. O que observar na saída: o arquivonote-store.test.tscom os casos em verde e nenhuma menção a diretório temporário — o fake roda em memória. - Feche com a baseline:
pnpm -r typecheck && pnpm -r build && pnpm -w test. O que observar:exit 0e a contagem total de testes maior do que antes de você começar.
Vale ver o ciclo como três passos numa esteira — itera no curto, fecha no completo, e só então a peça é uma unidade pronta:
@alembic/* que já tenha testes, p.ex. packages/hermes/src/notes/), crie schema.ts, note-store.ts e note-store.test.ts — exatamente os blocos das seções 04 e 07. Nada de import 'node:fs' na classe; o FsPort entra pelo construtor.pnpm --filter @alembic/<pkg> test. Comece pelo teste que já temos — "rejects an empty note and never throws" — e veja-o passar com o fake apoiado em Map, sem nenhum diretório temporário. Acrescente um teste para o caminho de teto e outro para o add feliz.pnpm -r typecheck && pnpm -r build && pnpm -w test. Confirme exit 0 e que a contagem total de testes subiu (você adicionou casos). Se o typecheck reclamar logo após criar o pacote, rode pnpm -r build primeiro para os dependentes enxergarem o novo .d.ts.max, para forçar o ramo err("At capacity …") sem tocar o disco? Pense antes.
new NoteStore(makeFakeFs(), '/x', 1) (o terceiro argumento é o max). Faça um add('primeira') (passa) e um add('segunda'); o segundo bate no posto ③ e retorna err, e como o teto é checado antes da gravação, o fake nunca recebe a segunda nota. Afirme (await s.add('segunda')).ok === false e s.count() === 1. Tudo em memória: zero os.tmpdir(), zero limpeza. É a mesma forma do teste de limite do MemoryStore (memory-store.ts:201-212).alembic run ou alembic serve exercitam o motor já montado; o que este lab entrega é uma peça dele. A prova certa de uma peça nova é a baseline de build/test — não um run. Quando o subsistema for ligado de fato a um run (a receita da Lição 29 cobre o exportar + documentar + verificar), aí sim ele aparece num alembic run. [uncertain] não há um comando alembic que exercite um NoteStore isolado — a verificação canônica de um subsistema é pnpm --filter <pkg> test + a baseline.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
FsPort entra pelo construtor?createNodeFsPort(), e o corpo não muda.safeParse na entrada do add(). A entrada é não confiável (modelo/rede) até ser parseada — entrada ruim vira err, não corrompe o estado.tryCatchAsync compra?fs (ENOENT/EACCES) num valor err, para o método honrar Promise<Result<…>> e nunca lançar (ADR-0009).err sai com estado e disco consistentes. Bound-before-write, como o MemoryStore.Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As quatro opções têm o mesmo tamanho de propósito — sem pista pela forma.
NoteStore recebe um FsPort no construtor em vez de chamar fs.writeFile diretamente?joinPath, não o motivo da injeção. c é falso: node:fs importa normalmente em ESM — o ponto é não acoplar a classe a ele. d inverte a realidade: a porta é uma indireção, não um ganho de performance; o que ela compra é testabilidade.add() checa o limite de capacidade antes de dar push e persistir. Por que a ordem importa?MemoryStore entregue faz o mesmo — monta um array de teste e checa o comprimento concatenado contra o limite antes de commitar (memory-store.ts:201-212).tryCatchAsync. O que isso garante para a 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). a erra: sem ele, um erro de disco escaparia como exceção e quebraria o invariante de nunca-lança (ADR-0009). b inventa retry — o helper não repete nada, só captura. c confunde com logging — ele não escreve no console; devolve o erro como valor para o chamador decidir o que fazer.remove(needle), o que deve acontecer quando needle casa com duas entradas distintas?locateUnique entregue (memory-store.ts:369-392): ambiguidade não é resolvida por chute — devolve-se err pedindo mais especificidade. b ("primeiro-vence") é exatamente o que a regra proíbe: removeria silenciosamente a entrada errada. c apaga demais — destrói dados que o usuário talvez quisesse manter. d viola o invariante de nunca-lança: todo caminho do remove retorna um Result, inclusive o ambíguo.writeFileAtomic e não writeFile?", "Como faço o remove distinguir N idênticas de N distintas?", "Quando um método NÃO precisa de Result?". É só dizer.