Lição 22 · Curso de Fusão · Parte 4 · Método e labs · Lab: construir um subsistema
Alembic × Hermes · O Curso de Fusão · Parte 4 — Método e labs

Lab: construir um subsistema

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.

Leia primeiro (fonte primária)
packages/hermes/src/memory/memory-store.ts + packages/etl/src/fs-port.ts (lidos verbatim)

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.

Leia a versão simples, ou abra a camada técnica em qualquer seção.
Ao fim deste lab você vai saber
  • Escrever as quatro propriedades que todo subsistema do motor tem: IO injetado, validado na borda, nunca lança, limitado.
  • Por que o FsPort entra pelo construtor — e como isso deixa o teste rodar em memória, sem disco.
  • Como tryCatchAsync transforma uma exceção do node:fs em um valor err, honrando a promessa Result<…>.
  • Por que o cap é checado antes de escrever — e por que o pop() de rollback mantém memória e disco coerentes.
  • Estender o store com um remove(needle) fail-closed na ambiguidade (a regra do locateUnique real).
Suposições tolas (assumimos pouco de você)
  • Você já viu uma classe com um construtor e alguns métodos. Não precisa dominar TypeScript.
  • Você lembra das lições anteriores que Result nunca lança (ADR-0009) e que um port é uma interface injetada (lição 5). Se não, recapitulamos aqui.
  • Você não precisa rodar nada: leia e escreva o código na cabeça ou num editor. Os tipos compilam contra o workspace real.
1

O alvo: um store com quatro propriedades

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.

Analogia: pense num porteiro de prédio rígido. Ele confere a identidade de quem chega (validação de borda), recusa quando o prédio está cheio antes de abrir a porta (limite), nunca grita nem entra em pânico — devolve um "não" educado (nunca lança) — e usa a chave que você lhe deu, não uma cópia própria (IO injetado). Quatro hábitos, e o prédio fica seguro.

Propriedade ①
IO é injetado

FsPort no construtor — sem import 'node:fs'. Testável em memória; store-agnóstico (invariante 2).

Propriedade ②
Validado na borda

Zod safeParse em toda entrada. Input não-confiável não pode corromper o estado.

Propriedade ③
Nunca lança

devolve Result<T, Error>; embrulha o IO em tryCatchAsync. A falha é um valor (ADR-0009).

Propriedade ④
Limitado

um teto de entradas, forçado antes da escrita. Sem crescimento ilimitado — espelha o budget de chars da memória.

O que cada propriedade nos compra

PropriedadeComo obtemosPor quê
IO injetadoFsPort no construtor — sem import 'node:fs'Testável em memória; store-agnóstico (invariante 2)
Validado na bordaZod safeParse em toda entradaInput não-confiável não corrompe o estado
Nunca lançadevolve Result<T, Error>; embrulha IO em tryCatchAsyncA falha é um valor (ADR-0009)
Limitadoum teto de entradas, forçado antes da escritaSem 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).

Quatro propriedades, um store no centro NoteStore ① IO injetado ② validado na borda ③ nunca lança ④ limitado remova qualquer uma e o store deixa de ser "do jeito do motor" — as quatro são não-negociáveis
O store no centro; as quatro propriedades em volta o tornam testável, seguro na borda, à prova de exceção e limitado.
NoteStore.add(text) : Promise<Result<NoteOutcome, Error>> — nunca lança ① Zod safeParse input ruim ⇒ err ② checa o cap acima do limite ⇒ err ③ muta o estado array em memória ④ writeFileAtomic port injetado cada passo falível faz short-circuit com err; a chamada de IO é embrulhada em tryCatchAsync
O contorno terracota é a fronteira "nunca lança": dentro dela, toda falha vira um valor err.
Preveja antes de continuar

Das quatro propriedades, qual é a que torna o store testável sem tocar um disco real?

IO injetado (①). Como o construtor recebe um 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.

2

Passo 1 — o schema de borda

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>;
Infográfico do pipeline do método add(): cinco caixas conectadas da esquerda para a direita — ① Zod safeParse (input inválido vira err), ② dedup (já existe vira ok no-op), ③ checa o cap antes de escrever (acima do limite vira err), ④ muta o array em memória, ⑤ fs.writeFileAtomic via port injetado e tryCatchAsync; sob as caixas 4 e 5 uma seta curva de rollback rotulada 'falha no disco vira pop()'.

O pipeline inteiro do add(): validar, limitar, mutar, persistir — nessa ordem. Só o último passo toca o disco.

Por que safeParse e não parse

z.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.

input inválido .parse(x) ⇒ THROW ZodError escapa — proibido .safeParse(x) ⇒ {success:false} um valor — convertível em err err(new Error(...)) escolhemos safeParse porque ele mantém a falha como valor — o caminho de baixo nunca lança
O caminho de cima (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.

Dica de borda A borda é onde o mundo não-confiável encontra o seu código. Tudo que cruza essa linha — input de modelo, rede, arquivo no disco — passa por um safeParse. Depois da borda, o dado é Note (tipado e válido) e o resto do código pode confiar nele.
3

Passo 2 — o tipo de resultado

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;
}
Por que um resultado nomeado, não 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.
devolver void add() ⇒ void reler o store para saber o que houve devolver NoteOutcome add() ⇒ Outcome message count max o estado volta no próprio valor
À esquerda, void obriga uma releitura; à direita, o outcome nomeado entrega o estado vivo já no retorno.

O delimitador e o formato em disco

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.

4

Passo 3 — a classe: injete o port, 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 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);
  }
Infográfico comparativo: ao centro uma caixa grande 'class NoteStore' com uma porta de entrada rotulada 'FsPort injetado'; à esquerda dois cartões empilhados apontando para a porta — 'produção: createNodeFsPort() (disco real)' e 'teste: makeFakeFs() (Map em memória, ~10 linhas, $0, sem disco)'; à direita uma saída 'o MESMO código roda nos dois casos'; abaixo cinco chips de métodos: joinPath, stat, readText, writeFileAtomic, ensureDir.

Uma porta, dois fornecedores. A classe conhece só o shape de um filesystem — por isso o teste roda em memória.

A inversão de dependência, desenhada
class NoteStore depende só do shape interface FsPort implementa ↑ produção createNodeFsPort() · disco real teste makeFakeFs() · Map em memória a classe recebe um FsPort no construtor — não escolhe qual
A seta tracejada é "implementa": tanto o port real quanto o fake satisfazem a mesma interface. A classe aponta para a interface, nunca para um concreto.

O que o FsPort expõe

A 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.

writeFileAtomic: temp-then-rename 1. escreve em NOTES.md.tmp 2. rename atômico .tmp → NOTES.md arquivo final íntegro crash no meio? NOTES.md intacto o rename é atômico no SO — ou o arquivo antigo, ou o novo; nunca um arquivo pela metade
Por que "Atomic": a escrita vai num temp e só então é renomeada — um crash no meio nunca corrompe o arquivo bom.

packages/etl/src/fs-port.ts — interface FsPort: stat/readText/writeFileAtomic/ensureDir/joinPath.

Guarde isto "Injetar o efeito colateral" é a invariante 2 inteira. A classe não importa o efeito (o disco); ela recebe uma coisa com o formato do efeito. Troque o fornecedor e o mesmo código serve a produção e ao teste. Esse é o coração da lição 5.
5

Passo 4 — load(): ler pelo port, fail-closed

Toda 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);
  }
Preveja antes de continuar

Na primeira execução, o arquivo NOTES.md ainda não existe. load() devolve err?

Não — devolve 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.

ensureDir stat(path) ausente ⇒ entries=[] return ok(undefined) existe ⇒ readText split(DELIMITER) entries carregadas cada chamada de fs é embrulhada em tryCatchAsync — uma exceção vira err e faz short-circuit
O ramo de cima (arquivo ausente) é sucesso, não erro. Só falhas reais descem pelo err do tryCatchAsync.

O que tryCatchAsync faz, exatamente

tryCatchAsync(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.

6

Passo 5 — add(): validar, limitar, mutar, persistir

Este é 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; }
}
O rollback no ④ é um toque deliberado. O 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.

A ordem das guardas, explorável

Clique em cada camada para ver o que ela faz — e por que está nessa posição:

① noteSchema.safeParse(input) ② entries.includes(note)? ③ length >= max? (ANTES de escrever) ④ entries.push(note) ⑤ writeFileAtomic (+ pop em falha)
Exemplo guiado — três adds, um store de max=2
1
add('comprar pão') → safeParse passa, não é dup, length (0) < max (2) → push, writeFileAtomic, ok({message:'added', count:1}).
2
add('comprar pão') de novo → safeParse passa, mas includes é verdadeiro → ok({message:'exists', count:1}). Nenhuma escrita, nenhum erro: dedup é sucesso no-op.
3
add('ligar pro dentista') → passa, não é dup, length (1) < 2 → entra. Agora count=2, no limite.
4
Agora você: um quarto 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.)
Flashcard · vire
Por que o guard ③ (cap) vem antes do push?
clique para ver a resposta
Resposta
Para que um add acima do limite nunca mute pela metade. A guarda faz short-circuit com 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.
Flashcard · vire
O que tryCatchAsync dá à assinatura do método público?
clique para ver a resposta
Resposta
Converte uma exceção lançada do filesystem (ENOENT, EACCES) num valor err, então add() pode honestamente prometer Result<…> e nunca lançar. É a ponte do mundo que lança para o mundo de valores.
Flashcard · vire
Por que o dedup (②) devolve ok, não err?
clique para ver a resposta
Resposta
Porque adicionar uma nota que já existe não é uma falha — o estado desejado ("essa nota está no store") já é verdade. É um no-op idempotente: ok({message:'exists'}). Reservar err só para falhas reais mantém o contrato honesto.

Como o store real limita antes de escrever

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.

7

Passo 6 — prove com um FsPort fake

Como 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 que torna este teste possível

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.

it('rejeita…') teste new NoteStore(fake) código sob teste Map<string,string> o "disco" em memória injeta grava/lê $0, microssegundos, determinístico — sem pasta temp, sem flakiness de filesystem
O fake é o terceiro fornecedor da interface — e o único que um teste unitário precisa.

Por que o cast as FsPort é aceitável aqui

O 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).

8

Sua vez — estenda o store

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.

Exercício: implemente remove(needle)
1
Assinatura: 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.
2
Zero matches ⇒ err("Nenhuma nota casou com …").
3
Dois ou mais matches distintoserr("Várias notas casaram … Seja mais específico.") — a ambiguidade é fail-closed, não "primeiro ganha".
4
Exatamente um match (ou N idênticos) ⇒ remova-o do array, persista via writeFileAtomic, devolva ok({message:'removed', …}). Nunca lance; todo caminho devolve um Result.
5
Depois teste-o com o fake de 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.
A regra do locateUnique: a ambiguidade falha fechada 0 matches err("Nenhuma…") 1 match splice + persist ⇒ ok 2+ distintos err("Várias… Seja mais específico.") só o caso do meio escreve; os dois extremos recusam — nunca apaga a coisa errada por adivinhação
Fail-closed na ambiguidade: quando há dúvida de qual apagar, o store recusa em vez de chutar.
Meta extra (stretch). Adicione um 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.
Cuidado Não caia no "primeiro ganha". A tentação natural é, com dois matches, remover o primeiro e seguir. Isso é exatamente o que o motor proíbe: uma ação destrutiva sob ambiguidade deve recusar, não adivinhar. O custo de errar (apagar a nota errada) é maior que o de pedir mais especificidade.
9

Recapitulando — o subsistema em seis passos

Um slide por ideia. Use as setas do teclado (← →) ou os botões para navegar.

Slide 1 · O alvo

Quatro propriedades, não-negociáveis

Todo subsistema do motor: IO injetado, validado na borda, nunca lança, limitado. O NoteStore é o menor exemplo que ainda merece as quatro.

① injetado② borda③ não lança④ limitado
1
Slide 2 · Borda

O schema é o contrato

Input não-confiável passa por noteSchema.safeParse. safeParse, não parse — porque parse lança, e lançar é o que proibimos.

input: unknownNote (válida)
2
Slide 3 · Injeção

Uma porta, dois fornecedores

O construtor recebe um FsPort. Produção passa createNodeFsPort(); teste passa um Map. A classe não sabe a diferença — invariante 2.

NoteStoreFsPortnodeMap
3
Slide 4 · Fail-closed

tryCatchAsync embrulha todo IO

Uma exceção do disco (ENOENT, permissão) vira um valor err. Arquivo ausente, porém, é sucesso: um store vazio, não um erro.

throwerr(e): um valor
4
Slide 5 · A ordem

Validar, limitar, mutar, persistir

O cap é checado antes da escrita; um overflow nunca muta pela metade. E o pop() de rollback mantém memória e disco coerentes se o save falhar.

③ cap⑤ disco
5
Slide 6 · A prova

O fake de Map prova tudo

Como o IO é injetado, o teste roda em memória: $0, microssegundos, determinístico. O mesmo código que rodaria contra o disco. Isso é a invariante 2 pagando.

expect(...ok).toBe(false) ✓
6
Slide 1 / 6 use
As sete verdades do subsistema
  1. Quatro propriedades obrigatórias: injetado, validado na borda, nunca lança, limitado.
  2. O FsPort entra pelo construtor; a classe nunca importa node:fs.
  3. Toda entrada passa por noteSchema.safeParsesafeParse, não parse.
  4. Todo IO é embrulhado em tryCatchAsync: exceção vira valor err.
  5. Arquivo ausente é sucesso (store vazio), não erro.
  6. O cap é checado antes da escrita; o pop() de rollback mantém memória e disco coerentes.
  7. Um fake de Map prova tudo em memória — $0, determinístico, sem disco.
10

Verifique o que você construiu

Três perguntas — sua pontuação aparece embaixo
1. Por que o NoteStore recebe um FsPort no construtor em vez de chamar fs.writeFile direto?
Correto: b. Injetar o efeito colateral é a segunda invariante do motor ("kernel puro, efeitos injetados"). A classe depende só do shape de um filesystem, então um fake de Map exercita exatamente o mesmo caminho que o disco real — sem pastas temp, sem flakiness.
2. O 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 é forçado antes de qualquer mutação. O MemoryStore de produção faz o mesmo — monta o array de teste e checa o tamanho contra o limite antes de commitar.
3. A chamada de IO é embrulhada em tryCatchAsync. O que isso dá à 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). Sem ela, um erro de disco escaparia como exceção e quebraria a invariante "nunca lança" (ADR-0009).
Acertos: 0/3
Pergunta para levar adiante: você acabou de construir um subsistema do zero. Mas como o motor aprende com o que roda nele — como uma execução vira conhecimento que melhora a próxima? É o que o próximo lab constrói: o passo de aprendizado.