ADR 0116 — ActionType (и будущие типы) генерируются из контракта OpenAPI, а не копируются вручную
- Статус: Принят
- Дата: 2026-08-28
- Контекст: погашает техдолг, названный в
docs/architecture.md("TheActionTypeunion is a copy, and a copy ages") и отслеживаемый вdocs/explanation/backlog.md; объединение уже дважды расходилось с бэкендом в продакшене - Не заменяет:
apps/web/src/lib/api-types.ts— большая его часть по-прежнему пишется вручную; см. Решение, пункт 3
Контекст
apps/web/src/lib/api-types.ts (~1600 строк) вручную отражает сущности из
apps/api/src/domain/**, намеренно: apps/web не зависит от серверного кода,
и это говорит сам заголовок файла. Для большей части файла это был правильный
выбор — это маленькие, стабильные типы в форме UI. Это был НЕПРАВИЛЬНЫЙ выбор
для одного конкретного типа: ActionType.
ActionType (apps/api/src/domain/actions/decide.ts, ACTION_TYPES) не
имеет формы UI — это исчерпывающий список, по которому decide() делает
switch, и каждый потребитель дальше (ApprovalCard, реестры
VERBO_DA_ACAO/FRASE_DA_ACAO в lib/aprovacoes.ts) нуждается в нём
полностью, иначе молча деградирует. Он ДВАЖДЫ расходился в продакшене с
вручную скопированным объединением в api-types.ts, оба раза незаметно для
компилятора, потому что apps/api не является зависимостью apps/web:
- Трёх типов bootstrap Gitflow (
git_repo_create,git_branch_create,git_branch_protect) не было в объединении web.ACTION_ICON[actionType]вApprovalCardвозвращал для нихundefinedи ронял весь экран сессии — у каждого проекта, bootstrapped против реального provider, была сессия, которая не открывалась. parallelize/raise_max_parallelвышли в ФАЗЕ 14d, и никто не вспомнил добавить их в копию web.
Единственное, что ловит это сегодня, — apps/web/src/lib/aprovacoes.test.ts,
который во время теста читает исходный текст decide.ts и проваливает тип
без фразы. Это реальная сетка безопасности, но времени выполнения,
обнаруживаемая в pnpm test, а не структурная, которую компилятор навязывает
при каждой правке.
Отдельно apps/api/src/scripts/export-openapi.ts уже экспортирует весь
документ OpenAPI, а docs/reference/openapi.json уже является генерируемым
артефактом с проверкой дрейфа (docs/.docmap.yml, правило
referencia-openapi; docs:generate/docs:check). ACTION_TYPES уже
выставлен там как настоящий enum JSON Schema — три DTO (ProposeActionDto,
CreateActionInternalDto, ProposedActionResponseDto) объявляют
@ApiProperty({ enum: ACTION_TYPES }) в поле actionType, так что 17
значений уже проходят круг через контракт, которому web уже должен доверять во
всём остальном.
Решение
1. openapi-typescript (^7.13.0) генерирует apps/web/src/lib/api-types.generated.ts из docs/reference/openapi.json
Выбран вместо самописного генератора, потому что превращение enum JSON
Schema (и, если файл вырастет, целых форм request/response) в объединение
TypeScript — ровно та задача, которую решает библиотека, и она фактический
стандарт для этого — это та «тяжёлая зависимость», которую правило CLAUDE.md
требует обосновать, и обоснование — два инцидента в продакшене выше, а не
удобство.
На входе — ЗАКОММИЧЕННЫЙ docs/reference/openapi.json, а не новый сцепленный
запуск export-openapi.ts. У этого файла уже есть навязанный инвариант
свежести (docs:generate --check, запускаемый в job guardiao из
.github/workflows/docs-check.yml) — читать живой экспорт из apps/web
означало бы поднимать весь AppModule из пакета, у которого нет зависимости
от NestJS и нет причин её получать, чтобы заново выводить гарантию, которая
уже существует по соседству.
Два npm-скрипта, в apps/web/package.json (не в apps/api): ПОТРЕБИТЕЛЬ
решает, когда регенерировать, а источник — файл, уже находящийся в
репозитории, а не живой процесс api — то, что apps/web нуждался бы в
поднятом dev-сервере api, чтобы получить собственные типы, было бы худшей
зависимостью, чем та, которую этот ADR убирает.
openapi:types— пишетsrc/lib/api-types.generated.ts.openapi:types:check— нативный режим--checkбиблиотеки: пересчитывает и сравнивает, никогда не пишет. Подключён вdocs-check.ymlсразу после уже существующего шага свежестиopenapi.jsonи зависит от того, что тот выполнится раньше —--checkздесь надёжен только тогда, когда уже известно, чтоopenapi.jsonактуален.
2. Затвор свежести docs/reference/openapi.json: переиспользован, а не продублирован
Правило referencia-openapi в docs/.docmap.yml уже наблюдает каждый DTO и
контроллер и требует, чтобы openapi.json/манифест MDX двигались вместе с
ними. apps/web/src/lib/api-types.generated.ts вошёл в список docs: ТОГО ЖЕ
правила, а не в новое правило с копией того же watch: — он генерируется ИЗ
openapi.json, тем же триггером, и второе правило лишь дрейфовало бы от
списка globs первого.
3. В этом изменении мигрирует только ActionType — не остальные ~1600 строк
Сам заголовок api-types.ts уже проводит настоящую линию: типы, действительно
общие для api и web (HealthStatus, GitProviderName), живут в
packages/shared, а не здесь; всё остальное в файле — вручную
поддерживаемое отражение доменных сущностей. Перегенерировать весь файл
разом, не проходя потребителя за потребителем, было бы ровно тем видом
изменения, от которого предостерегает CLAUDE.md («Не рефакторить код
завершённой фазы без явного запроса»), применённым к 1600 строкам, которые
никто не перечитал для этого PR. ActionType — единственный тип с
продемонстрированной и повторяющейся ценой в продакшене из-за ручного
копирования; он переносится. Остальные строки api-types.ts остаются
нетронутыми и по-прежнему пишутся вручную, пересматриваемые тип за типом в
будущих PR так же, как этот.
api-types.ts теперь делает:
import type { components } from './api-types.generated';
export type ActionType =
components['schemas']['ProposedActionResponseDto']['actionType'];
ProposedActionResponseDto выбран вместо ProposeActionDto /
CreateActionInternalDto (которые объявляют тот же enum из 17 значений),
потому что это форма ответа, которую web реально читает на каждом экране
одобрений; сегодня все три — одно и то же объединение, а NestJS Swagger не
выпускает единую именованную схему ActionType, на которую можно было бы
указать вместо этого, — он встраивает enum в каждый DTO, поскольку ни один из
трёх не использует @ApiExtraModels/общее имя enum. Глобальное именование
enum в документе OpenAPI — будущая работа, не нужная для этого исправления:
actionType у ProposedActionResponseDto — простое объединение строковых
литералов, структурно идентичное типу, который он заменяет.
4. aprovacoes.test.ts (ФАЗА 19) остаётся — его работа изменилась, а не исчезла
Тест делал две вещи: доказывал, что у каждого ActionType бэкенда есть запись
глагола/фразы (СУЩЕСТВОВАНИЕ), и доказывал, что эта запись не пуста, на
португальском и с правильной пунктуацией (СОДЕРЖАНИЕ). Существование теперь
ловит и компилятор — Record<ActionType, string> в lib/aprovacoes.ts
перестаёт компилироваться в тот момент, когда значение из
api-types.generated.ts остаётся без ключа, ещё до запуска pnpm test. Это
делает проверку существования в тесте ИЗБЫТОЧНОЙ при более сильной и более
ранней гарантии — но его утверждения о содержании (длина фразы, точка в конце,
никакой сырой snake_case, просачивающийся в португальскую прозу) — не то, что
проверяет система типов, и они ценны как прежде. Удаление теста обменяло бы
ремень и подтяжки на отсутствие подтяжек; он остаётся, задокументированный
здесь как частично превзойдённый, а не молча сохраняемый по инерции.
Последствия
За
- Класс дефекта, дважды достигший продакшена (
ApprovalCard, падающий наActionType, которого web не знал), теперь ловитсяtsc, в каждом PR, трогающемdecide.tsи регенерирующемopenapi.json, — а не обнаруживается вживую. docs-check.ymlпадает ГРОМКО (::error::) с точной командой для запуска, тот же UX, что у любого другого генерируемого артефакта в репозитории.- Никаких изменений в HTTP-контракте, DTO или поведении времени выполнения — это изменение только типов, во время разработки.
Против
- Вторая поверхность devDependency для
apps/web(openapi-typescript) только для генерации кода; она никогда не попадает в бандл браузера (api-types.generated.ts— исходник, а не сама библиотека). api-types.generated.tsнасчитывает ~18 тысяч строк (ВСЯ поверхность OpenAPI, каждый путь и DTO), хотя из него сегодня потребляется лишь один тип —--root-types/фильтрация схем могли бы уменьшить его позже, если это станет реальной ценой (он не загружается во время выполнения; это исходный файл в формате.d.ts, который читаетtscи никогда не упаковывает vite).api-types.tsтеперь для одного типа — реэкспорт, а не определение, — читающему нужно пройти лишний шаг (api-types.ts→api-types.generated.ts→openapi.json→decide.ts), чтобы найти источник истины. Комментарий в точке импорта вapi-types.tsназывает каждое звено этой цепочки именно поэтому.
Рассмотренные альтернативы
Перегенерировать весь api-types.ts из документа OpenAPI в одном PR.
Отвергнуто — файл не является чистым отражением сущностей (packages/shared
уже отделяет действительно общее), а оптовая замена без прослеживания каждой
из ~1600 строк до их потребителей рисковала бы молчаливыми изменениями формы,
которые никто не ревьюил. Миграция тип за типом, начиная с доказанно
дорогого, медленнее, но каждый шаг проверяем.
Генерировать из живого запуска export-openapi.ts вместо закоммиченного
openapi.json. Отвергнуто в пункте 1 — потребовало бы, чтобы apps/web
зависел от рантайма NestJS из apps/api или вызывал его через shell, чтобы
заново выводить гарантию свежести, которую docs:check уже даёт бесплатно.
Удалить aprovacoes.test.ts теперь, когда компилятор покрывает
исчерпываемость. Отвергнуто в пункте 4 — утверждения о содержании (качество
фразы) не являются свойствами уровня типов, а удаление файла удалило бы и
комментарий, объясняющий, ПОЧЕМУ существует правило одной фразы на тип
(RN-096).
Ссылки
apps/api/src/domain/actions/decide.ts—ACTION_TYPES, канонический источникapps/web/src/lib/api-types.ts— реэкспорт и комментарий, называющий всю цепочкуapps/web/src/lib/aprovacoes.ts/aprovacoes.test.ts— потребитель, чья исчерпываемость теперь проверяется компиляторомdocs/.docmap.yml, правилоreferencia-openapi— общий затвор свежести.github/workflows/docs-check.yml—openapi:types:check, подключённый после шага свежестиopenapi.jsondocs/architecture.md— "TheActionTypeunion is a copy, and a copy ages"- RN-096 — каждому
ActionTypeнужна фраза на португальском