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

Lab: construir um subsistema de portas-e-injeção

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.

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.

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. Concretamente, nosso NoteStore deve ter as quatro propriedades que todo subsistema entregue tem:

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
NoteStore.add(text) : Promise<Result<Outcome, Error>> — nunca lança ① Zod safeParse entrada ruim ⇒ err ② checa teto acima do limite ⇒ err ③ muta estado array em memória ④ fs.writeFileAtomic porta injetada cada passo falível faz short-circuit com err; a chamada de IO é envolvida em tryCatchAsync

Passo 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>;

Passo 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;
}

Passo 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);
  }

Passo 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);
  }

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

Este é o coração. 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; }
}
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.

Passo 6 — prove com um FsPort fake

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
});
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.
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. 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). Sem ele, um erro de disco escaparia como exceção e quebraria o invariante de nunca-lança (ADR-0009).

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

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.

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