Перейти к основному содержимому

ADR 0116 — ActionType (и будущие типы) генерируются из контракта OpenAPI, а не копируются вручную

  • Статус: Принят
  • Дата: 2026-08-28
  • Контекст: погашает техдолг, названный в docs/architecture.md ("The ActionType union 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:

  1. Трёх типов bootstrap Gitflow (git_repo_create, git_branch_create, git_branch_protect) не было в объединении web. ACTION_ICON[actionType] в ApprovalCard возвращал для них undefined и ронял весь экран сессии — у каждого проекта, bootstrapped против реального provider, была сессия, которая не открывалась.
  2. 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.json
  • docs/architecture.md — "The ActionType union is a copy, and a copy ages"
  • RN-096 — каждому ActionType нужна фраза на португальском