Липвиг попросил две вещи, и обе про то, что лежит вне программы: файлы на диске и значения, которые переживают перезапуск. В уроке 94 база стала сервисом SqlClient, а драйвер слоем. С диском, путями и процессами так же: в Effect это сервисы платформы. Код говорит, что ему нужно, а Node, Bun или тест подают реализацию.
Что даёт платформа
Сервисы лежат в самом пакете effect:
FileSystem: читать, писать, создавать папки, проверять, есть ли файл;Path: собирать и разбирать пути;TerminalиStdio: терминал и стандартные потоки, они понадобятся в следующем уроке;ChildProcessSpawnerизeffect/process: запускать другие программы.
Реализации живут в пакетах платформ. Для Node это @effect/platform-node, и его NodeServices.layer даёт разом FileSystem, Path, Stdio, Terminal и ChildProcessSpawner. Для Bun есть @effect/platform-bun, для браузера @effect/platform-browser. Почтамт про них не знает: модуль archive.ts импортирует только effect, а слой ему подают снаружи. Так же, как store.ts не знает про PGlite.
Зачем сервис, если в Node есть node:fs? Ровно затем, зачем HttpClient вместо fetch в уроке 90. Код на node:fs работает только в Node: в браузере, где идут задачи курса, диска нет, и тест такого кода пишет настоящие файлы. Код на FileSystem работает везде, где ему дали слой, и в тесте слой это карта в памяти. Ошибки тоже честнее: вместо исключения с кодом ENOENT эффект падает значением PlatformError в канале ошибок.
Пути
Путь склеивают не плюсом, а сервисом Path:
const path = yield* Path.Path;
path.join('/archive/', 'dead', 'letters.jsonl'); // '/archive/dead/letters.jsonl'
path.dirname('/archive/dead/letters.jsonl'); // '/archive/dead'
join ставит разделитель сам и убирает лишний: папку '/archive/' со слэшем на конце и '/archive' без него он склеит одинаково. dirname отдаёт папку файла. Path.layer в самом effect это пути в стиле POSIX, через /; на Node пути даёт NodeServices.layer, со своими правилами для своей системы.
Файлы
Полка мёртвых писем это файл, в котором каждое письмо занимает одну строку JSON. Такой формат зовут JSON Lines: дописать в конец можно, не читая и не переписывая файл.
const ArchiveLine = Schema.fromJsonString(Letter);
export const archiveDead = (dir: string, letters: ReadonlyArray<Letter>) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const file = path.join(dir, 'dead', 'letters.jsonl');
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
const lines = yield* Effect.forEach(letters, (letter) => Schema.encodeEffect(ArchiveLine)(letter));
if (lines.length > 0) yield* fs.writeFileString(file, lines.map((line) => `${line}\n`).join(''), { flag: 'a' });
return file;
});
Разберём по строкам.
Schema.fromJsonString(Letter)из урока 79: письмо в строку JSON и обратно одной схемой. Кодирование тоже идёт через схему, а не черезJSON.stringify: брендLetterIdи литералы статуса проверяются на выходе так же, как на входе.makeDirectoryсrecursive: trueсоздаёт папку вместе с родителями и не падает, если папка уже есть. Какmkdir -p.writeFileStringсflag: 'a'дописывает в конец. Без флага файл переписывается целиком: вчерашняя полка исчезает, и остаётся только сегодняшняя пачка. Пустую пачку не пишут вовсе: нет писем, нет и записи.
Полку читают обратно тремя вызовами: fs.exists(file) скажет, есть ли файл, fs.readFileString(file) отдаст текст, а Schema.decodeEffect(ArchiveLine) разберёт каждую строку. Файла нет, значит, мёртвых писем ещё не было, и это пустой массив, а не беда. Если читать файл, которого нет, без проверки, эффект упадёт PlatformError с причиной NotFound:
NotFound: FileSystem.readFile (/archive/dead/letters.jsonl)
Диск без диска
В браузере диска нет, и тест его не трогает. FileSystem.layerNoop собирает файловую систему из тех методов, что ему дали; остальные падают как «нет такого файла»:
const files = new Map<string, string>();
const MemoryFs = FileSystem.layerNoop({
makeDirectory: () => Effect.void,
exists: (path) => Effect.succeed(files.has(path)),
readFileString: (path) => Effect.succeed(files.get(path) ?? ''),
writeFileString: (path, data, options) =>
Effect.sync(() => {
files.set(path, (options?.flag === 'a' ? (files.get(path) ?? '') : '') + data);
}),
});
Одна мелочь, на которой спотыкаются: layerNoop не выводит строковые методы из байтовых. Дали ему writeFile, а код зовёт writeFileString, и запись падает. Поэтому тестовая ФС задаёт ровно те методы, которые зовёт код. Это тот же приём, что подменный провод в уроке 90: слой знает столько, сколько нужно тесту, и ни байтом больше.
Ящик с номерками
Номерок должен пережить перезапуск, значит, хранить его в Ref нельзя. Для таких мелочей есть хранилище ключей KeyValueStore из effect/persistence: строка по ключу.
export const nextTicket = Effect.gen(function* () {
const store = KeyValueStore.toSchemaStore(yield* KeyValueStore.KeyValueStore, Schema.Int);
const last = yield* store.get('ticket');
const ticket = Option.getOrElse(last, () => 0) + 1;
yield* store.set('ticket', ticket);
return ticket;
});
Само хранилище знает только строки: get отдаёт строку или undefined. toSchemaStore надевает на него схему, и get отдаёт уже Option<number>, а set принимает число и кладёт его строкой '3'. Нет ключа, и это Option.none(), то есть первый номерок.
Где лежат номерки, решает слой: KeyValueStore.layerMemory в памяти, для тестов; layerFileSystem(dir) файлами в папке, ему нужны FileSystem и Path; layerStorage(() => localStorage) в браузере; layerSql() в той же базе, что письма. Код nextTicket от выбора не меняется.
Чужие программы
Иногда Почтамту надо запустить другую программу: узнать, сколько места занимает подвал, или отдать этикетку принтеру. Для этого есть effect/process:
const archiveSize = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
return yield* spawner.string(ChildProcess.make('du', ['-s', '/srv/pochtamt/dead']));
});
ChildProcess.make только описывает запуск: программа, аргументы массивом. Строкой с пробелами аргументы не передают, по той же причине, по какой в уроке 94 не склеивали SQL. spawner.string запускает и собирает вывод строкой, spawner.lines по строкам, spawner.spawn отдаёт процесс с потоками вывода. Запуск процессов это тоже сервис: на Node его даёт NodeServices.layer, в тесте ChildProcessSpawner.make с ответом, который нужен тесту. В браузере процессов нет вовсе, поэтому в задаче их не будет.
Где тут дыра
const archive = (dir: string, letters: ReadonlyArray<Letter>) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const file = dir + '/dead/letters.jsonl';
yield* fs.writeFileString(file, letters.map((letter) => `${JSON.stringify(letter)}\n`).join(''));
});
На его примере всё работало: одна пачка за ночь, папка '/archive'. Соседний пример: ночью мёртвые письма приходят двумя пачками. Вторая запись переписывает файл, и утром на полке только вторая пачка; о первой не знает никто, даже ошибка.
А через неделю папку настроили как '/archive/', со слэшем, и путь стал '/archive//dead/letters.jsonl'. Файловая система такое прощает, а сравнение путей и ключи кэша нет: архив раздвоился в отчётах. path.join и flag: 'a' закрывают обе дыры, а Schema.encodeEffect вместо JSON.stringify не пустит на полку письмо с номером ноль.
Нестабильно.
FileSystemиPathв корнеeffect, а такжеeffect/persistenceиeffect/processв 4.1 помечены@stability unstable: имена методов и настроек могут поменяться в минорной версии. Урок написан наeffect@4.1.0.
Почтамт, этап 6
Почтамт спускается в подвал. Новый модуль src/archive.ts: путь к полке, мёртвые письма строками JSON в конец файла, чтение полки обратно по схеме и номерки, которые живут в хранилище. Тест подаёт файловую систему в памяти и считает, что именно легло в файл.
Практика
Глубже в архиве
Файлы и процессы на голом Node разобраны в уроке про файлы и процессы.
Дальше
К ночи в подвале новая полка, а ящик с номерками помнит, на чём остановился. Липвиг стучит по нему костяшкой, улыбается и тут же мрачнеет.
Ночная смена ждёт в следующем уроке.