Curso / Lição 22
Lição 22 · Lab · prático 1 de 2

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.

Leia primeiro (fontes primárias)
Os padrões que este lab espelha — lidos literalmente do repo

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.

O que você precisa. Nada a executar neste lab — leia e escreva o código na cabeça ou num editor. Os tipos aqui são reais: 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.
Objetivos desta lição
  • Construir um subsistema com as quatro propriedades de todo store entregue: IO injetado, validado na fronteira, nunca-lança, limitado.
  • Injetar um FsPort no construtor em vez de importar node: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 FsPort fake de ~10 linhas — sem disco real, sem dir temporário.
0
propriedades obrigatórias
0
passos de construção
0
import fs na classe
0
linhas do fake de teste

01 · 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:

A analogia do cofre. Imagine um cofre que guarda bilhetes. (1) Ele não conhece a parede onde está chumbado — alguém entrega o encaixe (a porta). (2) Tem um fiscal na entrada que rejeita bilhete em branco. (3) Se a fechadura emperra, ele devolve um aviso em vez de explodir. (4) Tem um limite de bilhetes e recusa o excedente. Essas quatro regras são, literalmente, as quatro propriedades abaixo — só que em TypeScript.
PropriedadeComo obtemosPor quê
IO é injetadoFsPort no construtor — sem import 'node:fs'Testável em memória; agnóstico de store (invariante 2)
Validado na fronteiraZod safeParse em toda entradaEntrada não confiável não pode corromper o estado
Nunca lançaretorna Result<T, Error>; envolve IO em tryCatchAsyncFalha é um valor (ADR-0009)
Limitadoum teto de entradas, imposto antes de gravarSem crescimento ilimitado — espelha o orçamento de chars da memória
AS QUATRO PROPRIEDADES · cada uma vira uma camada concreta do NoteStore
IO injetado FsPort no construtor validado Zod safeParse nunca lança Result + tryCatchAsync limitado teto de entradas As mesmas quatro do MemoryStore e do SkillStore — só que reduzidas ao mínimo que ainda ensina o padrão. Tire qualquer uma e o subsistema deixa de ser "seguro" pelas regras do motor.

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:

ESQUELETO DE PORTS · a classe conhece só a INTERFACE; o fs concreto é injetado de fora
NoteStore kernel puro · nenhum import fs depende de interface FsPort readText·writeFileAtomic·stat·joinPath·ensureDir @alembic/contracts Result · ok · err · tryCatchAsync createNodeFsPort() produção · disco real fake apoiado em Map teste · ~10 linhas, em memória "implementam" ↓ — a classe não percebe qual recebeu
Leia a seta principal: 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.
É invariante 2 desenhado: "kernel puro, efeitos colaterais injetados". O 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:

SUPERFÍCIE DO FsPort · os 5 métodos que o NoteStore usa vs os 3 que ignora
USADOS pelo NoteStore — o fake precisa implementar estes 5 joinPath() ensureDir() stat() readText() writeFileAtomic() NÃO USADOS aqui — parte da interface, mas fora do escopo deste store readDir() appendLine() openLineStream() A regra: dependa da forma de que você precisa, não de uma implementação concreta. O fake da seção 07 implementa exatamente os 5 verdes; os 3 tracejados ficam como stub "conforme necessário" — porque o NoteStore nunca os chama. 5 de 8 — você consome só a fatia da interface que o domínio exige. [uncertain] a contagem total de métodos da interface pode variar com a versão; cite fs-port.ts:61.

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:

A TROCA · um único NoteStore, dois construtores — o corpo da classe é idêntico
PRODUÇÃO createNodeFsPort() disco real · @alembic/etl injeta ↓ TESTE makeFakeFs() · Map em memória · ~10 linhas injeta ↓ new NoteStore(fs, baseDir) MESMO corpo · nenhum if(test) · nenhum import fs a classe não percebe qual porta recebeu Só o argumento muda. Por isso o teste roda EXATAMENTE o mesmo caminho que a produção — a injeção não é "um modo de teste"; é a forma única que serve aos dois lados. É o invariante 2 do motor pago em prática: kernel puro, efeito colateral escolhido de fora.

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:

FLUXOGRAMA DE CONSTRUÇÃO · os 6 passos, do schema à prova (siga as setas)
1 · schema (Zod) o que é uma nota válida 2 · NoteOutcome o payload de sucesso 3 · classe + porta injeta FsPort no construtor 4 · load() stat → read, fail-closed 5 · add() ★ validar·limitar·mutar·persistir 6 · teste (fake) ✓ prova em memória Ordem importa: o contrato (1–2) vem antes da classe (3); load (4) antes do add (5); a prova (6) fecha. ★ o passo 5 é o coração — quatro guardas em ordem (seção 05). ✓ o passo 6 só é barato PORQUE o IO foi injetado. É a mesma sequência que o MemoryStore seguiu — você está reconstruindo o esqueleto, não inventando um novo.
Preveja antes de continuar
No passo 6, o teste precisa de um diretório temporário em disco para rodar? Quantas linhas o "fake" do filesystem costuma ter? Chute antes de revelar.
Zero diretório temporário; ~10 linhas de fake. Como o IO é injetado, o teste passa um objeto que satisfaz a interface 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.

Exemplo resolvido · construa o NoteStore do zero (passos 1 a 6)
1
O schema da fronteira. // notes/schema.ts O schema é o contrato do que uma nota válida é. Validamos na fronteira porque, em produção, essa entrada pode vir de um modelo ou de uma chamada de rede — é não confiável até ser parseada. Isso espelha 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>;
2
O tipo de resultado (outcome). // notes/note-store.ts (topo) Uma operação que muta estado retorna um pequeno outcome terminal refletindo o estado vivo — exatamente como 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;
}
3
A classe: injete a porta, nunca toque 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);
  }
4
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);
  }
5
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; }
}
6
Prove com um 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.
Agora você: antes de escrever, decida o tipo de retorno de um método novo 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.
O rollback em ④ é um toque deliberado. O 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:

load() · a sequência fail-closed — ausente ⇒ vazio (ok), falha real ⇒ err
ensureDir() cria o baseDir stat: existe? (o arquivo) NÃO entries = [] · ok ausente ⇒ vazio, NÃO erro SIM readText() lê o conteúdo split + filter · ok entries preenchido ↓ short-circuit fail-closed qualquer tryCatchAsync falha ⇒ return err EACCES, decode ruim — falha REAL, não ausência Mesma forma de stat-depois-read do MemoryStore.readEntries (memory-store.ts:306): distinguir "vazio" de "quebrado" é a essência do fail-closed.

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:

add(input) · a esteira de quatro guardas — cada falível faz short-circuit com err
NoteStore.add(input) : Promise<Result<NoteOutcome, Error>> — nunca lança ① Zod safeParse entrada ruim ⇒ err ② dedup já existe ⇒ ok no-op ③ checa teto no limite ⇒ err ④ writeFileAtomic porta injetada · pop() se falhar só o posto ④ toca o disco — e só DEPOIS de ①②③ aprovarem. O teto (③) vem ANTES da mutação: zero gravação parcial. a chamada de IO do ④ é envolvida em tryCatchAsync; ENOENT/EACCES viram err, nunca exceção.

Quer o porquê em duas profundidades? Alterne abaixo entre a explicação simples e a técnica:

Em uma frase: a nota só é salva se for válida, nova e couber no limite — e mesmo a gravação, se falhar, vira um aviso em vez de explodir. É um porteiro educado: ou deixa entrar, ou devolve um "não" claro.
Tecnicamente: as guardas fazem short-circuit via early return de 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:

tryCatchAsync · a ponte do mundo-que-lança para o mundo-de-valores
mundo que LANÇA fs.writeFileAtomic() throw ENOENT / EACCES tryCatchAsync(fn) @alembic/contracts captura · nunca propaga ok(value) sucesso da gravação err(error) a exceção, agora um valor Os dois ramos saem como Result — nenhum como throw. É o que deixa o add() prometer Promise<Result<…>> honestamente (ADR-0009). Regra: só código que cruza a porta (toca o fs) precisa da ponte; leitura pura de estado não lança e dispensa o wrap.

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:

LINHA DO TEMPO DO ROLLBACK · memória vs disco quando a gravação ④ falha
t0 · antes t1 · push (memória) t2 · write FALHA t3 · pop() + err memória n notas n+1 (otimista) n+1 (à frente!) n (pop!) disco n notas n (não tocado) n (write rejeitou) EACCES n notas em t2 as trilhas DIVERGEM (memória n+1, disco n) — é a janela que o pop() fecha em t3. O MemoryStore entregue para em t2 (push-then-save, memory-store.ts:214-217); o lab acrescenta o t3 para reconvergir.

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:

FLUXOGRAMA · uma chamada add() — da entrada não confiável ao veredito (nenhum throw)
add(input: unknown) entrada não confiável Zod aprova? (①) NÃO err "Invalid note" SIM já existe? (②) SIM ok 'exists' no-op NÃO no teto? (③) SIM err "At capacity" NÃO push + writeFileAtomic gravou? (④) SIM ok 'added' count++ NÃO pop() + err rollback
Conte as saídas: são cinco — três 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.
O ramo do rollback (④ → NÃO): a gravação falhou depois do 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
});
Por que isto é barato
Sem 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():

CRUZA A PORTA? · a única pergunta que decide a assinatura de cada método
o método toca o fs? (cruza a porta) SIM NÃO Promise<Result<T, Error>> + envolve o IO em tryCatchAsync ex.: add() · load() · remove() retorno direto · síncrono sem Result, sem tryCatchAsync ex.: list() · count() Aplicar Result onde nada pode falhar só polui a assinatura. O count() da "sua vez" cai à direita: lê this.entries, não cruza a porta.

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):

AspectoNoteStore (este lab)MemoryStore (entregue)Igual ou reduzido?
IOFsPort injetado no construtorFsPort injetado (memory-store.ts:105-112)Igual — o esqueleto
ValidaçãonoteSchema.safeParsememoryActionSchema na fronteiraIgual — o padrão
FalhaResult + tryCatchAsync, nunca lançaResult + tryCatchAsync (ADR-0009)Igual — o invariante
Limiteteto de entradas (DEFAULT_MAX_NOTES)orçamento de chars, checado antes de gravar (memory-store.ts:201-212)Igual — bound-before-write
Remoçãoexercício "sua vez" (seção 09)remove + locateUnique (memory-store.ts:261-276, 369-392)Reduzido — você adiciona
Snapshotdesafio 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:

NOTESTORE ⊂ MEMORYSTORE · esqueleto compartilhado (verde) vs recursos podados (claro)
NoteStore (lab) esqueleto: inject·validate·Result·bound + add() + load() + list() o mínimo que ainda justifica o padrão MemoryStore (entregue) o MESMO esqueleto + remove · locateUnique + snapshot congelado ← viram seus exercícios Mesmo esqueleto, superfície maior. Dominar o NoteStore é dominar 100% do que torna o MemoryStore "seguro". O que muda entre subsistemas não é o esqueleto — é só o schema e os métodos de domínio.

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:

0 linhas esqueleto 18 domínio 36 Piso fixo do esqueleto ≈ 18 linhas (construtor + load + list + imports) · escala: 0–360 linhas · estimativa de guardanapo. [uncertain] os números exatos variam com o domínio.

09 · Sua vez — estenda o store

Exercício: adicione 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 distintaserr("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, retorne ok({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.

SUA REGRA DE AMBIGUIDADE · quantas correspondências distintas? (fail-closed no meio)
conte matches de needle (distintos) 0 → err "No note matched" 1 → ok splice + writeFileAtomic ≥2 → err "Be more specific"
Desafio extra. Adicione um 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.

ONDE A PEÇA ENTRA · a montante alimenta a construção → NoteStore (esta peça) → a jusante a consome
A MONTANTE · alimenta a construção padrão ports & injeção Lição 05 — o gabarito @alembic/contracts + FsPort Result · ok · err · a interface MemoryStore · o modelo Lição 07 — a forma a reduzir NoteStore ESTA peça · este lab inject · validate · Result · bound mesma forma dos stores entregues — só muda schema + métodos de domínio A JUSANTE · o que a peça passa a alimentar teste com fake (Map) a porta injetada paga aqui pipeline de gates Lição 17 — Proof verifica o store loop do agente pluga como os outros stores Leitura da esquerda p/ a direita: o gabarito + os contracts + o modelo alimentam a construção; pronta, a peça é testada barato, verificada pelos gates e plugada no motor — sem caso especial. É o invariante 2 do motor (kernel puro, efeito injetado) virando a porta de entrada de TODO subsistema novo. A receita formal de 8 passos para fazer isso de fora do lab está na Lição 29.
Percorra o fluxo: nada aceso ainda — clique para acender a montante

As peças com que este lab se conecta — cada uma é uma lição que abre uma face deste mesmo padrão:

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 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
Nota — o vitest.config.ts por pacote. Um @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 tem testes, o config já existe e você não precisa criar outro.
Experimente · do clone ao verde
  1. Clone e entre no monorepo: git clone <repo> alembic && cd alembic, depois pnpm install. O que observar: o workspace resolve os pacotes @alembic/* sem erro.
  2. 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).
  3. Rode o teste só desse pacote: pnpm --filter @alembic/<pkg> test. O que observar na saída: o arquivo note-store.test.ts com os casos em verde e nenhuma menção a diretório temporário — o fake roda em memória.
  4. Feche com a baseline: pnpm -r typecheck && pnpm -r build && pnpm -w test. O que observar: exit 0 e 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:

O CICLO DE PROVA DESTE LAB · andaime → teste do pacote (loop) → baseline → pronto
escreve store + teste schema → add → fake pnpm --filter <pkg> test loop curto · vermelho → verde enquanto falhar, ajuste e repita verde baseline do repo typecheck · build · test exit 0 unidade pronta ✓ O teste do pacote é o loop de iteração; a baseline é o portão de "pronto". A peça só é uma unidade depois que a baseline passa.
Faça à mão · ande o ciclo de prova do NoteStore
1
Posicione os arquivos. Dentro do pacote escolhido (um @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.
2
Itere no curto. Rode 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.
3
Feche no completo. Rode 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.
Agora você: escreva o caso de teste do teto antes de revelar. De quantas notas você precisa semear, e com que valor de max, para forçar o ramo err("At capacity …") sem tocar o disco? Pense antes.
Construa com um teto pequeno e explícito — 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).
Por que nenhum comando de runtime aqui. Comandos como 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.

Injeção
Por que o FsPort entra pelo construtor?
clique pra virar ↻
Resposta
Para a classe depender só da forma do filesystem (invariante 2): teste passa um fake em memória, produção passa createNodeFsPort(), e o corpo não muda.
Fronteira
Onde e por que validar com Zod?
clique pra virar ↻
Resposta
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.
Nunca lança
O que tryCatchAsync compra?
clique pra virar ↻
Resposta
Converte a exceção do fs (ENOENT/EACCES) num valor err, para o método honrar Promise<Result<…>> e nunca lançar (ADR-0009).
Ordem
Por que checar o teto ANTES de gravar?
clique pra virar ↻
Resposta
Para um add acima do limite nunca mutar pela metade: o 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.

1. Por que NoteStore recebe um FsPort no construtor em vez de chamar fs.writeFile diretamente?
Correto: b. Injetar o efeito colateral é o segundo invariante do motor ("kernel puro, efeitos colaterais injetados"). A classe depende só da forma de um filesystem, então um fake apoiado em Map exercita exatamente o mesmo caminho de código que um disco real — sem dirs temporários, sem flakiness. a é um benefício incidental do 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.
2. add() checa o limite de capacidade antes de dar push e persistir. Por que a ordem importa?
Correto: c. As guardas rodam em ordem e fazem short-circuit; o limite é imposto antes de qualquer mutação. a está errado: inverter a ordem (mutar, depois checar) deixaria o estado adiante do disco num add reprovado. b confunde concisão com correção — o motivo é consistência, não tamanho. d inventa uma exigência do Zod que não existe: o Zod só valida o formato (posto ①); o teto (posto ③) é regra de domínio. O 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).
3. A chamada de IO é envolvida em tryCatchAsync. O que isso garante para a assinatura do método público?
Correto: d. 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.
4. No exercício remove(needle), o que deve acontecer quando needle casa com duas entradas distintas?
Correto: a. A regra vem do 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.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que 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.