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.
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.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:
| 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 |
// 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>;
// 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; }
fs diretoO 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-closedToda 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, persistirEste é 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; } }
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.FsPort fakeComo 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 });
NoteStore recebe um FsPort no construtor em vez de chamar fs.writeFile diretamente?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). Sem ele, um erro de disco escaparia como exceção e quebraria o invariante de nunca-lança (ADR-0009).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):
err("No note matched …").err("Multiple notes matched … Be more specific.") — ambiguidade é fail-closed, não primeiro-vence.writeFileAtomic, retorne ok({message:'removed', …}).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).