Раздел 46 · Effect: Тихая Академия

Книги Почтамта: SQL

middle-senior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Письма Почтамта живут в 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 и слой данных раздела про бэкенд.

Дальше

К вечеру Гекс перезапускают нарочно, трижды подряд. Письма на месте, журнал тоже. Липвиг листает книги и хмурится на последнюю страницу.

Подвал Почтамта ждёт в следующем уроке.