Bug: parseSchemaJson fallback на line 196 возвращал {kind: 'string'}
для unknown types (object с nested properties, array of objects, etc.).
При save back to schema это давало lossy round-trip:
spacecraft.orbit (object с 7 nested fields)
→ parseSchemaJson → kind: 'string'
→ propertyToSchema → {type: 'string'}
→ diffSchemas → BREAKING CHANGE 'object → string' → save blocked
Симптом видел user: edit modal spacecraft показывает 'НЕСОВМЕСТИМОЕ
ИЗМЕНЕНИЕ СХЕМЫ' для orbit, save заблокирован, хотя admin не трогал
orbit.
Fix:
- Новый PropertyKind 'opaque' для unknown types
- PropertyDef.rawSchema (optional JsonSchema) хранит оригинал
- parseSchemaJson fallback теперь {kind:'opaque', rawSchema:raw}
- propertyToSchema для opaque возвращает rawSchema verbatim (с
description override если admin менял в Поля-табе)
- PropertyEditor: 'opaque' исключён из Type dropdown (admin не может
вручную выбрать), для existing opaque поля показывается warning
card с raw JSON read-only + hint что редактируется через JSON-таб
Test coverage: 73 tests passing (5 + 10 SchemaBuilder + 7 + ...).
Эффект: edit любого справочника с nested objects (orbit) больше не
триггерит false-positive breaking change.
i18n: schema.kind.opaque + schema.opaque.{title,hint} × ru-RU/en-US.
@Scheduled sweep раз в час (configurable). Walks all schemas, для каждого
x-references marker считает orphans через JdbcTemplate native SQL
(WHERE source.data->>field NOT IN target dict active records).
Метрика: Gauge per (source_dict, source_field, target_dict, target_field).
Cardinality bounded by FK count в bundles (~5 для cuod v1.2.1).
Conditional: ordinis.references.scan-enabled=true (default off, включаем
явно в prod values). Read-only, no write impact.
OrphanReferenceQuery (ordinis-domain): native SQL helper, identifier
validation против SQL injection. Field name regex check т.к. JSONB key
path не bind'ится через @Param.
Tests: 4 в OrphanReferenceScannerTest (parseRef edge cases). Реальный
SQL логику покроет integration тест на staging данных.
Total project tests: 191 → 195.
Демо нового x-references validator (commit 4142678).
До этого FK relationships были только в description как "FK на ...".
Теперь app-level integrity check на CRUD:
- spacecraft.satellite_type_code → satellite_type.code
- consumer.consumer_type → consumer_type.code
- deliverable_type.typical_processing_level → processing_level.code
- orbital_regime.parent_orbit_type → orbit_type.code
- antenna.ground_station → ground_station.code
Effect: POST/PUT записи с invalid FK → 422 ValidationError code
x_references_target_not_found, не silent broken reference.
Bundle version bumped 1.2.0 → 1.2.1 (breaking-change detection
прокатит — schemaVersion полей не менялись, только metadata).
JSON Schema extension `x-references: "dict_name.field"` объявляет что
значение поля должно ссылаться на active record в указанном словаре.
На POST/PUT record service валидирует existence + scope visibility.
Пример:
{
"type":"object",
"properties":{
"satTypeCode":{
"type":"string",
"x-references":"satellite_type.code"
}
}
}
ReferenceValidator (ordinis-rest-api/service/reference/):
- Walks schema properties, finds x-references markers
- Для каждого ref: lookup target dictionary + active record by JSONB
field value (использует existing findActiveByJsonField repo method)
- Returns ValidationError list, intergrates с RecordValidator pipeline
через DictionaryRecordService.validate()
- Scope hide: target dict не accessible → "not_found" error (не
"scope_denied"), чтобы не leak'ало existence
- Optional поля: если value missing, skip (JSON Schema validation
отдельно отлавливает required)
Error codes:
- x_references_malformed (spec не "dict.field")
- x_references_dict_not_found (target dictionary не существует)
- x_references_target_not_found (referenced record нет / scope hidden)
- x_references_non_string (v1 поддерживает только string values)
v1 ограничения (документированы в JavaDoc):
- Top-level fields only (nested objects не поддержаны)
- Only string values
- No cascade on delete/close (orphan FK alert — v2.5)
Tests (15 в ReferenceValidatorTest):
- parseRef: valid, empty, missing dot, trailing dot, nested paths
- validate: no refs, missing value (optional skip), existing record OK,
missing record error, missing dictionary error, scope-hidden error,
non-string value error, malformed spec error, multiple fields
validated independently, null schema graceful
Total project tests: 176 → 191.
Note: Mockito не может mock entity classes на JDK 25 (subclass
mock-maker limitation). Используем real entity ctor для
DictionaryDefinition + DictionaryRecord в тестах.
Закрывает Phase 5 deferred item. Завершает webhooks v2 покрытие.
WebhookE2ETest (2 tests):
Test 1: success path
- Стартует JDK HttpServer на random localhost port (no extra deps)
- Создаёт WebhookSubscription указывающий на /hook
- POST record в dictionary → outbox event
- Ждёт что receiver получит POST в течение 20s
- Verify body содержит business_key
- Verify HMAC signature header валиден (HmacSigner.sign(secret, body)
matches X-Ordinis-Signature)
- Verify Ordinis headers (X-Ordinis-Event-Type, X-Ordinis-Scope=PUBLIC)
- Verify DB delivery row.status = delivered + lastStatusCode = 200
Test 2: retry path
- Receiver всегда 500
- POST record → outbox → dispatcher tries deliver
- Verify delivery transitions to status=retrying + lastStatusCode=500
- Не ждём DLQ (1000 attempts × 1m backoff = недели)
SSRF guard через ordinis.webhook.allowed-hosts=localhost,127.0.0.1
test override (production deny private CIDR остаётся включённым).
Test config:
- ordinis.outbox.enabled=true (для outbox poller)
- ordinis.webhook.enabled=true (для dispatcher)
- match-interval-ms=200, send-interval-ms=200 (faster tests)
Webhooks v2 — все 5 phases закрыты.
Дополнение к bbox: ?polygon=<GeoJSON> для произвольных полигонов
(ground stations coverage, custom AOI). Один из bbox/polygon —
взаимоисключающие; передача обоих → 400.
Repository:
- findActiveByPolygonGeoJson native query через PostGIS
ST_GeomFromGeoJSON(polygon) → ST_SetSRID(_, 4326). DB-side parsing,
не нужен jts-io-common.
GeoJsonPolygon validator (app-side, structural):
- Парсит JSON, проверяет {"type":"Polygon","coordinates":[[...]]}
- RFC 7946 closed-ring check: first point == last
- >= 4 points в каждом ring (3 вертекса + closing point)
- Поддержка holes (multiple rings)
- Coordinate range validation: lon ∈ [-180,180], lat ∈ [-90,90]
- DoS guards: MAX_JSON_BYTES = 1MB, MAX_POINTS = 50_000
- Canonical re-serialize (whitespace stripped) перед DB
Tests (13 в GeoJsonPolygonTest):
- Valid square + polygon with hole
- Reject empty/null/non-JSON/non-object/wrong-type/missing-coords
- Reject too-few points / unclosed ring
- Reject lon/lat out of range
- Reject malformed point [no lat]
- Reject too-large JSON
Total project tests: 161 → 174.
API now supports:
GET /api/v1/{dict}/records?bbox=west,south,east,north
GET /api/v1/{dict}/records?polygon={"type":"Polygon",...}
Альтум-задача (rect AOI) и геопортал (polygon AOI) разблокированы
от client-side full-fetch + filter.
v2 фича: Альтум-задача (заказ съёмки) использует bbox region. До
этого consumers fetch'или ВСЕ записи и filter'или client-side. Теперь
PostGIS ST_Intersects на server-side через GiST index.
API:
GET /api/v1/{dictionaryName}/records?bbox=west,south,east,north
Coords в SRID 4326 (longitude, latitude). Параметр optional —
отсутствует = старое поведение (full list).
BoundingBox helper (ordinis-read-api/spatial/):
- Парсинг "west,south,east,north" с валидацией
- Range checks: lon ∈ [-180,180], lat ∈ [-90,90]
- west <= east (anti-meridian crossing rejected в v1)
- south <= north
- Понятные error messages → 400 Bad Request через BadBboxException
Repository (ordinis-domain):
- findActiveByBbox native query, ST_MakeEnvelope(:west,:south,:east,
:north,4326) ST_Intersects на geometry колонке. GiST index hit.
- Записи с geometry IS NULL автоматически отфильтровываются.
Tests (12 в BoundingBoxTest):
- Valid parsing + negative coords + whitespace
- Reject empty/null/wrong-count/non-numeric
- Range validation (lon/lat bounds)
- Anti-meridian + inverted N/S detected
- Area calc sanity
Total project tests: 149 → 161.
Polygon GeoJSON support — отложен (нужен jts-io-common dep). Bbox
покрывает Альтум use case + 80% other consumers.
5 ошибок TS, накопленных за прошлые итерации — теперь pnpm tsc зелёный:
- SchemaDrivenForm: убран unused import formatIsoDateTime + unused
prop `path` (передавался в FieldRenderer, но FieldRendererProps его
не объявлял и FieldBody не использовал)
- dictionaries.$name: удалена неиспользуемая функция toIsoDate
- api/client.ts: добавлен `$schema?: string` в JsonSchema type
(используется в buildSchemaJson и тестируется в types.test.ts)
Перенесли scope-визуальные константы (SCOPE_ORDER, SCOPE_DOT,
SCOPE_ACCENT) в `lib/scope-style.ts` для переиспользования. На detail
странице (`dictionaries/$name`) добавили top accent strip 4px цветом
scope + цветной чип в breadcrumb рядом с back-link. Теперь при переходе
из списка в карточку scope сразу виден без чтения badge в таблице.
40 справочников в плоском grid тяжело сканировать. Разделили на секции
PUBLIC / INTERNAL / RESTRICTED с цветными accent-полосами слева у карточек
(emerald / amber / rose). Search фильтрует across all sections, пустые
секции скрыты. Цвет даёт instant scan, секции — explicit разделение.
Spring Boot Micrometer по дефолту эмитит только _count/_sum/_max
для http.server.requests — без _bucket нельзя посчитать
histogram_quantile() в Prometheus, p50/p95/p99 latency дашборды
получают "No data".
Включён percentiles-histogram + кастомные SLO bucket boundaries
от 5ms до 5s — покрывают realistic range read-api JSON endpoints.
Server-side histograms точнее client-side percentiles (по которым
нельзя aggregate across instances).
Применить: build + push image + helm upgrade. После рестарта
ordinis-app/read-api/projection-writer Grafana дашборды покажут
latency графики.
Backend:
- DictionaryRecordRepository.countActiveGroupedByDictionary — один GROUP BY
по всем справочникам с фильтром по scope, без N+1.
- DictionaryResponse.recordCount (nullable) + factory from(d, count).
- DictionaryDefinitionController передаёт счётчики в list/get,
фильтрованные по currentScopes() пользователя.
Frontend:
- SearchInput над сеткой карточек, client-side filter по
name/displayName/description (case-insensitive, useDeferredValue).
- Badge "N записей" (i18n plural ru/en) в углу карточки.
- "Показано N из M" рядом с поиском.
- EmptyState когда поиск пустой.
projection-writer крашится в bootstrap на 403 при чтении
secret/data/ordinis/cuod/k8s — путь которого нет ни в одной из vault-policies
(ordinis-app/read-api/projection-writer). Сами секреты приходят через Vault
Agent injector в env-переменные ещё до старта JVM, поэтому spring-cloud-vault
как config source избыточен.
writer и read-api ту же 403 получают, но Spring Cloud Vault лог-warning'ом
переваривает. У projection-writer почему-то фатально (возможно из-за
ROTATE-mode на secret lease + redis-зависимости в bootstrap chain). Не лезу
в эту разницу — просто отключаю kv совсем.
Если когда-то понадобится использовать vault как config source — добавить
читать secret/data/ordinis/cuod/k8s в политики или сменить application-name
на ordinis-projection-writer + создать отдельный путь.
RequireAuth: гейтит весь UI по аутентификации. Anonymous юзер вместо
красного "AxiosError 401" получает immediate signinRedirect на Keycloak;
authenticated юзер с истёкшим токеном получает silent renew → fallback на
full redirect. Guards против infinite loop:
- auth.isLoading / activeNavigator — bootstrap или signin/signout уже идёт
- ?error= в URL — вернулись с провалом из Keycloak, показываем сообщение
- auth.error без isAuthenticated — Keycloak недоступен, показываем сообщение
main.tsx: RequireAuth обёрнут вокруг Router/Query, чтобы tan-stack-router и
React Query не делали запросов до auth (иначе 401-каскад на старте).
TokenSync: 401 interceptor теперь редиректит и anonymous (раньше срабатывал
только для authenticated с истёкшим токеном), с теми же guards.
RTL component тесты — 22 теста на 3 ключевых компонента:
- SchemaBuilder (7): empty state, add/remove/reorder properties
- PropertyEditor (10): kind change, required/unique toggle, kind-specific
поля (string→pattern, integer→min/max, enum→values, array_string→uniqueItems),
move up/down disabled на boundaries
- SchemaDrivenForm (5): identity tab с required, переключение на extra,
isPending → save disabled, cancel callback, serverError → Alert
Setup:
- vite.config.ts: test block (jsdom env, setup file, css off)
- src/test/setup.ts: jest-dom matchers, cleanup, HTMLCanvasElement.getContext
stub (lottie-web из @nstart/ui крашит в jsdom без canvas)
- src/test/render.tsx: renderWithI18n helper, форсит ru-RU
(LanguageDetector в node по умолчанию даёт en-US)
Deps: +@testing-library/react +@testing-library/jest-dom
+@testing-library/user-event +jsdom
ScopeContext.canAccess(DataScope) — единая точка проверки доступа,
использует DataScope.visibleTo (PUBLIC видит PUBLIC, INTERNAL видит
PUBLIC+INTERNAL, RESTRICTED видит всё).
DictionaryRecordController:
- requireReadAccess на GET /{businessKey} и /{businessKey}/history
→ 404 dictionary_not_found если scope словаря недоступен (намеренно
скрываем существование INTERNAL/RESTRICTED данных, защита от
enumeration)
- requireWriteAccess на POST/PUT/DELETE
→ 403 scope_insufficient если scope недостаточен для записи
DictionaryDefinitionController:
- list() фильтрует список по canAccess (PUBLIC-юзер не видит INTERNAL
словари в каталоге)
- get() → 404 если недоступен
- create()/update() → 403 если новый scope выше доступа юзера
(защита от scope escalation через PUT)
AuthE2ETest.publicUser_cannotSeeInternalRecords: TODO snять, expectation
обновлён на isNotFound() (раньше было isOk + комментарий о gap).
До этого fix: PUBLIC-юзер мог GET /api/v1/dictionaries/{n}/records/{k}
INTERNAL-словарь на writer-side. Read-api (отдельный модуль) фильтровал
корректно. Issue найден через AuthE2ETest в Phase 2c.
Все 6 AuthE2ETest и 24 unit-теста rest-api зелёные.
Найден production-bug на staging: ordinis-app в CrashLoopBackOff с
NoClassDefFoundError: com/nimbusds/jose/RemoteKeySourceException.
Причина: в Phase 2c e2e тестах я добавил nimbus-jose-jwt 9.37.3 как
test-scope direct dependency в ordinis-app/pom.xml. Maven scope
resolution: direct test-scope побеждает transitive compile-scope,
nimbus исключился из Spring Boot fat jar. На runtime
JwtDecoders.fromIssuerLocation() (использует Nimbus internally) падал.
Fix:
- ordinis-auth/pom.xml: explicit compile-scope dep на nimbus-jose-jwt
(раньше тянулся транзитивно через oauth2-resource-server starter)
- ordinis-app/pom.xml: убрал test-scope direct decl, JwtTestSupport
использует nimbus с compile classpath.
Verified: BOOT-INF/lib/nimbus-jose-jwt-9.37.3.jar в fat jar после rebuild.
e2e AuthE2ETest всё ещё зелёный (12/12).
ordinis-app/pom.xml:
- surefire excludes **/e2e/*E2ETest.java по умолчанию (быстрые unit без Docker)
- profile `e2e`: combine.self override снимает excludes + includes только e2e
.gitlab-ci.yml: новый job maven-e2e:
- maven image + docker:29.1.2-dind service (тот же что docker-* jobs)
- DOCKER_HOST=tcp://docker:2375, TESTCONTAINERS_HOST_OVERRIDE=docker, RYUK_DISABLED=true
- mvn -pl ordinis-app -am -P e2e test
- artifact: junit reports
- rules: backend-changes (как maven-test)
Локально:
mvn -pl ordinis-app -am test # быстро, без Docker
mvn -pl ordinis-app -am -P e2e test # с Docker, ~23 sec для 12 тестов
В CI оба job'а параллельно после path-filter. e2e будет ловить регрессии типа
audit_log INET/scope filter gap которые мы уже нашли через эти тесты.
Покрытие выросло с 1 до 6 e2e тестов через Testcontainers (Postgres+Kafka).
BitemporalE2ETest (4 тесты):
- createThenUpdate_keepsBothVersionsInHistory: v1+v2 в /history, oldValidTo == newValidFrom
- closeRecord_setsValidTo_andGetReturns404: после DELETE active=null, history живёт
- idempotencyKey_returnsCachedResponse_doesntDuplicate: повторный POST с тем
же Idempotency-Key → тот же id, count=1 в БД
- breakingSchemaChange_blockedOnUpdate: documents текущее поведение PUT
/api/v1/dictionaries/{name} с breaking schema (status < 500 = OK)
OutboxKafkaE2ETest (1 тест):
- POST record → outbox event в БД → poller публикует в Kafka → consumer
получает envelope с правильным dictionaryName/dataScope/payload.businessKey
- Pre-create topic через AdminClient чтобы избежать race с auto-create.
- ordinis.outbox.enabled=true + poll-interval=200ms через @SpringBootTest
properties (общий test profile его выключает для скорости остальных тестов).
SmokeE2ETest: UUID-suffixed businessKey чтобы при testcontainers reuse=true
и параллельных тестах не было коллизий.
Logging: outbox DEBUG для диагностики publish flow.
pom.xml: + awaitility для polling-based проверок.
Все 6 тестов зелёные: 1 smoke + 4 bitemporal + 1 outbox-kafka. Total ~16 sec
включая Spring context boot. Reuse=true ускоряет повторные прогоны.
Phase 1 e2e — proof framework работает:
- E2ESupport: Postgres 18 (postgis/postgis:18-3.6) + Kafka (cp-kafka 7.6.0)
через Testcontainers, reuse=true. db-init.sql создаёт postgis+btree_gist
расширения которые ожидает Liquibase 0001.
- application-test.yml: тушит cloud-config/vault/otel/outbox publishing,
включает Liquibase autoconfig. Auth disabled для упрощения.
- SmokeE2ETest: full e2e — POST dictionary → POST record → GET record →
проверка audit_log row. UUID-suffix в dictName для идемпотентности.
- master.xml: relativeToChangelogFile=true для всех include — иначе
Liquibase из ordinis-app classpath не находит changes/X.xml в JAR.
- pom.xml: testcontainers 1.21.3, surefire env DOCKER_HOST + DOCKER_API_VERSION
для macOS Docker Desktop.
Найден реальный prod-баг: audit_log.ip_address был INET, Hibernate JPA не
делает автоматический cast String→INET → INSERT падал. AuditLogger.write
обёрнут в try/catch, поэтому тихо съедал ошибку — на staging audit_log
оставался пустым после CRUD.
- 0012-audit-log-ipaddress-varchar.xml: ALTER COLUMN INET → VARCHAR(64)
- AuditLog entity: убрал columnDefinition="inet", length=64.
Tempo на staging (tempo.monitoring:4318) — single-process, принимает
только traces. Spring Boot OTel autoconfig по умолчанию отправляет всё
по тому же endpoint → 404 на каждой попытке (раз в секунду в логах
writer/read-api/projection-writer).
Override через ENV — можно переключить когда поднимем полный stack
(Mimir для metrics, Loki для logs).
Метрики Prometheus продолжают работать через actuator/prometheus
(scrape снаружи), это отдельный path.
Triage card для on-call: где взять unseal-keys (1Password placeholder,
уточнить с DevOps lead), как запустить script либо ручную процедуру,
что проверить после, troubleshooting типовых проблем.
Также: §7 — план миграции на auto-unseal (Transit / Yandex KMS) когда
определимся с master Vault или KMS-backend.
react-oidc-context + oidc-client-ts. Confidential client тоже работает —
SPA просто не использует client_secret.
- src/auth/oidcConfig.ts — UserManager settings (authority, redirect_uri,
PKCE, automaticSilentRenew). VITE_KEYCLOAK_{URL,REALM,CLIENT_ID} с дефолтами
на nstart realm + ordinis client.
- src/auth/TokenSync.tsx — синкает access_token в localStorage и axios default
header после login/refresh. Existing API код не трогали — interceptor уже
читает localStorage.
Bonus: 401 interceptor триггерит signinSilent → signinRedirect fallback.
- src/auth/AuthBadge.tsx — header'ный бейдж: "Войти" если anonymous, имя +
logout если залогинен.
- main.tsx обёрнут в <AuthProvider>.
- __root.tsx показывает AuthBadge рядом с language switch.
- .env.example с конфигом Keycloak.
- i18n auth.login / auth.logout (RU + EN).
Keycloak client `ordinis` — настроить:
Valid redirect URIs: https://ordinis.k8s.265.nstart.cloud/*,
https://ordinis.k8s.264.nstart.cloud/*,
http://localhost:5173/*
Web Origins: те же + (или "+"), PKCE Code Challenge: S256
Альтум-команда обнаружила что /api/v1/dictionaries/{name}/records даёт 500
и spacecraft_codes ссылаются на несуществующий "RESURS-P-3". Оба бага мои:
1. Read-api живёт на /api/v1/{name}/records (БЕЗ /dictionaries/ префикса).
/dictionaries/* — это admin-write контроллер, у него GET-list нет.
Fix: docs/integration/altum-imaging-order.md + docs/tech/api-integration.md
обновлены с правильными URL'ами + примером ответа из live staging.
2. spacecraft seed имел только 4 КА (Terra, Sentinel-1A/2A, Кондор-ФКА),
а mission.spacecraft_codes ссылался на пустые/RESURS-P-3 — каскад в
"Заказ съёмки" не работал бы.
Fix: добавлено 10 российских КА (Ресурс-П 1-4, Канопус-В 1+5, Метеор-М 2+2-2,
Электро-Л 2, Арктика-М 1) с COSPAR designators как businessKey.
3. mission.records.json + instrument.records.json: spacecraft_codes теперь
ссылается на реально существующие COSPAR-ключи. Каскад работает:
instrument MSU-MR → spacecraft [2014-037A, 2019-038A] → Метеор-М № 2 / 2-2.
После redeploy и идемпотентного пересева:
spacecraft 14 (было 4, +10 РФ)
mission 9 (existing 9, references фиксированы)
instrument 11 (было 10, +МСУ-ГСМ Арктики)
Альтум выдаёт роли формата "ordinis:client:public" / "ordinis:client:internal" /
"ordinis:client:restricted" в realm_access.roles. Старая версия ScopeContext
обрабатывала только "-" / "_" разделители + ORDINIS- префикс — colon-роли
приводили к fallback на PUBLIC.
Теперь normalizeRoleToScope:
- strips "ORDINIS:" (как и "ORDINIS-" / "ORDINIS_")
- берёт сегмент после последнего из "-", "_", ":"
Совместимо с прежними форматами. + 2 unit-теста (всего 13 в ScopeContextTest).
Path-filter (rules: changes: frontend) пропускал docker-admin-ui когда менялся
только backend. Но resolve-tag всегда отдавал общий UPSTREAM_IMAGE_TAG, и
helm в infra тащил admin-ui:<commit>, которого нет в registry → ErrImagePull.
Layer-cache BuildKit делает повторную сборку идентичных слоёв ~30 sec.
Стоимость незначительная, конфигурация надёжнее.
(Backend docker jobs уже собираются всегда — для тех же причин.)
PostgreSQL не может вывести тип параметра OffsetDateTime когда оба боковых
выражения IS NULL ⇒ 500 на /api/v1/admin/audit. Фикс — controller подставляет
0001-01-01/9999-12-31 если from/to не указаны, JPQL использует обычное
сравнение eventTime BETWEEN :from AND :to. Строковые фильтры (dictionary,
user, action, businessKey) остаются с :p IS NULL OR x = :p — они работают
потому что VARCHAR PG может вывести из правой стороны сравнения.
До этого audit_log entity была определена, но никто туда не писал — реальный
аудит шёл только через Hibernate Envers (без user/IP/trace). Теперь:
- AuditLogger service пишет в той же транзакции что и CRUD (DictionaryRecordService).
Захватывает: user (JWT sub либо preferred_username), scope, IP (X-Forwarded-For),
UA, trace_id (MDC), request_id. Не ломает основной flow при сбое — только
громко логирует.
- AuditLogRepository: гибкий search() с nullable фильтрами (dictionary, user,
action, businessKey, from, to) + Pageable.
- AuditAdminController: GET /admin/audit (paginated) + /admin/audit/export.csv
(cap 10k строк против OOM).
Frontend:
- /audit route: фильтр-панель, таблица с цветными бейджами (CREATE/UPDATE/CLOSE),
expand-row с JSON before/after diff, footer IP/UA/req_id, кнопка экспорта CSV.
Pagination 50/100/200.
- /outbox route: stat-cards pending/DLQ + DLQ-таблица с last_error.
- Nav links + i18n (RU + EN).
Tests (Vitest):
- src/lib/dates.ts (вынесено из SchemaDrivenForm + dictionaries.$name): parseFormDate,
formatIsoDate, formatIsoDateTime, extractTime, combineDateTime, localTzOffset, nowIsoLocal
- src/lib/dates.test.ts (20 tests): TZ-safety, pure-date local midnight, far-future filter,
round-trip parse/format, time extraction в local TZ, clear-button (null Date → '')
- src/components/schema/types.test.ts (31 tests): buildSchemaJson всех 9 kinds + x-unique +
x-id-source, parseSchemaJson roundtrip, diffSchemas (8 breaking detection cases),
suggestVersionBump (major/minor/patch + breaking-wins-mix)
CI:
- Откатил dual resolve-tag (BACKEND/FRONTEND) на single resolve-tag.
GitLab variable interpolation в trigger jobs не подхватывает dotenv от needs-job
→ admin-ui-only push не доезжал до infra. Now backwards compat path работает
как раньше с UPSTREAM_IMAGE_TAG.
- Backend docker jobs (maven-package + 4 docker-*) теперь без rules:changes —
catch ImagePullBackOff если frontend push шёл без backend rebuild. BuildKit cache
делает повторный билд идентичных layers ~30 sec.
- Path-filter оставлен на maven-test (тесты не запускаются на frontend-only PR
— они бэкендовые) и на docker-admin-ui (frontend-only).
Run: pnpm test → 51 passed.
Раньше любую запись с validTo<9999 помечал 'закрыта'. Но запись с validTo в
будущем — не закрытая, а либо ЗАПЛАНИРОВАННАЯ (validFrom > now), либо АКТИВНАЯ
(validFrom <= now < validTo). User видел 'закрыта' на v3 которая ещё не настала.
Теперь:
- future: validFrom > now → бейдж 'запланирована' (info), точка orbit/horizon
- active: validFrom <= now < validTo → бейдж 'активна' (success) если не latest
- expired: validTo <= now → 'закрыта' (neutral), точка пустая
- latest active: 'текущая' (primary), точка ultramarain
Бессрочная запись (validTo year>=9999) больше не показывает 'действует до:' —
у бессрочной нет конечной даты, отображение его смущало. i18n: новые ключи
history.active / history.scheduled (RU/EN).
CI: path-filtering (наконец-то)
- Backend jobs (maven-test, maven-package, docker-app/read-api/projection-writer/migrations,
resolve-backend-tag) запускаются только при backend-changes (Java модули, pom.xml, CI yml).
- Frontend (docker-admin-ui, resolve-frontend-tag) — только при ordinis-admin-ui/**.
- Trigger downstream передаёт BACKEND_IMAGE_TAG и/или FRONTEND_IMAGE_TAG. Backwards
compat для старого UPSTREAM_IMAGE_TAG сохранён в infra pipeline.
- Frontend-only PR теперь не катит backend rolling update (raньше каждый push
тегал ВСЕ сервисы новым sha и роллил).
Edit dialog UX (bitemporal):
- При Edit validFrom default = now() (а не historical validFrom существующей записи).
Раньше юзер сдвигал validFrom назад → backend не находил активной версии в этот
момент → 'record_not_active'. Теперь по умолчанию 'новая версия с этого момента'.
- nowIsoLocal() в timezone браузера (с offset).
- validTo default пустой = бессрочно.
Backend: понятное сообщение для record_not_active с инструкцией.
Backend (defensive):
- GlobalExceptionHandler ловит HttpMessageNotReadableException → 400 c понятным
сообщением вместо 500 'Internal server error'.
- DateTimeParseException → code='invalid_date_format', сообщение с подсказкой
ISO формата (например 2026-05-08T14:30:00+03:00).
- InvalidFormatException → code='invalid_field_format' с именем сломанного поля.
- Раньше падало 500 если фронт отправлял 'YYYY-MM-DD' вместо OffsetDateTime
(из-за browser cache на старом bundle).
Frontend:
- Submit-валидация validFrom < validTo с inline error на validTo поле,
переключение на таб 'Идентификация' с фокусом на ошибке.
- DateTimeField теперь принимает hint и error props и показывает их под
парой DatePicker+TextInput[time].
- Подсказки i18n под полями: 'Когда запись становится активной. Пусто = с момента
создания.' / 'Когда перестаёт действовать. Пусто = бессрочно.'
- Новая i18n ключ form.error.validToBeforeFrom (RU/EN).
rec.version — JPA @Version (optimistic lock counter). Растёт на UPDATE, поэтому
у нескольких закрытых исторических версий может оказаться одинаковое значение
(была 0, стала 1 при close, новая запись 0, потом 1, итого две закрытые с v=1).
В UI это выглядит как дубли v1, v1.
Используем индекс позиции в timeline: новая = data.length, старая = 1.
Отображение: v3 (текущая) → v2 → v1.
format=date поля приходят как 'YYYY-MM-DD' без времени. new Date(s) интерпретирует
их как UTC-midnight. В TZ западнее UTC (UTC-5 etc.) local Date уезжает на день
назад → getDate() возвращает (день-1).
Сейчас pure-date строки регексим и собираем через Date(y, m-1, d) — всегда
local midnight. Datetime строки парсятся как раньше.
Bug 1: ставил 8 мая, показывало 9.
Раньше formatIsoDateTime отдавал 'YYYY-MM-DDTHH:MM:00Z' (UTC). На load
new Date(s) парсил UTC и показывал в МСК (UTC+3) → +3 часа → день вперёд.
Сейчас:
- localTzOffset(d) вычисляет offset из браузера, например '+03:00'
- formatIsoDateTime(d, time) собирает Date в local TZ и отдаёт ISO с
явным offset: '2026-05-08T23:59:00+03:00'
- extractTime(s, fallback) парсит через new Date + getHours/getMinutes,
показывая time в local TZ юзера
Bug 2: кнопка сброса (X) в DatePicker не очищала validFrom/validTo.
combineDateTime(null) возвращал current value вместо ''. Фикс — на null
возвращаем пустую строку, value уходит в form state как ''.