mission у КА хранился свободным x-localized текстом (краткое описание),
дублировался, рассинхронизировался, не был связан со справочником
mission (где уже есть программы миссий + поле spacecraft_codes).
Теперь mission — строка-код, x-references → mission.code. Референсная
целостность + единый источник.
Маппинг 14 КА → 9 миссий взят из mission.spacecraft_codes (обратная
связь уже была в данных). Проверено: все 14 мапятся однозначно,
0 пропусков, 0 конфликтов.
- 0027-spacecraft-mission-fk.xml — Liquibase-миграция существующих БД:
схема + 14 записей одной транзакцией прямым SQL (смена типа поля —
chicken-and-egg, как 0020 raw SQL минует Envers/outbox; forward-only).
schema_version → 2.0.0.
- cuod-bundle: spacecraft.schema.json mission → x-references,
records/spacecraft.records.json — 14 mission → код, manifest 2.0.0.
Свежие установки получают новую форму сразу.
Caveat: миграция через raw SQL не пишет Envers _AUD и Kafka outbox —
проекции read-api нужно пересобрать после деплоя (как при 0020).
1. reviews.tsx: ReviewDrawer звал useLiveRecord для ЛЮБОГО черновика,
включая CREATE. У CREATE-черновика live-записи нет по определению →
GET /dictionaries/{id}/records/{key} → 404 (красный шум в консоли на
каждый CREATE-draft). Теперь для CREATE запрос не запускается, блок
«текущая версия» показывает «нет текущей версии».
2. modal.tsx: Modal рендерил DialogTitle только при наличии prop title.
Без title Radix писал в консоль «DialogContent requires a DialogTitle»
+ warning про aria-describedby. Теперь Modal всегда рендерит DialogTitle
(sr-only fallback) и гасит aria-describedby когда описания нет.
Принцип: клик по «Сохранить» не должен оставаться без реакции.
Раньше submit мог молча оборваться: AJV-ошибка ложилась через setError
на nested-путь (data.<field>.<locale>), а FieldError читает data.<field>
→ ошибка невидима, форма «не реагирует».
Добавлено:
- blockedMsg — состояние с причиной, рендерится видимым Alert сверху
формы при любой блокировке submit (validFrom/validTo, AJV).
- onInvalid — обработчик RHF handleSubmit на случай когда RHF сам
блокирует submit (required-поля); тоже ставит blockedMsg.
- scrollIntoView к началу формы — Alert гарантированно в зоне видимости.
Теперь Save либо сохраняет, либо показывает «Не удалось сохранить —
проверьте поля: …». Молчания не будет.
Симптом: «Сохранить» (create и edit) ничего не делал, ошибок не видно.
Причина: x-localized поле рендерит Controller на каждую локаль
(data.<field>.<locale>). RHF при сабмите реконструирует values из имён
зарегистрированных полей и материализует родительский объект даже когда
исходное значение было null/отсутствовало → optional поле (например
mission) превращалось в { "ru-RU": undefined, "en-US": undefined }.
AJV отвергал undefined-локали ("must be string"), submit() делал return
до onSubmit — запрос не уходил. Ошибка ложилась через setError на
data.<field>.<locale>, а FieldError читает data.<field> → невидима.
Фикс: pruneEmpty() в submit() рекурсивно убирает undefined и опустевшие
объекты из data перед AJV-валидацией и отправкой — нетронутое
optional-поле снова валидируется как «отсутствует». Не трогает
null/''/0/false.
Откат временной диагностики [ordinis-diag] из MR !251.
Regression-тест падает без фикса, проходит с фиксом.
ВРЕМЕННО — для поиска причины «Save ничего не делает». Откатить
после диагностики (все логи помечены [ordinis-diag]).
SchemaDrivenForm: console.warn в submit() — событие формы, RHF
onInvalid, результат AJV, ветки return, вызов onSubmit.
dictionaries.$name: логи в route.handleSubmit + createMut/updateMut
mutate + onSuccess/onError со статусом ответа.
Также добавлен постоянный регресс-тест: повторный submit после
исправления валидации вызывает onSubmit.
GlobalExceptionHandler не имел @ExceptionHandler для
MethodArgumentNotValidException — провал bean-валидации @Valid
@RequestBody (@NotBlank businessKey, @NotNull data и т.п.) падал в
generic Exception catch → HTTP 500 internal_error с пустым traceId.
admin-ui на создании/редактировании записи получал бесполезный
«Internal server error» без полей вместо 400 с пофайловой разбивкой.
Добавлен handler → 400 validation_failed с FieldError-списком.
Покрыто GlobalExceptionHandlerTest.
Файлы лежат в docs/integration/ с прошлых MR (!243, !247, !248), но в
toc.yaml не были включены — Diplodoc не строил их в /docs nav, и
пользователи могли попасть на них только по прямой ссылке.
Добавил три entries в секцию «Интеграция по API» сверху списка:
- 🚀 Quick Start (15 минут) → api-quickstart.md
- 📋 Cheat Sheet → api-cheatsheet.md
- ✅ Регламент — что заводить в НСИ → dictionary-criteria.md
После merge ordinis-docs container пересоберётся, и в admin UI ссылка
"Docs" (top-right) откроет страницу с этими entries в left nav.
Cheatsheet был отдельным файлом — теперь дополнительно встроен в
api-quickstart.md как Приложение A. Reasoning:
* Single source — интегратор открывает quickstart один раз, дальше
живёт в приложении (search в одном файле, не прыгает между двумя)
* Standalone api-cheatsheet.md сохранён для прямых ссылок и Postman README
* Section 13 обновлён: ссылка на Приложение + Postman collection
* Из appended body убраны self-references и дубликаты
Размер: 587 → 874 строк (+287 — cheatsheet body).
После Postman demo audit — в cheatsheet не хватало живых примеров,
которые можно скопировать и сразу проверить. Добавил:
* FK-зависимость:
GET /dictionaries/satellite_type/dependents →
[{sourceDict: spacecraft, sourceField: satellite_type_code,
onClose: BLOCK, activeRecordsInSourceDict: 14}]
+ список других живых FK на staging (ground_station, country etc).
* Локализация записи через Accept-Language:
ru: НАСА / Глобальный мониторинг суши и атмосферы
en: NASA / Global land and atmosphere monitoring
+ warning что ?locale= игнорируется и schema endpoint вообще
не локализуется.
* Time-travel: 14.05.2026 12:00 → 200, 14.05.2026 00:00 → 404
record_not_active (ожидаемое поведение, не bug).
* History и changelog/diff живые примеры с test1.
Также убрал password-grant из Auth (используется client_credentials —
это правильный flow для server-to-server интеграторов). И добавил
dependents проверку в 60-second smoke.
Tested: GET /dictionaries/spacecraft с Accept-Language: en-US возвращает
schemaJson с descriptions на ru (defaultLocale). Header игнорируется на
этом endpoint.
Локализация (Accept-Language header) работает только на records-side
(read-api DictionaryReadController). DictionaryDefinitionController
(writer/ordinis-app) возвращает schema без locale negotiation.
Cheatsheet обновлён, добавлен warning в query-params секцию.
После testing на staging выявлено: backend парсит локаль ТОЛЬКО из
Accept-Language header. ?locale=en-US query param сейчас игнорируется
(сервер возвращает default ru-RU вне зависимости от значения).
Реальный demo (spacecraft/1999-068A):
default (без header): operator: "НАСА"
Accept-Language: en-US: operator: "NASA"
?locale=en-US (без header): operator: "НАСА" ← не работает
Cheatsheet + quickstart обновлены. Postman collection тоже (используется
header). ?locale= query — отдельный backend feature request в roadmap.
ClassCastException on every GET /api/v1/dictionaries/{name}/snapshots:
java.lang.ClassCastException: class java.time.Instant cannot be cast
to class java.sql.Timestamp
Root cause: pgJDBC 42.7+ возвращает timestamptz из native query как
java.time.Instant, а старый код жёстко кастил в java.sql.Timestamp.
42.6 возвращал Timestamp — отсюда и тесты с Timestamp проходили,
а runtime ломался.
Fix: helper `toBucketOffsetDateTime(Object raw, OffsetDateTime now)`
ловит все 3 возможных типа от драйвера:
- java.sql.Timestamp (pgJDBC 42.6 и ранее)
- java.time.Instant (pgJDBC 42.7+ — current behavior)
- java.time.OffsetDateTime (если jdbc.timestamp.return-type=offset-date-time)
При любом другом типе бросаем IllegalStateException — fail-loud вместо
silent CCE на каждом запросе.
Tests:
+ snapshots_acceptsInstantFromPg42_7Driver — regression на конкретный
bug (Instant from current driver)
+ snapshots_acceptsOffsetDateTime — defensive coverage
+ snapshots_rejectsUnexpectedTimestampType — fail-loud branch
Existing tests (с Timestamp) сохранены для backward-compat.
Top-left dark-glass input на странице /graph. Substring match
case-insensitive по name + displayName (admin помнит либо
technical id `antenna`, либо русское название `Антенны`).
UX:
- Matched nodes остаются в full opacity, остальные dim'ятся (0.25) —
как при hover-focus. Force-layout не трогаем (positions стабильны).
- При единственном match → matched node получает focused border
(orange accent + glow) — легко найти результат.
- Edges светятся только когда оба конца — match. Иначе dim.
- Counter chip `N из M` под input'ом + `ничего не найдено` для empty.
- Esc clears query и снимает focus. ✕-кнопка тоже clears (focus
остаётся на input).
- Search dominates над hover dim — non-matched nodes остаются dim
даже если hover их затрагивает.
i18n: graph.search.placeholder / .empty / .count для ru-RU и en-US.
Force-simulation не пересчитываем при изменении query — это намеренно.
Иначе nodes прыгали бы по экрану на каждый символ, ломая spatial
recognition (юзер уже привык где какой node лежит).
Восемь дефектов из QA-отчёта одним MR.
#1 — Records-table теряла первую колонку после закрытия Create-modal.
Корень: lg:grid-cols-[220px_1fr] без minmax(0,…) — 1fr имел implicit
min-content (=max-content), позволяя table вырасти шире viewport.
После закрытия Radix Dialog снималось body padding-right (~15px), grid
пересчитывался и клиппил колонку под overflow-hidden card.
Fix: grid-cols-[220px_minmax(0,1fr)] + убран overflow-hidden с card
(Table primitive имеет свой overflow-x-auto).
#2 — Esc закрывал modal с задержкой (визуал/DOM рассинхрон).
duration-200 на slow CPU ощущался как несколько кадров между state=closed
и unmount. Снижено до duration-150 — совпадает с tw-animate-css дефолтами.
#3 — «+ Создать» обрезалось до «+ Соз» на ~1075px на detail-странице.
EditorTabBar: 6 tabs + 3 кнопки actions в одном flex row без shrink-0
давили друг друга. Решение: tabs в overflow-x-auto wrapper с min-w-0,
actions в shrink-0 контейнере — tabs скроллятся, actions всегда видны
полностью. scrollbar скрыт визуально (height-консистентность).
#4 — Обрезка названий в records-table при PUBLIC+INTERNAL.
Добавлен div.overflow-x-auto вокруг table + min-w-[760px] на table +
min-w-[180px] max-w-[320px] truncate на name cell. Catalog table также
получает min-w-[220px] на name column.
#5 — Заголовки колонок и кнопка «Граф» не локализованы на EN.
defaultValue:'Название' inline в коде падал на ru-text при EN locale.
Вынесены в i18n: dict.col.title/id/bundle/records/updated/outgoingFk/
incomingFk + dict.list.graph для обеих локалей.
#6 — Modal backdrop почти невидим.
bg-black/40 + 1px blur → bg-black/55 + 2px blur. Заметно темнее при
сохранении читабельности фона, фокус на modal.
#7 — Неестественная подсказка в форме «Создать»:
«Имя оставьте уникальное; версия сбросится на 1.0.0» →
«Укажите уникальное имя — версия сбросится на 1.0.0».
#8 — «Главная» в сайдбаре сбивала active state.
Клик на «Главную» вёл на `/` → redirect на /dictionaries → подсветка
уезжала на «Справочники». Logo сверху уже даёт home affordance, поэтому
duplicate nav item удалён (desktop + mobile).