# Цифровая визитка GraphQL API, которое отдаёт публичную визитку Ильи Братских: профиль, навыки, опыт, проекты и связанные с ними факты. Контракт смотрится в Apollo Sandbox. Факты взяты с [bratskih.tech/about](https://bratskih.tech/about) и из публичных репозиториев, которые этой странице соответствуют. Годы хранятся так, как они указаны в резюме: месяц не выдумывается. В список навыков не добавлены технологии только из-за того, что они использованы в этом репозитории. ## Стек NestJS 11, TypeScript, Prisma ORM 7, PostgreSQL, Apollo Server, Docker. ## Запуск с нуля Нужны Docker и свободные порты 3000 и 5432. ```powershell docker compose up --build ``` Когда контейнер `api` станет healthy, Sandbox открывается на [http://localhost:3000/graphql](http://localhost:3000/graphql). Корень сайта перенаправляет туда же. Проверка живости: `GET /health`. Пустая база не требует ручных шагов. Процесс при старте применяет миграции и записывает визитку, если её содержимое ещё не совпадает с кодом. Запрос из задания: ```graphql query { profile { name description skills { name } experience { company position } projects { name } } } ``` Более полный пример лежит в `examples/profile.graphql`. Остановить и удалить данные: ```powershell docker compose down -v ``` ## Локальная разработка ```powershell Copy-Item .env.example .env docker compose up -d postgres npm install npm test npm run start:dev ``` `npm test` генерирует Prisma Client и гоняет модульные тесты. База для них не нужна. `npm run test:e2e` поднимает приложение на PostgreSQL из `.env` и проверяет Sandbox, пример запроса и число SQL-обращений. Локальный старт перезаписывает `schema.gql`. В контейнере схема держится в памяти: процесс не пишет в файловую систему образа. ## Структура ```text src/ main.ts миграции, затем HTTP config/ проверка окружения health/ / и /health prisma/ клиент и migrate deploy profile/ domain/ визитка, период, план связей application/ сценарий чтения infrastructure/ Prisma, include, отображение строк presentation/ GraphQL-типы, selection set, ошибки seed/ декларативные данные и их запись prisma/schema.prisma prisma/migrations ``` ## Архитектура Визитка — один агрегат. Корень — `Profile`. Навыки, места работы, достижения, проекты, образование и конкурсы не живут отдельно и не имеют собственного сценария изменения. Поэтому это один модуль `profile`, а не четыре сервиса с одинаковой зависимостью от профиля. Слои внутри модуля разделены по ответственности: - GraphQL знает про selection set и форму ответа. Он не собирает SQL. - Сервис проверяет идентификатор и решает, что профиль существует. Он не знает про Prisma. - Репозиторий один раз читает агрегат. Состав `include` зависит от плана связей. - Период вроде `2023 — н.в.` считается при чтении. В базе лежат годы, чтобы не хранить уже отформатированную строку. Запись в базу есть только у сида. Публичного изменения данных нет: визитка — опубликованная модель чтения, а правка содержания проходит через код и ревью. Для этого сервиса мутации были бы другим продуктом. ## Модель `Profile` связан с дочерними строками каскадом. Навык уникален в пределах профиля. Проект ссылается на навыки через `ProjectSkill`, поэтому один и тот же навык не копируется в каждый проект. Подсветки проекта и достижения — отдельные упорядоченные строки, а не массив внутри JSON: их можно дочитать отдельным запросом и не тащить, если поле не выбрано. Порядок на сайте задаёт `sortOrder`. Год окончания `null` означает текущее место. Статус проекта тоже может отсутствовать, если резюме его не называет. ## Вложенные поля и база Корень `profile` смотрит на дерево запроса, включая фрагменты и алиасы, и строит план: какие связи вообще нужны. Репозиторий передаёт в Prisma `include` только для этих связей. Поля ответа читают уже загруженный объект, отдельных resolver-ов на каждую связь нет. Для запроса из задания план такой: навыки, места работы без достижений, проекты без навыков и подсветок. Достижения, образование и ссылки в этот запрос не попадают и не читаются. Prisma получает `include` только по выбранным связям. Драйвер PostgreSQL умеет relation joins, поэтому несколько связей могут уйти одним SQL, но состав этого SQL всё равно ограничен планом. Число обращений не растёт вместе с числом строк: e2e на PostgreSQL проверяет, что два места работы отдают достижения одним чтением, а запрос без достижений таблицу достижений не открывает. Форму `include` для примера из задания фиксирует `profile-query.spec.ts`. DataLoader здесь не используется. Он нужен, когда каждый field resolver ходит в базу сам и одинаковые ключи надо склеить. В этом графе состав запроса известен в корне, и вторая очередь загрузки была бы лишней. Если появится список профилей, которые догружаются из разных родителей, DataLoader встанет на границу репозитория, а не вместо плана. ## Старт и данные `main` сначала запускает `prisma migrate deploy`, затем Nest. Сид выполняется в `onApplicationBootstrap`, уже после соединения с базой. Сид идемпотентный. Содержимое визитки хешируется, хеш лежит в `Profile.contentHash`. Совпадает — запись не трогается, идентификаторы живут между перезапусками. Не совпадает — агрегат заменяется в одной транзакции. Повторный старт двух процессов сериализуется `pg_advisory_xact_lock`, чтобы они не создали две визитки. Prisma 7 ходит в PostgreSQL через драйвер `pg`, без отдельного бинарного query engine. Строка подключения для CLI лежит в `prisma.config.ts`, для приложения её проверяет схема окружения: это должен быть PostgreSQL URL, порт по умолчанию 3000. Клиент Prisma CLI при отсутствии переменной подставляет локальный URL из Compose, чтобы `prisma generate` не требовал поднятую базу. Сам API без `DATABASE_URL` не стартует. Образ собирается в два этапа. В нём остаётся Prisma CLI, потому что миграции применяет тот же процесс, который потом слушает порт. Отдельный init-контейнер не нужен: `docker compose up --build` на пустом volume достаточен. ## Что сознательно не делалось Нет авторизации и мутаций: визитка публичная. Нет пагинации: объём фиксирован и мал. Нет общего маппера «любой GraphQL-запрос в любой Prisma include»: схема маленькая, явный план читается и тестируется. Месяцы работы не заполнялись, где резюме указывает только год.