Письма Почтамта живут в Letters.layerMemory из урока 92, а это Ref в памяти процесса. Процесс перезапустили, и памяти нет. Нужна база данных: программа, которая пишет на диск и переживает перезапуск.
Окошкам меняться не придётся. Обработчики знают сервис Letters с тремя методами и не знают, где лежат письма. Слои из урока 48 для этого и придуманы: тот же сервис, другой слой. Сегодня напишем слой lettersSql, который держит письма в книгах SQL, а всё остальное Почтамта его даже не заметит.
База это тоже сервис
В Effect с базой говорят через сервис SqlClient из effect/sql. Кто на том конце, сервис не говорит: это решает драйвер, и драйвер это слой.
import { PgliteClient } from '@effect/sql-pglite';
import { Effect } from 'effect';
import { SqlClient } from 'effect/sql';
const program = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
return yield* sql<{ now: string }>`select now()::text as now`;
});
const run = program.pipe(Effect.provide(PgliteClient.layer()));
program требует SqlClient и больше ничего про базу не знает. PgliteClient.layer() из пакета @effect/sql-pglite даёт его поверх PGlite, настоящего PostgreSQL, собранного под браузер. На нём идут задачи этого урока. Для сервера Академии на настоящем PostgreSQL слой берут из @effect/sql-pg, и program не меняется ни на букву. Код запросов от драйвера не зависит, поэтому модуль store.ts в задаче не импортирует PGlite вовсе: базу ему подаёт тест.
Шаблон, который не склеивает
Запрос пишут шаблонной строкой с меткой sql. Похоже на склейку, но это не она:
const to = 'T-2';
const query = sql`select id, body from letters where addressee = ${to}`;
console.log(query.compile());
[ 'select id, body from letters where addressee = $1', [ 'T-2' ] ]
Метка sql получает куски текста и значения порознь и собирает из них две вещи: текст запроса с местом $1 и отдельный список значений. В базу они уходят тоже порознь. Значение из ${to} никогда не становится частью текста запроса, это параметр запроса. Что бы горожанин ни написал в письме, база прочитает это как данные, а не как команду.
Для вставки есть помощник sql.insert: он берёт объект и сам раскладывает его на колонки и параметры.
const insert = sql`insert into letters ${sql.insert({ sender: 'Липвиг', addressee: 'T-2', body: 'буря' })}`;
insert into letters ("sender","addressee","body") VALUES ($1,$2,$3)
Запрос это эффект: yield* sql`...` исполняет его и отдаёт строки ответа. Пока его не запустили, он, как и всё в Effect, только описание.
Ответ по схеме
Тип в угловых скобках sql<{ id: number; body: string }> это обещание, а не проверка: TypeScript поверит на слово, а база ничего не сверит. Колонку переименовали, тип не поменяли, и строка из базы поедет дальше под чужим именем. Мы это уже проходили с книгой башен в уроке 90: ответ чужого мира разбирают схемой.
Для этого есть SqlSchema. Он склеивает запрос, схему того, что спрашиваем, и схему того, что ждём в ответ:
const Row = Schema.Struct({ id: Schema.Int, body: Schema.String });
const forAddress = SqlSchema.findAll({
Request: Schema.String,
Result: Row,
execute: (to) => sql`select id, body from letters where addressee = ${to} order by id`,
});
forAddress('T-2') это эффект, который отдаёт массив строк, прошедших Row, или SchemaError, если база ответила не тем. Вот что будет, если в схеме ждать номер строкой, а база отдаёт число:
SchemaError Expected string
at [0]["id"]
Путь [0]["id"] показывает строку и колонку, как разбор кадров в уроке 81. У SqlSchema четыре формы: findAll для списка, findNonEmpty для списка, в котором есть хоть одна строка, findOne для ровно одной строки (нет строки, и в обоих случаях это ошибка NoSuchElementError), findOneOption для «может быть, есть»: Option.
В Почтамте колонки называются по-своему, sender, addressee, body, а письмо по-своему, from, to, text. Переименование живёт в одном куске запроса, и ответ сразу разбирается схемой Letter из урока 91:
const columns = sql`id, sender as "from", addressee as "to", body as text, status`;
const byId = SqlSchema.findOneOption({
Request: LetterId,
Result: Letter,
execute: (id) => sql`select ${columns} from letters where id = ${id}`,
});
Кусок columns вставлен в другой шаблон: метка sql умеет вкладывать запросы друг в друга, и параметры у них складываются в один список.
Всё или ничего
Липвиг просил, чтобы письмо и запись в журнале появлялись вместе. Это два запроса, и между ними может случиться что угодно: процесс упал, база отказала, вторая вставка не прошла. Тогда письмо есть, а записи нет. На почте это решают квитанцией с корешком: их отрывают одним движением, и половины не бывает. Транзакция делает из двух запросов такой же один шаг:
const send = (letter: NewLetter) =>
Effect.gen(function* () {
const saved = yield* insert(letter);
yield* sql`insert into journal ${sql.insert({ letter_id: saved.id, event: 'принято' })}`;
return saved;
}).pipe(sql.withTransaction);
sql.withTransaction берёт эффект и исполняет его внутри транзакции. Эффект закончился успехом, и транзакцию фиксируют. Упал с любой ошибкой, своей или базы, и всё, что он успел записать, откатывают. К ошибке эффекта добавляется SqlError: транзакция сама может не начаться.
Проверим откат: вставили письмо и тут же упали с бедой, к базе не относящейся.
const attempt = Effect.gen(function* () {
yield* sql`insert into letters ${sql.insert({ sender: 'T-5', addressee: 'T-3', body: 'x' })}`;
return yield* Effect.fail('нет такой башни');
}).pipe(sql.withTransaction);
После attempt писем в книге столько же, сколько было: вставка откатилась вместе с бедой.
Так устроена sendBatch в задаче. Башня сдаёт пачку писем, и пачку принимают целиком или никак: один неизвестный адрес, и вся пачка откатывается, а башня присылает её ещё раз, исправленной. Внутри sendBatch каждое письмо идёт через letters.send, у которого своя транзакция. Вложенная транзакция в Effect становится точкой сохранения (SAVEPOINT) внутри внешней, поэтому внешний откат убирает и её.
Горожанину нечего делать с SqlError, поэтому ошибки базы в окошко не идут. В lettersSql они становятся дефектом через Effect.orDie, как молчание книги башен в уроке 92. Ожидаемая беда остаётся в типе: LetterNotFound у byId, UnknownAddress у пачки.
Книги с первой страницы
Таблиц в новой базе нет. Их создают миграции: пронумерованные шаги, которые база проходит по порядку и запоминает, какие прошла.
export const migrations = {
'0001_letters': Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`create table letters (id serial primary key, sender text not null)`;
}),
};
export const migrate = Migrator.make({})({ loader: Migrator.fromRecord(migrations) });
Migrator.fromRecord читает миграции из объекта: ключ это номер_имя, значение эффект. Migrator.make({}) даёт прогон, который не знает драйвера. Прогон заводит в базе свою таблицу effect_sql_migrations, сверяется с ней и исполняет только новые шаги. Отдаёт он список исполненных: первый прогон [[1, 'letters']], второй []. Поэтому миграции можно звать при каждом запуске Почтамта, и Layer.effectDiscard(migrate) делает из прогона слой, который ничего не даёт, а только готовит базу.
Миграцию, которая уже ушла в книги, не правят: она прошла, и второй раз её не исполнят. Нужна новая колонка, и нужна новая миграция с новым номером. Это тот же договор, что у версий кадра в уроке 84: старое не переписывают, новое добавляют.
Под капотом
Где тут дыра
const saveLetter = (from: string, to: string, text: string) =>
sql.unsafe(`insert into letters (sender, addressee, body) values ('${from}', '${to}', '${text}')`);
На его письмах всё работало: Ринцвальд, T-2, «привет». Соседнее письмо пишет горожанин О’Брайен. Апостроф в имени закрывает строку раньше времени, и база получает обрывок:
SqlSyntaxError: Failed to execute statement (cause: error: syntax error at or near "Брайен")
О’Брайену повезло: его письмо не приняли, и всё. Если в поле «текст» напишут '); drop table letters; --, база прочитает это как команду и исполнит её. Это и есть SQL-инъекция. С меткой sql такое письмо ложится в книгу как обычный текст: в задаче его проверяет тест.
Нестабильно. Модули
effect/sqlв 4.1 помечены@stability unstable, как и драйвер@effect/sql-pglite: именаSqlSchema,Migratorи настройки драйверов могут поменяться в минорной версии. Урок написан наeffect@4.1.0и@effect/sql-pglite@4.1.0.
Почтамт, этап 5
Почтамт заводит книги. Новый модуль src/store.ts: миграции, слой lettersSql с тем же сервисом Letters на SqlClient, журнал той же транзакцией и пачка писем от башни, которая ложится целиком или никак. Тест подаёт базу сам, PGlite в памяти, и в конце гоняет через те же окошки из этапа 3, поменяв только слой.
Практика
Глубже в архиве
Хранилище событий на PostgreSQL в Effect v3 разобрано в уроке про event store старого раздела, слой данных и внедрение зависимостей без Effect в уроке про DI и слой данных раздела про бэкенд.
Дальше
К вечеру Гекс перезапускают нарочно, трижды подряд. Письма на месте, журнал тоже. Липвиг листает книги и хмурится на последнюю страницу.
Подвал Почтамта ждёт в следующем уроке.