124 lines
10 KiB
Markdown
124 lines
10 KiB
Markdown
# Цифровая визитка
|
||
|
||
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»: схема маленькая, явный план читается и тестируется.
|