Раздел 2 · Программирование / CS

Своя ORM на TypeScript + Effect.ts

senior~420 мин

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

Своя ORM на TypeScript + Effect.ts

Кому это: ты уже знаком с монадами и ReaderTaskEither из fp-ts (или с ZIO в Scala-мире). В TypeScript ориентируешься чуть глубже, чем “куда ставить точку с запятой”: знаешь, что такое conditional types, mapped types, как работает infer. Тебе интересно, как функциональные подходы ложатся на проектирование DSL, и не пугает идея потратить пару вечеров на то, чтобы собрать собственный инструмент с нуля.

Если с Effect.ts ты раньше плотно не работал, не страшно: воркшоп можно проходить и как первое серьёзное знакомство. Главное держать в голове ментальную карту между fp-ts и Effect, ниже соберу её одной таблицей.

Зачем это вообще: мы соберём рабочую ORM, которая умеет говорить на двух движках: PGlite (Postgres, упакованный в WASM) и SQLite. Под капотом будет три красивых FP-паттерна, которые мы по очереди обсудим: Free/AST с подключаемыми интерпретаторами (запрос как структура данных, отдельный fold превращает её в SQL), phantom-typed builder сверху (fluent API, в котором порядок вызовов проверяется на этапе компиляции) и полноценный domain-слой: Repository, Identity Map и Unit of Work. Целью считаю не “написать ещё один Drizzle”, а получить API уровня effect/sql, но с настоящим маппингом сущностей, а не голым построением запросов.

Как это устроено: четыре занятия по два-три часа. Каждое урок-себе-самодостаточный кусок: можно остановиться, и то, что ты собрал, уже работает.

  • Урок 1. Сырые SQL-запросы через единый Driver-интерфейс. На выходе работают codec’и (это про преобразование типов между TS и БД) и трейсинг (видишь каждый запрос в OpenTelemetry).
  • Урок 2. Типизированные SELECT’ы через AST. Появляется стриминг для больших результатов и DataLoader, лекарство от классической проблемы N+1.
  • Урок 3. Fluent-билдер с join’ами и состоянием в типах: то, что в Kysely и Drizzle называется phantom-typed builder.
  • Урок 4. Domain-слой целиком: Repository поверх билдера, Identity Map, Unit of Work, транзакции с savepoint’ами и собственный migrations runner.

Договариваемся о словах

Прежде чем писать код, давай разберёмся, о чём вообще речь. Слово “ORM” в индустрии используют очень по-разному. Кто-то называет ORM любую штуку, которая прячет SQL за методами. Кто-то требует, чтобы там обязательно был UoW и lifecycle сущностей. От того, какую именно часть спектра ты считаешь “настоящей ORM”, зависит, какой проект у тебя получится. Поэтому первым делом нарисуем шкалу инструментов и скажем, где на ней живём мы.

Спектр инструментов работы с данными

Raw SQL           Query Builder         Datamapper           Active Record
   │                   │                    │                      │
   ▼                   ▼                    ▼                      ▼
pg.query(sql)    db.select.from(...)   repo.save(user)       user.save()
postgres`...`    .where(...).execute   repo.findById(1)      User.findOne(1)
sqlite.run       Kysely / Drizzle      Doctrine / EFCore     ActiveRecord
                                       MikroORM              Eloquent
                                                             Sequelize
СлойГде хранится знание о схемеКто знает про SQLDomain entity знает про БД
Raw SQLв твоей головетынет
Query Builderв schema-объектахbuilder, типынет (entity нет)
Datamapperв metadata + MapperMapper / Repositoryнет (POJO)
Active Recordв самом классе entityсам entityда

Что выбираем мы и почему

Если коротко: первые три урока мы строим query builder, типизированный API над SQL, в котором ещё нет никакого понятия “сущность”. Это стиль Drizzle и Kysely. На четвёртом уроке поверх билдера натягиваем слой Datamapper’а: Repository, Identity Map, Unit of Work. В итоге получается гибрид того, что делает MikroORM, только в функциональном стиле и без классов-наследников.

Дальше три неочевидных вопроса, ответы на которые задают весь дизайн.

Почему мы намеренно не идём в Active Record. В Active Record сущность намертво сращивается с транспортом, то есть с механизмом доступа к БД (соединение, драйвер, ORM-инстанс). Одно без другого не работает: чтобы сохранить пользователя, нужно живое соединение с БД, спрятанное в user.save(). В мире Effect это означает, что каждый метод-мутатор на классе должен возвращать Effect, и API быстро превращается в кашу из yield* на каждом шагу. Кроме того, Active Record смешивает доменную логику и работу с persistence: поля, методы, валидаторы и сгенерированный SQL живут в одном классе. На маленьком проекте это уютно, на среднем превращается в god-класс на тысячу строк, который никто не хочет открывать. И последнее: тестировать такую сущность без БД невозможно; user.save() без коннекта просто упадёт. Datamapper-сущность это обычный POJO, её можно гонять в тестах без Postgres-контейнера и mock-ов.

Почему сначала query builder, а Datamapper уже потом. Query builder это примитив, на котором всё остальное стоит. Без него нет смысла говорить про Repository: что репозиторий будет звать, голую pg.query? К тому же большинство Effect-проектов сегодня прекрасно живут на Drizzle или Kysely вообще без репозиториев сверху, и это нормальный выбор. В маленьком сервисе Repository поверх билдера это лишний бойлерплейт, который ты ещё и обязан поддерживать. Я хочу, чтобы к четвёртому уроку ты понимал, не как натянуть Datamapper, а когда пора это делать и какую конкретно боль он снимает. Эта боль не очевидна, пока ты сам не прошёл уроки 1-3 и не упёрся в N+1, в дублирование SQL по проекту и в отсутствие транзакционных границ.

Где живёт type safety

ПодходОткуда берутся типыType safety на уровне
Raw SQLвручную аннотируешьruntime
postgres-js (твой шаблон)template literal typebasic
Drizzle / Kyselyschema-объекты + phantom typesstructural, compile-time
Datamapper (наш урок 4)те же phantom types + brand на entitystructural + nominal

К концу воркшопа наш ORM даёт три разных уровня типобезопасности, которые работают одновременно. Первый, структурный, на уровне строк: компилятор знает, что users.email это string, и не даст случайно вставить туда число. Второй, номинальный, на уровне сущностей через бренды: UserId и PostId это разные типы, даже если оба под капотом number, и перепутать их в аргументе функции уже не получится. Третий, на уровне самого билдера, через состояние, закодированное в типах: вызвать .execute() до .select() ты не сможешь, такого метода в нужном типе просто нет. Каждый из этих уровней мы соберём руками и обсудим, какой ценой он достаётся.

Repository, DAO и Service: кто чем отличается

Эти три слова часто путают, потому что все они “что-то про данные”. Договоримся, кто за что отвечает.

  • DAO (Data Access Object) это низкоуровневая обёртка над SQL. Вызов userDao.selectById(1) отдаёт тебе сырую строку из БД, как есть. DAO ничего не знает про доменные сущности, он просто прячет строки SQL.
  • Repository говорит уже на языке домена. Вызов userRepo.findById(1) возвращает не строку, а User, нормальную типизированную сущность. Маппинг row -> entity репозиторий делает внутри себя через отдельный маппер.
  • Service живёт ещё выше и оркестрирует всё вместе. userService.register(input) валидирует входные данные, проверяет, что email не занят, создаёт пользователя через репозиторий, отправляет welcome-письмо. Это слой бизнес-логики, репозиторий для него инструмент.

В четвёртом уроке мы делаем ровно Repository. DAO и Service остаются за рамками воркшопа, но если ты захочешь натянуть их сверху, всё уже будет на месте.


Мини-вступление в Effect.ts

Если ты раньше работал с fp-ts (Task, TaskEither, ReaderTaskEither) или с ZIO, Effect ты освоишь за один вечер: идеи те же, эргономика лучше. Если плотно не работал, держи короткую карту, к ней можно возвращаться по ходу воркшопа.

fp-ts / RTEEffectЧто это
Task<A>Effect<A> без ошибок и зависимостейленивое async-вычисление
TaskEither<E, A>Effect<A, E>как выше плюс типизированный канал ошибок
ReaderTaskEither<R, E, A>Effect<A, E, R>как выше плюс зависимость от окружения
монада do через chainW/pipeEffect.gen(function* () { ... })“do-нотация” в Effect через генераторы
Reader + DI вручнуюLayer<RIn, RErr, ROut> + Context.Serviceсоставной DI с lifecycle и финалайзерами
IORefRefячейка с атомарным обновлением
свой логгер/трейсерEffect.withSpan, Effect.logвстроенный OpenTelemetry-tracer и логгер
свой scheduler/retriesSchedule, Effect.retryалгебра расписаний
Stream из RxJS/highlandStreampull-based стрим с backpressure

Главный сдвиг в голове по сравнению с fp-ts один: тип-параметр окружения (R) и канал ошибок (E) встроены в каждый Effect. Не надо комбинировать Reader поверх TaskEither, всё уже сидит в одном типе.

Что почитать на ходу. Документация Effect довольно дружелюбная, и я буду давать ссылки прямо в местах, где у тебя могут быть вопросы.

Если по ходу воркшопа какая-то конструкция выглядит магически, открывай соответствующий раздел доки и возвращайся: 95% времени там лежит точное объяснение того, что мы тут используем.


Архитектурный обзор

От remote Postgres к embedded базам

Классический application stack для удалённой Postgres выглядит так:

APP Code -> ORM -> Query Runner -> Driver -> Native Binding -> Wire Protocol
        -> Network (TCP/TLS) -> DB Server -> Storage Engine -> OS/FS

Прежде чем двигаться дальше, пройдём по этому стеку слева направо и поймём, что каждый слой делает. Иначе следующие два абзаца превратятся в кашу из незнакомых слов.

  • APP Code, твой бизнес-код. Зовёт ORM в духе userRepo.findById(1).
  • ORM, библиотека вроде Drizzle или MikroORM. Превращает domain-вызов в запрос и обратно: на входе сущности, на выходе тоже сущности.
  • Query Runner, тонкая прослойка внутри ORM, которая берёт собранный запрос, передаёт драйверу и парсит ответ обратно в объекты.
  • Driver, JS-пакет (pg, postgres, mysql2). Знает протокол конкретной СУБД и говорит на нём от имени Node.
  • Native Binding, опциональный мост к сишной библиотеке (например, libpq). Есть не у всех: у pg всё на чистом JS, а у better-sqlite3, наоборот, основная логика лежит в нативной C-библиотеке.
  • Wire Protocol, формат байтов, в которых драйвер сериализует запрос и читает ответ. Это язык, на котором клиент и сервер БД общаются друг с другом.
  • Network (TCP/TLS), сетевой канал между приложением и сервером БД. Может быть 1 ms (соседний контейнер) или 30 ms (другой регион), и эта задержка ниже по стеку никуда не девается.
  • DB Server, отдельный процесс postgres или mysql. Парсит SQL, строит план запроса, выполняет.
  • Storage Engine, его внутренняя подсистема, которая раскладывает данные по страницам, держит B-tree-индексы и WAL.
  • OS/FS, дисковые операции через файловую систему.

Запомни эту картинку, дальше мы будем выкидывать слои из неё по одному.

Когда вместо удалённой БД мы переходим на embedded-движок (PGlite или SQLite), три нижних слоя из этого стека просто исчезают:

  • Сети больше нет. Всё крутится в одном процессе с приложением. Сокетов нет, TLS-handshake-ов нет, сетевая задержка обнуляется.
  • DB Server тоже исчезает как отдельный процесс. PGlite, это postgres, скомпилированный в WASM и работающий внутри JS-воркера; SQLite, это библиотека, слинкованная прямо в твоё приложение.
  • Wire-протокол, частично или полностью. У SQLite его нет в принципе: ты зовёшь функции библиотеки напрямую, как любой другой код. У PGlite он формально остался (чтобы не переписывать половину постгреса заново), но это уже не байты в TCP-сокет, а вызов функции execProtocol внутри того же процесса, через postMessage в WASM-воркер.

Сам драйвер при этом тоже усыхает. Из штуки, которая знала весь wire protocol, он превращается в тонкий фасад поверх библиотеки. Для SQLite это N-API-биндинг к нативной C-библиотеке. Для PGlite, JS-обёртка над WASM-модулем.

Зато появляется новый слой, которого в обычном стеке не было: компилятор диалекта. Причина простая: два движка, два диалекта SQL, и они различаются в неудобных мелочах.

PGlite (Postgres)SQLite
Параметры$1, $2? или ?1, ?2
Типыjsonb, uuid, arrays, numeric, timestamptzTEXT / INTEGER / REAL / BLOB / NULL
Booleanнативный boolхранится как INTEGER 0/1
APIawait db.query(sql, params) (async)db.prepare(sql).all(params) (sync)

Самая злая строчка тут последняя. PGlite полностью асинхронный (внутри он гоняет постгрес внутри WASM-воркера и общается через postMessage), а better-sqlite3, наоборот, полностью синхронный (всё happens на текущем потоке). Раз нам надо поддержать оба, наш Driver обязан быть асинхронным изначально, через Effect. Сделать только синхронный вариант не получится: PGlite в него не влезет.

Архитектура нашего ORM

┌─────────────────────────────────────┐
│        App code                     │  ← пишет программы
├─────────────────────────────────────┤
│   Service / Use Case                │  ← урок 4 (бизнес-логика)
├─────────────────────────────────────┤
│   Repository + Mapper               │  ← урок 4 (domain ↔ row)
├─────────────────────────────────────┤
│   Unit of Work + Identity Map       │  ← урок 4 (transactional scope)
├─────────────────────────────────────┤
│   Builder + ExpressionBuilder       │  ← урок 3 (fluent API, phantom states)
├─────────────────────────────────────┤
│   Schema-typed AST + DataLoader     │  ← урок 2 (typed smart constructors)
├─────────────────────────────────────┤
│   Pure IR + Compiler                │  ← урок 2 (Free pattern, fold)
├─────────────────────────────────────┤
│   Driver (Effect Service + tracing) │  ← урок 1 (DI через Layer)
├─────────────────────────────────────┤
│   Codec layer + Dialect             │  ← урок 1 (encode/decode + placeholder)
└─────────────────────────────────────┘
        │                    │
        ▼                    ▼
   PGlite WASM           Native SQLite
   (memory/idb/file)     (file/:memory:)

Почему мы выбираем Free/AST, а не Tagless Final и не голый Phantom-Builder

На этой задаче конкурируют три классических подхода. Phantom-typed builder, тот, который ты видишь в Kysely и Drizzle, делает fluent API, в котором всё состояние сидит прямо в типах. С точки зрения опыта разработчика это очень приятно: автокомплит знает, что доступно, а что нет. Но билдер по своей природе сильно завязан на один драйвер: типы и runtime у него спаяны, и поддерживать сразу два движка приходится через костыли.

Free/AST идёт с другого конца. Запрос здесь это просто структура данных, sum type вроде “Select от таблицы такой-то с предикатом таким-то”. Отдельный компилятор делает над этой структурой fold и выдаёт SQL. Подход естественно ложится на multi-backend: AST один, компиляторов сколько угодно. Один и тот же SelectIR ты можешь скомпилировать в Postgres, в SQLite, в pretty-printed строку для логов или в JSON для пересылки на сервер. Для нашей задачи это идеально.

Третий кандидат, Tagless Final, представляет запрос как полиморфную функцию от какой-нибудь алгебры. В Haskell это красиво, в TypeScript, гораздо хуже: чтобы это нормально работало с разными уровнями (Expr, Predicate, Query<R>), нужны higher-kinded types, которых в TS нет. Эмуляция HKT через паттерн URI2HKT шумная и многословная. Я подробно разберу этот вариант в конце воркшопа, но в ядре мы его не используем.

Мы пойдём гибридным путём, потому что он соединяет лучшее. В ядре, Free/AST: ради multi-backend и подключаемых интерпретаторов. Сверху, phantom-builder: ради хорошего fluent API и типобезопасности на уровне порядка вызовов. Поверх билдера, Repository и Unit of Work из четвёртого урока. По сути это архитектура MikroORM плюс Kysely, только в функциональном стиле и в одном репозитории.


Урок 1: Foundation

Цели урока

К концу этого урока ты:

  • Описал schema как первоклассное value, из которого выводятся domain-типы.
  • Реализовал Effect-native Driver interface с двумя живыми бекендами (PGlite, SQLite).
  • Понял разницу между Effect.try (sync) и Effect.tryPromise (async) на примере двух драйверов.
  • Подключил codec layer, чтобы boolean не сломался на SQLite, а Date сериализовался одинаково в обоих диалектах.
  • Обернул Driver в tracing layer через Effect.withSpan (OpenTelemetry-совместимо из коробки).
  • Прошёл базовую threat model: где живут границы доверия и почему quoteIdentifier не делает identifier’ы безопасными.
  • Запустил одну и ту же программу на обоих бэкендах через смену одного Layer.

Что НЕ работает в конце урока 1: удобный query builder, типизация SQL. SQL пишется руками.

1.1 Схема как обычное значение

Любая ORM начинается с того, как ты описываешь таблицу. У нас будет одно жёсткое требование: описание таблицы это обычное значение, не декоратор и не класс. Почему так. Декоратор требует runtime-машинерии (рефлексия, метаданные), которая в TypeScript всё ещё опциональна. Класс несёт за собой всю свою объектную природу: this, наследование, instanceof. А обычный объект, во-первых, легко обходить в runtime (просто Object.entries), а во-вторых, из него отлично вытаскиваются типы через infer без дополнительных плясок.

Подсказка: смотри на это описание как на “форму данных, которую TS будет читать обоими способами одновременно”. В runtime это объект, по которому можно бегать; в типах это структура, из которой выводятся User, NewUser, UserId. Тот же файл, то же значение.

// schema/columns.ts

export type SqlType = "integer" | "real" | "text" | "blob" | "boolean";

// Column definition трекает три параметра на уровне типов:
// - SQL type
// - nullable?
// - primary key?
export interface ColumnDef<
  T extends SqlType = SqlType,
  Null extends boolean = false,
  PK extends boolean = false,
> {
  readonly _type: T;
  readonly _nullable: Null;
  readonly _pk: PK;
  readonly _default?: unknown;
}

// Smart constructors: каждый возвращает максимально узкий тип
export const integer = (): ColumnDef<"integer", false, false> =>
  ({ _type: "integer", _nullable: false, _pk: false });

export const text = (): ColumnDef<"text", false, false> =>
  ({ _type: "text", _nullable: false, _pk: false });

export const real = (): ColumnDef<"real", false, false> =>
  ({ _type: "real", _nullable: false, _pk: false });

// Имя smart constructor намеренно укорочено до bool, чтобы не затирать
// глобальный TS-тип boolean: он же используется в дженериках ColumnDef.
export const bool = (): ColumnDef<"boolean", false, false> =>
  ({ _type: "boolean", _nullable: false, _pk: false });

// Modifiers: иммутабельно "уточняют" column на уровне типов
export const nullable = <T extends SqlType, PK extends boolean>(
  c: ColumnDef<T, false, PK>,
): ColumnDef<T, true, PK> => ({ ...c, _nullable: true });

export const primaryKey = <T extends SqlType, N extends boolean>(
  c: ColumnDef<T, N, false>,
): ColumnDef<T, N, true> => ({ ...c, _pk: true });

Tabledef и type inference:

// schema/table.ts

export interface TableDef<
  Name extends string,
  Cols extends Record<string, ColumnDef>,
> {
  readonly _name: Name;
  readonly _columns: Cols;
}

export const table = <N extends string, C extends Record<string, ColumnDef>>(
  name: N,
  columns: C,
): TableDef<N, C> => ({ _name: name, _columns: columns });
// schema/infer.ts

export type SqlToTs = {
  integer: number;
  real: number;
  text: string;
  blob: Uint8Array;
  boolean: boolean;
};

// Достаём TS-тип одной колонки с учётом nullable
export type InferColumn<C extends ColumnDef> =
  C["_nullable"] extends true ? SqlToTs[C["_type"]] | null : SqlToTs[C["_type"]];

// Row-тип таблицы: запись из всех её колонок
export type InferRow<T extends TableDef<string, any>> = {
  [K in keyof T["_columns"]]: InferColumn<T["_columns"][K]>;
};

// Insert-тип отличается: PK с автоинкрементом опциональные
export type InferInsert<T extends TableDef<string, any>> = {
  [K in keyof T["_columns"] as T["_columns"][K]["_pk"] extends true ? never : K]:
    InferColumn<T["_columns"][K]>;
} & {
  [K in keyof T["_columns"] as T["_columns"][K]["_pk"] extends true ? K : never]?:
    InferColumn<T["_columns"][K]>;
};

Применение:

const users = table("users", {
  id:    primaryKey(integer()),
  name:  text(),
  email: text(),
  age:   nullable(integer()),
});

type User = InferRow<typeof users>;
//   ^? { id: number; name: string; email: string; age: number | null }

type NewUser = InferInsert<typeof users>;
//   ^? { name: string; email: string; age: number | null; id?: number }

1.2 Размеченные ошибки

Effect предлагает строить ошибки через Data.TaggedError. По сути это способ собрать discriminated union (объединение, в котором у каждого варианта свой тег _tag). За счёт этого канал ошибок строго типизирован: компилятор знает, какие именно ошибки могут прилететь, и ты ловишь нужную через Effect.catchTag("UniqueViolationError", ...) без instanceof и без switch-by-string. Дальше будет видно, что три-четыре разных тега покрывают почти всю работу с БД.

// errors.ts
import { Data } from "effect";

export class DbError extends Data.TaggedError("DbError")<{
  readonly cause: unknown;
  readonly sql: string;
  readonly params: ReadonlyArray<unknown>;
}> {}

export class NotFoundError extends Data.TaggedError("NotFoundError")<{
  readonly sql: string;
}> {}

export class TooManyError extends Data.TaggedError("TooManyError")<{
  readonly sql: string;
  readonly count: number;
}> {}

export class UniqueViolationError extends Data.TaggedError("UniqueViolationError")<{
  readonly constraint: string;
  readonly sql: string;
}> {}

export class ForeignKeyViolationError extends Data.TaggedError("ForeignKeyViolationError")<{
  readonly constraint: string;
  readonly sql: string;
}> {}

export class StatementTimeoutError extends Data.TaggedError("StatementTimeoutError")<{
  readonly sql: string;
  readonly timeoutMs: number;
}> {}

export class CodecError extends Data.TaggedError("CodecError")<{
  readonly column: string;
  readonly value: unknown;
  readonly cause: unknown;
}> {}

// Driver-level errors. NotFound/TooMany возникают выше, на уровне execute helpers
export type DriverError =
  | DbError
  | UniqueViolationError
  | ForeignKeyViolationError
  | StatementTimeoutError
  | CodecError;

Откуда берутся helper-ы. Сырой драйвер базы возвращает Promise<Row[]> и кидает обычные исключения. На стороне Effect мы хотим Effect<Row[], DriverError, Driver>: размеченный канал ошибок и явный контекст. Поэтому над сырым драйвером в любом Effect-проекте появляется лесенка обёрток. Нижняя ступенька, executeRaw, берёт SQL и параметры, перехватывает исключения, читает SQLSTATE (23505, 23503, statement_timeout) и превращает их в driver-level tagged error из списка выше. Сам метод увидим в 1.5, на интерфейсе драйвера, и тогда станет видно, как DriverError встаёт ровно в позицию E у Effect<A, E, R>.

Поверх executeRaw лежат уже знакомые по комментарию execute helpers: они берут результат запроса и решают, что делать с количеством строк. Именно тут возникают NotFoundError и TooManyError, которых внизу не было: на драйверном уровне “ноль строк”, это нормальный успех, а на уровне бизнес-кода это часто ошибка инварианта. И вот этот верхний слой естественно расходится на три варианта.

Зачем нужны три уровня строгости. В реальном коде запросы естественно делятся на три категории. Бывает “хочу любое количество строк, хоть ноль” (это execute). Бывает “хочу первую строку, и если её нет, это ошибка” (executeOne). И бывает третья, более строгая ситуация: “ожидаю ровно одну, и если их ноль или больше одной, у меня сломаны инварианты, и хочу узнать об этом сразу” (executeOneOrThrow). Идея подсмотрена у pigoz/effect-sql, и она удобно раскладывается ровно на три размеченных ошибки.

1.3 Интерфейс диалекта

Диалект у нас, это чистое значение, не зависящее от Effect. Это намеренно: диалект работает на этапе компиляции AST в SQL, а это абсолютно синхронный проход без I/O. Если бы мы тащили туда Effect, мы бы зря размазывали асинхронность по тому слою, где её просто нет.

// dialect.ts

export interface Dialect {
  readonly id: "postgres" | "sqlite";
  readonly placeholder: (n: number) => string;
  readonly quoteIdentifier: (name: string) => string;
  readonly mapColumnType: (sqlType: string, opts: { autoIncrement?: boolean }) => string;
  readonly supportsReturning: boolean;
}

export const PgDialect: Dialect = {
  id: "postgres",
  placeholder: (n) => `$${n}`,
  quoteIdentifier: (name) => `"${name.replace(/"/g, '""')}"`,
  mapColumnType: (t, { autoIncrement }) => {
    if (autoIncrement && t === "integer") return "BIGSERIAL";
    return ({
      integer: "INTEGER",
      real: "DOUBLE PRECISION",
      text: "TEXT",
      blob: "BYTEA",
      boolean: "BOOLEAN",
    }[t] ?? t.toUpperCase());
  },
  supportsReturning: true,
};

export const SqliteDialect: Dialect = {
  id: "sqlite",
  placeholder: () => "?",
  quoteIdentifier: (name) => `"${name.replace(/"/g, '""')}"`,
  mapColumnType: (t) =>
    ({
      integer: "INTEGER",
      real: "REAL",
      text: "TEXT",
      blob: "BLOB",
      boolean: "INTEGER",   // SQLite не имеет boolean: кодируем как INTEGER 0/1
    }[t] ?? t.toUpperCase()),
  supportsReturning: true,
};

1.4 Слой кодеков, без которого вообще ничего не работает

Если из всего первого урока запомнить только одну мысль, пусть это будет такая: строка, которую вернул драйвер, не равна строке, которую ждёт твой домен. Между ними обязан стоять codec, маленькая пара функций “как преобразовать значение туда и обратно”.

Чтобы это перестало звучать абстрактно, давай посмотрим на конкретные больные точки. Boolean в Postgres хранится как bool, а в SQLite, как INTEGER 0/1, потому что у SQLite вообще нет булевого типа. Date в PG, это timestamptz и приходит уже как объект Date, а в SQLite это либо ISO-строка, либо unix-миллисекунды, и без преобразования ты в одном диалекте получаешь Date, а в другом, банальную строку. Дальше идут JSON-поля, UUID, bigint, и каждый из них на разных движках выглядит по-разному.

Вот таблица, которая полезна как чек-лист в работе.

Почему кодек обязателен:

Domain типPG storageSQLite storageЧто без codec
booleanboolINTEGER 0/1true улетает в SQLite как строка "t" или throw
DatetimestamptzTEXT (ISO) или INTEGER (unix ms)в одном диалекте Date, в другом string
bigintint8INTEGER (но JS bigint через better-sqlite3)потеря точности на 2^53
JSONjsonb (объект)TEXT (string)в SQLite получаешь строку, ждёшь объект
UUIDuuidTEXTразные представления

Архитектура слоя кодеков. К каждой ColumnDef пристёгиваем пару функций encode/decode. Драйвер прогоняет encode через параметры перед тем, как послать запрос в БД, и decode через каждое значение в каждой строке после того, как ответ пришёл. Кодек параметризован диалектом: для boolean на Postgres это passthrough (там и так нативный bool), а на SQLite это превращение bool в int и обратно. Кодек, это маленькая, но очень частая работа, поэтому держим его максимально простым.

// codec.ts

export interface Codec<TS, SQL> {
  readonly encode: (value: TS) => SQL;
  readonly decode: (raw: SQL) => TS;
}

export const passthrough: Codec<unknown, unknown> = {
  encode: (v) => v,
  decode: (v) => v,
};

// Boolean: PG passthrough, SQLite bool <-> int
export const booleanCodec = (dialectId: "postgres" | "sqlite"): Codec<boolean, unknown> =>
  dialectId === "postgres"
    ? (passthrough as Codec<boolean, unknown>)
    : {
        encode: (v) => (v ? 1 : 0),
        decode: (raw) => raw === 1 || raw === "1" || raw === true,
      };

// Date: PG нативный Date, SQLite ISO string
export const dateCodec = (dialectId: "postgres" | "sqlite"): Codec<Date, unknown> =>
  dialectId === "postgres"
    ? (passthrough as Codec<Date, unknown>)
    : {
        encode: (v) => v.toISOString(),
        decode: (raw) => new Date(raw as string),
      };

// JSON: PG jsonb passthrough, SQLite TEXT
export const jsonCodec = <T>(dialectId: "postgres" | "sqlite"): Codec<T, unknown> =>
  dialectId === "postgres"
    ? (passthrough as Codec<T, unknown>)
    : {
        encode: (v) => JSON.stringify(v),
        decode: (raw) => JSON.parse(raw as string) as T,
      };

// BigInt: спорный кейс, лучше явно
export const bigintCodec: Codec<bigint, unknown> = {
  encode: (v) => v.toString(),
  decode: (raw) => BigInt(raw as string | number),
};

Расширение ColumnDef для опциональной привязки codec:

export interface ColumnDef<...> {
  // ... существующие поля
  readonly _codec?: (dialectId: "postgres" | "sqlite") => Codec<unknown, unknown>;
}

export const withCodec = <T, S, C extends ColumnDef>(
  c: C,
  codecFn: (d: "postgres" | "sqlite") => Codec<T, S>,
): C => ({ ...c, _codec: codecFn as never });

И smart constructors с codec’ом по умолчанию:

export const bool = (): ColumnDef<"boolean", false, false> =>
  withCodec({ _type: "boolean", _nullable: false, _pk: false }, booleanCodec);

export const timestamp = (): ColumnDef<"text", false, false> =>
  withCodec({ _type: "text", _nullable: false, _pk: false }, dateCodec);

export const json = <T>(): ColumnDef<"text", false, false> =>
  withCodec({ _type: "text", _nullable: false, _pk: false }, jsonCodec<T>);

Где это включается в драйвере. Самое простое: оборачиваем executeRaw декодером, который пробегает по строкам и применяет codec по схеме. Это можно держать прямо в Driver-слое, а можно вынести отдельным CodecLayer поверх. В четвёртом уроке кодек переедет в Repository.toEntity, и это правильно: репозиторий знает схему, драйвер нет.

1.5 Сервис драйвера и трейсинг с первой строки

Драйвер у нас, это ресурс: он владеет соединением или WASM-инстансом, который надо корректно закрыть. Идиома Effect для таких штук, Layer.effect поверх Effect.acquireRelease: ресурс создаётся при входе в scope, освобождается на выходе, причём корректно при любом исходе (успех, падение, прерывание). Отдельного Layer.scoped в Effect 4 нет, Layer.effect сам понимает scoped-эффекты и вычитает Scope из требований.

И сразу, не позже, не “когда-нибудь потом”, добавляем трейсинг на каждый запрос. Это пять строк кода и всего один Layer-декоратор, но они делают разницу между “ORM работает в проде” и “ORM работает в проде, и мы видим, что именно тормозит”. В отдельный Layer трейсинг вынесен ровно для того, чтобы можно было просто не подключать его в тестах и подключать в проде.

// driver.ts
import { Context, Effect } from "effect";
import type { Dialect } from "./dialect";
import type { DriverError } from "./errors";

export interface RawResult {
  readonly rows: ReadonlyArray<Record<string, unknown>>;
  readonly affectedRows: number;
  readonly lastInsertRowId?: number | bigint;
}

export interface DriverImpl {
  readonly dialect: Dialect;
  readonly executeRaw: (
    sql: string,
    params: ReadonlyArray<unknown>,
  ) => Effect.Effect<RawResult, DriverError>;
}

export class Driver extends Context.Service<Driver, DriverImpl>()("our-orm/Driver") {}

Почему API драйвера такой узкий. Метод executeRaw один, и этого достаточно. SELECT возвращает rows. INSERT, UPDATE, DELETE возвращают affectedRows (плюс опционально lastInsertRowId для SQLite). Один и тот же shape результата покрывает все случаи, и от этого выигрывают сразу два слоя выше: интерпретатору проще, репозиторию проще. Расширять интерфейс новыми методами имеет смысл только тогда, когда это даёт что-то, что нельзя выразить через executeRaw (так позже появится executeStream).

Декоратор для трейсинга. Оборачиваем существующий драйвер в Layer-декоратор и получаем span на каждый запрос, не меняя ни одной строки в самих драйверах. Идея простая: новый Layer берёт Driver из контекста, оборачивает у него executeRaw в Effect.withSpan и публикует наружу в качестве “нового” Driver. Снаружи это всё ещё один и тот же сервис, только теперь со span-ом.

// driver-tracing.ts
import { Effect, Layer } from "effect";
import { Driver } from "./driver";

export const TracingLayer = Layer.effect(
  Driver,
  Effect.gen(function* () {
    const inner = yield* Driver;
    return Driver.of({
      dialect: inner.dialect,
      executeRaw: (sql, params) =>
        inner.executeRaw(sql, params).pipe(
          Effect.withSpan("db.query", {
            attributes: {
              "db.system": inner.dialect.id,
              "db.statement": sql,
              "db.params.count": params.length,
            },
          }),
          Effect.tap((r) =>
            Effect.annotateCurrentSpan({
              "db.rows.returned": r.rows.length,
              "db.rows.affected": r.affectedRows,
            }),
          ),
        ),
    });
  }),
);

Использование:

// Tracing подключается ПОВЕРХ базового driver'а
const layer = TracingLayer.pipe(Layer.provide(PGliteDriver.layer({})));

Что мы за это получили. Каждый запрос автоматически проваливается в OpenTelemetry-span и виден в Honeycomb, Tempo, Jaeger или любой другой коллектор. Slow query log делается через Effect.tap с проверкой длительности span-а. Метрики (counter, histogram, gauge) добавляются вторым декоратором по той же схеме, и это сразу подсказка: декораторами хорошо собирается набор кросс-режущих фич, при этом сами драйверы остаются тонкими и не обрастают сторонними concerns.

Картинка слоёв в голове должна быть такая:

[business code]
       │
       ▼
[Driver Tag]  ← один и тот же интерфейс снаружи
       │
       ├─ MetricsLayer (E1.4)        ← счётчики и гистограммы
       ├─ SlowQueryLayer (E1.5)      ← лог тормозов
       ├─ TracingLayer               ← OTel-span на каждый запрос
       └─ PGliteDriver / SqliteDriver ← собственно I/O

Каждый Layer-декоратор оборачивает следующий и пробрасывает наружу тот же Driver-интерфейс. Бизнес-код не знает и не должен знать, какие именно слои стоят под ним.

Важная оговорка. В атрибут db.statement мы кладём только сам SQL, без значений параметров. Это сделано специально: если параметром случайно прилетел секрет (хеш пароля, токен), он не утечёт в трейсы или в систему наблюдения. Если в dev-окружении хочется видеть значения для отладки, добавь отдельный атрибут db.params под отдельным env-флагом, чтобы это явно было видно в коде и легко отключалось в продакшене.

1.6 PGlite driver

// drivers/pglite.ts
import { Effect, Layer } from "effect";
import { PGlite, type PGliteOptions } from "@electric-sql/pglite";
import { Driver, type DriverImpl } from "../driver";
import { PgDialect } from "../dialect";
import { DbError, UniqueViolationError, ForeignKeyViolationError, type DriverError } from "../errors";

const make = (options: PGliteOptions = {}) =>
  Effect.gen(function* () {
    // acquireRelease гарантирует, что close() выполнится при размотке Scope
    const pg = yield* Effect.acquireRelease(
      Effect.tryPromise({
        try: () => PGlite.create(options),
        catch: (cause) => new DbError({ cause, sql: "<connect>", params: [] }),
      }),
      (instance) => Effect.promise(() => instance.close()),
    );

    return Driver.of({
      dialect: PgDialect,
      executeRaw: (sql, params) =>
        Effect.tryPromise({
          try: () => pg.query(sql, params as unknown[]),
          catch: (cause) => mapPgError(cause, sql, params),
        }).pipe(
          Effect.map((r) => ({
            rows: r.rows as ReadonlyArray<Record<string, unknown>>,
            affectedRows: r.affectedRows ?? 0,
          })),
        ),
    });
  });

// Postgres SQLSTATE -> tagged error
const mapPgError = (
  cause: unknown,
  sql: string,
  params: ReadonlyArray<unknown>,
): DriverError => {
  const code = (cause as { code?: string })?.code;
  const c = cause as { constraint?: string; message?: string };
  const constraint = c.constraint ?? c.message?.match(/constraint "([^"]+)"/)?.[1] ?? "unknown";

  if (code === "23505") return new UniqueViolationError({ constraint, sql });
  if (code === "23503") return new ForeignKeyViolationError({ constraint, sql });
  return new DbError({ cause, sql, params });
};

export const layer = (options?: PGliteOptions) =>
  Layer.effect(Driver, make(options));

Несколько важных деталей, на которые стоит посмотреть, иначе они выстрелят в проде.

  • Мы пишем Effect.acquireRelease, а не Effect.addFinalizer. Это идиоматичный паттерн для ресурсов: release-эффект гарантированно выполнится при размотке Scope, в том числе если внутри случилась ошибка или файбер прервали.
  • Сам Layer объявляем как Layer.effect, потому что наш драйвер владеет ресурсом (WASM-инстансом PGlite). В Effect 4 Layer.effect принимает и scoped-эффекты, отдельный Layer.scoped не нужен. Когда программа оборачивается в Effect.scoped, финалайзеры отработают автоматически в обратном порядке.
  • Функция mapPgError переводит SQLSTATE-коды Postgres в наши размеченные ошибки. Стоит расширять её под свои нужды: добавь 23514 для CHECK constraint, 23502 для NOT NULL и так далее. Каждый такой код, это отдельная ветка catch в бизнес-коде.
  • PGlite принимает URL для каталога данных: memory:// (всё в памяти, отлично для тестов), idb://my-app (хранение в IndexedDB браузера) и file://./data (обычные файлы, для Node).

1.7 SQLite driver

// drivers/sqlite.ts
import { Effect, Layer } from "effect";
import Database from "better-sqlite3";
import { Driver } from "../driver";
import { SqliteDialect } from "../dialect";
import { DbError, UniqueViolationError, ForeignKeyViolationError, type DriverError } from "../errors";

export interface SqliteOptions {
  readonly path: string;        // ":memory:" или путь к файлу
  readonly readonly?: boolean;
  readonly enableForeignKeys?: boolean; // default true
}

const make = (options: SqliteOptions) =>
  Effect.gen(function* () {
    const db = yield* Effect.acquireRelease(
      Effect.try({
        try: () => {
          const instance = new Database(options.path, { readonly: options.readonly });
          // Foreign keys в SQLite по умолчанию OFF, всегда включаем
          if (options.enableForeignKeys !== false) {
            instance.pragma("foreign_keys = ON");
          }
          return instance;
        },
        catch: (cause) => new DbError({ cause, sql: "<connect>", params: [] }),
      }),
      (instance) => Effect.sync(() => instance.close()),
    );

    return Driver.of({
      dialect: SqliteDialect,
      executeRaw: (sql, params) =>
        Effect.try({
          try: () => {
            const stmt = db.prepare(sql);
            if (stmt.reader) {
              const rows = stmt.all(...(params as unknown[])) as Record<string, unknown>[];
              return { rows, affectedRows: 0 };
            }
            const info = stmt.run(...(params as unknown[]));
            return {
              rows: [],
              affectedRows: info.changes,
              lastInsertRowId: info.lastInsertRowid,
            };
          },
          catch: (cause) => mapSqliteError(cause, sql, params),
        }),
    });
  });

const mapSqliteError = (
  cause: unknown,
  sql: string,
  params: ReadonlyArray<unknown>,
): DriverError => {
  const code = (cause as { code?: string })?.code;
  const msg = (cause as Error)?.message ?? "";

  if (code?.startsWith("SQLITE_CONSTRAINT_UNIQUE") || code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
    const constraint = msg.match(/UNIQUE constraint failed: (.+)$/)?.[1] ?? "unknown";
    return new UniqueViolationError({ constraint, sql });
  }
  if (code?.startsWith("SQLITE_CONSTRAINT_FOREIGNKEY")) {
    return new ForeignKeyViolationError({ constraint: "fk_violation", sql });
  }
  return new DbError({ cause, sql, params });
};

export const layer = (options: SqliteOptions) =>
  Layer.effect(Driver, make(options));

Несколько мест, где SQLite-драйвер легко прострелить себе ногу.

  • Библиотека better-sqlite3 синхронная, поэтому обёртка идёт через Effect.try, а не Effect.tryPromise. Вся работа выполняется прямо на текущем файбере, без ухода в microtask queue. Это блокирует event loop, поэтому если сервис обслуживает HTTP-запросы из этого же процесса, тяжёлый запрос подвесит всех. Подробности и обходные пути в секции “Node.js-специфика”.
  • В SQLite поддержка foreign keys по умолчанию выключена. Это известный исторический footgun: ты пишешь REFERENCES, тебе кажется, что всё работает, а на деле проверки FK не происходят. Всегда дёргай PRAGMA foreign_keys = ON сразу при открытии соединения, как мы делаем в коде выше.
  • stmt.reader, это флаг от better-sqlite3, который означает “это SELECT-стейтмент, его надо вызывать через .all(), а не .run()”. Если перепутать, .run() на SELECT молча отдаёт пустой результат, и ты долго ищешь, куда делись строки.
  • Поле lastInsertRowId в better-sqlite3 имеет тип bigint, если первичный ключ объявлен как INTEGER. Это важно, если ты потом маппишь его в number: на маленьких id всё работает, а на больших значениях ломается тихо, через потерю точности.

1.8 Threat model: где проходят границы доверия

ORM, это место, где пользовательский ввод встречается с базой данных. Прежде чем написать первый where, договоримся, что именно мы защищаем и от чего. Без этой картинки в голове очень легко наставить дыр там, где казалось, что “ну тут же placeholder, что может пойти не так”.

Под “границей доверия” мы понимаем точку, где данные перешли из категории “может быть чем угодно, в том числе атакой” в категорию “проверены, можно использовать без оглядки”. У нас таких границ несколько, и для каждой надо явно понимать, кто отвечает за валидацию и какой инвариант она гарантирует.

Что у нас точно безопасно. Параметризованные значения через lit(value) всегда уходят через placeholder. Драйвер передаёт их в БД либо в бинарном виде (PG), либо через bind (SQLite). Никакой интерполяции в строку SQL не происходит, поэтому SQL-инъекция через значение в принципе невозможна. Это базовый минимум, на котором держится всё остальное.

Что небезопасно, и что важно знать наизусть.

  • Имена идентификаторов из пользовательского ввода. Кажется, что quoteIdentifier(userColumnName) решает все проблемы, но это иллюзия. В Postgres идентификатор после кавычек в некоторых сборках может содержать NULL-байт, плюс есть весёлые edge case-ы с нормализацией Unicode. Если пользователь выбирает имя колонки (например, в админке с динамическим фильтром), валидируй через явный allowlist, а не через кавычки.
  • ORDER BY по полю, которое выбрал пользователь. Та же история с инъекцией, плюс отдельная боль: пользователь может попросить сортировку по неиндексированной колонке и тем самым уронить продакшен на полном table scan. Allowlist здесь обязательно.
  • LIMIT без верхней границы. Пользователь пришёл и попросил ?limit=10000000, твой WASM съел гигабайт оперативки и умер. Всегда ставь верхний потолок прямо в API: Math.min(userLimit, MAX_LIMIT).
  • Отсутствие statement timeout. Без него один случайный SELECT pg_sleep(10000) или внезапный full table scan на миллион строк аккуратно кладёт твой WASM-инстанс. PGlite умеет SET statement_timeout, в Postgres-сервере он тоже есть. Поставь его в bootstrap каждого драйвера, не позже.
  • PGlite в режиме общего IndexedDB. Несколько вкладок одного браузера могут видеть одну и ту же БД. Если данные одной вкладки попадают в DOM другой без санитизации, это уже XSS-вектор. Простое правило: не хранить в БД сырой HTML, который ты потом будешь засовывать в innerHTML.

Как через это проходят данные. Картинка в голове должна быть такая:

[user input]
    │
    ▼
[validation: zod / effect.Schema]   ← граница 1: всё, что прошло, можно в LIT
    │
    ▼
[business code: ИДЕНТИФИКАТОРЫ из enum'ов, не из строк]
    │
    ▼
[builder API: всё через лит, identifier'ы из schema-объектов]
    │
    ▼
[driver: только параметризация, никакой interpolation]

Практическое правило, которое стоит держать в голове. В нашем билдере из третьего урока невозможно передать идентификатор как пользовательский ввод: метод b.col(table, "name") принимает literal type, а не произвольную строку. Это compile-time защита, она не даст случайно подставить переменную туда, где должна быть константа из схемы. Если когда-нибудь решишь вызвать executeRaw(template_with_user_input) напрямую, помни: именно здесь проходит граница доверия, и за ней ты сам отвечаешь за безопасность.

1.9 Проверка: одна и та же программа на двух движках

// example/lesson-1.ts
import { Effect, Layer } from "effect";
import { Driver } from "../driver";
import { TracingLayer } from "../driver-tracing";
import * as PGliteDriver from "../drivers/pglite";
import * as SqliteDriver from "../drivers/sqlite";

const program = Effect.gen(function* () {
  const db = yield* Driver;
  const ph = (n: number) => db.dialect.placeholder(n);
  const id = db.dialect.quoteIdentifier;

  yield* db.executeRaw(
    `CREATE TABLE IF NOT EXISTS ${id("users")} (
       id INTEGER PRIMARY KEY,
       name TEXT NOT NULL,
       email TEXT UNIQUE NOT NULL,
       active ${db.dialect.id === "postgres" ? "BOOLEAN" : "INTEGER"} NOT NULL
     )`,
    [],
  );

  // На PGlite даст $1, $2, $3; на SQLite ?, ?, ?
  // bool(true) проходит через codec и становится `1` на SQLite
  const inserted = yield* db.executeRaw(
    `INSERT INTO ${id("users")} (name, email, active) VALUES (${ph(1)}, ${ph(2)}, ${ph(3)}) RETURNING *`,
    ["Vassiliy", "v@example.com", db.dialect.id === "sqlite" ? 1 : true],
  );

  return inserted.rows;
}).pipe(
  Effect.catchTag("UniqueViolationError", (e) =>
    Effect.logWarning(`User already exists: ${e.constraint}`).pipe(Effect.as([])),
  ),
);

// Tracing layer оборачивает базовый driver. Меняется только нижний
// слой, бизнес-код выше не знает, на каком движке исполняется.
const sqliteLayer = TracingLayer.pipe(Layer.provide(SqliteDriver.layer({ path: ":memory:" })));
const pgLayer = TracingLayer.pipe(Layer.provide(PGliteDriver.layer({})));

await Effect.runPromise(Effect.scoped(program).pipe(Effect.provide(sqliteLayer)));
await Effect.runPromise(Effect.scoped(program).pipe(Effect.provide(pgLayer)));

Здесь критически важен Effect.scoped. Без него scoped Layer просто не размотается, и ты тихо утечёшь WASM-инстанс PGlite или handle к SQLite. На одном тесте это незаметно, в долго живущем сервисе превращается в утечку памяти и не закрытые файловые дескрипторы.

Обрати внимание на ручной костыль params[2] === sqlite ? 1 : true в коде выше: мы вынуждены руками подсовывать разные значения под разные движки, потому что ещё не подключили codec на уровне драйвера. На втором уроке это уйдёт: codec из 1.4 начнёт работать автоматически, и бизнес-код будет передавать обычный true, не зная, на каком движке он сейчас запущен.

Упражнения к уроку 1

Acceptance-тесты к уроку 1

Заведи у себя test/exercises-lesson-1.test.ts рядом со своими тестами и собери в нём проверки ниже. Прогон через vitest. Я сознательно даю набор как acceptance criteria, не как готовый кейс: нужно реализовать упражнение и снять it.todo там, где он стоит.

E1.1. withDefault + опциональность в InferInsert.

import { describe, expect, expectTypeOf, it } from 'vitest';
import { integer, primaryKey, text, withDefault } from '~/schema/columns.js';
import type { InferInsert } from '~/schema/infer.js';
import { table } from '~/schema/table.js';

const articles = table('articles', {
  id: primaryKey(integer()),
  title: text(),
  likes: withDefault(integer(), 0),
});

describe('E1.1', () => {
  it('runtime: _hasDefault = true, _default доступен', () => {
    expect(articles._columns.likes._default).toBe(0);
    expect(articles._columns.likes._hasDefault).toBe(true);
  });

  it('types: колонка с default опциональна в insert', () => {
    expectTypeOf<{ title: string }>().toExtend<InferInsert<typeof articles>>();
  });
});

E1.2. CHECK / NOT NULL поднимаются как tagged errors.

const program = Effect.gen(function* () {
  const driver = yield* Driver;
  yield* driver.executeRaw(
    `CREATE TABLE products (id INTEGER PRIMARY KEY, price INTEGER, CHECK (price >= 0))`,
    [],
  );
  const result = yield* Effect.result(
    driver.executeRaw(`INSERT INTO products (id, price) VALUES (1, -10)`, []),
  );
  expect(result._tag).toBe('Failure');
  if (result._tag === 'Failure') expect(result.failure).toBeInstanceOf(CheckViolationError);
});

E1.3. varchar(n) рендерится одинаково в обоих диалектах.

expect(PgDialect.mapColumnType('varchar', { length: 255 })).toBe('VARCHAR(255)');
expect(SqliteDialect.mapColumnType('varchar', { length: 32 })).toBe('VARCHAR(32)');

E1.5. SlowQueryLogLayer прозрачен.

const stack = SlowQueryLogLayer({ slowMs: 1000 }).pipe(
  Layer.provide(SqliteDriver.layer({ path: ':memory:' })),
);
await Effect.runPromise(Effect.scoped(program).pipe(Effect.provide(stack)));

E1.6. StatementTimeoutLayer ловит зависший query.

const FakeSlow = Layer.effect(Driver, Effect.gen(function* () {
  const inner = yield* Driver;
  return Driver.of({
    dialect: inner.dialect,
    executeRaw: (sql, params) =>
      Effect.sleep('200 millis').pipe(Effect.andThen(inner.executeRaw(sql, params))),
  });
}));

const r = yield* Effect.result(driver.executeRaw('SELECT 1', []));
expect(r._tag).toBe('Failure');
if (r._tag === 'Failure') expect(r.failure._tag).toBe('StatementTimeoutError');

E1.9. RetryLayer повторяет только transient ошибки.

// UNIQUE-нарушение НЕ ретраится
let attempts = 0;
const r = yield* Effect.result(driver.executeRaw(`INSERT INTO u (email) VALUES ('x@y.z')`, []));
expect(r._tag).toBe('Failure');
if (r._tag === 'Failure') expect(r.failure._tag).toBe('UniqueViolationError');
expect(attempts).toBe(1); // ровно один заход

// transient DbError ретраится до maxAttempts раз
expect(attempts).toBe(3);

Пункты E1.4 (Metrics), E1.7 (libsql), E1.8 (Pool), E1.10 (codec layer) добавляй как отдельные it(...) по мере реализации, форму acceptance-проверки определишь по описанию упражнения.


дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.