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 как ''.
x-id-source (auto-derive businessKey):
- DictionaryRecordService.resolveBusinessKey() читает schemaJson['x-id-source'].
- Если задан — businessKey берётся из data[idSource]. Если клиент явно прислал
и значения не совпадают → 400 business_key_mismatch (защита от рассинхрона).
- Если поле в data пустое → 400 id_source_missing.
- Если x-id-source не задан и businessKey пуст → 400 business_key_required.
x-unique (constraint на дубли значений):
- DictionaryRecordRepository.findActiveByJsonField() — native SQL c data->>field.
- DictionaryRecordService.enforceUniqueFields() walks schema.properties, для
каждого property с x-unique=true ищет активные записи с тем же значением.
Исключает current record на update.
- Конфликт → 409 unique_field_violation с указанием business_key конфликтующей.
Делается на каждом create/update в той же SERIALIZABLE транзакции что и save —
без отдельного DB constraint (для MVP). Partial unique index per-field —
оптимизация на потом, когда будет много справочников с x-unique.
Update bitemporal записи: close old (set valid_to = new valid_from) → insert new.
Hibernate batch'ил эти два save() и до commit'а UPDATE не flush'ился. PostgreSQL
видел что старая запись имеет valid_to=FAR_FUTURE и tstzrange новой записи
пересекался → 23P01 conflicting key value violates exclusion constraint
dictionary_records_no_overlap → 500 для пользователя.
Заменил repository.save(current) на saveAndFlush(current) — UPDATE летит в БД
до INSERT, range'ы становятся [from1, X) и [X, to2) и не пересекаются.
Поля с format=date-time в schema (например orbit.epoch у spacecraft) рендерились
через DatePicker и форс-сетили T00:00:00Z. Теперь та же DateTimeField (DatePicker
+ time input) что и для validFrom/validTo. format=date по-прежнему чистый
DatePicker без времени.
History:
- Drawer на детальной странице записи (timeline всех версий через GET /records/{key}/history)
- Иконка ClockCounterClockwise в actions колонке таблицы записей
- Каждая версия показывает v(version), validFrom/To, updatedBy + expand JSON
Schema diff (breaking detection):
- Util diffSchemas() сравнивает старую/новую схему и классифицирует изменения:
breaking (removed/typeChanged/becameRequired/enumRemoved/maxLengthTightened/
patternChanged/newRequiredField), compatible (added optional/enumAdded/
becameOptional), metadata (description)
- В DictionaryEditorDialog при breaking → Alert с детальным списком + Save заблокирован
- При compatible → Alert info со списком
- suggestVersionBump: major для breaking, minor для compatible field changes,
patch для metadata-only
x-unique:
- Чекбокс «Уникальное» на PropertyEditor → emit x-unique: true в schema
- Backend enforcement позже — отдельный PR с partial unique index
x-id-source:
- SingleSelect в Метаданных «Использовать как ID» → emit x-id-source на schema root
- В SchemaDrivenForm если установлен — businessKey input скрывается, на submit
derived из data[idSource]
- Подсказка с именем поля
DateTime для validFrom/validTo:
- Заменил DatePicker (date-only) на DateTimeField компонент: DatePicker + TextInput
type=time рядом
- Default time: validFrom=00:00, validTo=23:59 (запись действует до конца дня
иначе зануляется в полночь)
- Поддерживает 2-часовые интервалы (запуск КА в 14:30 → сход в 02:15 etc.)
Bug fix:
- parseSchemaJson изменил сигнатуру с PropertyDef[] на {properties, idSource},
старый код в DictionaryEditorDialog падал "{} is not iterable". Поправлено
через destructuring const parsed = parseSchemaJson(...).
i18n: 25+ новых ключей (RU/EN) — schema.unique, schema.idSource, schema.diff.*,
history.*, form.idSourceNote.
500 при сохранении записи — backend ждёт OffsetDateTime в DTO CreateRecordRequest,
а DatePicker отдавал просто 'yyyy-MM-dd'. Добавил helpers:
- formatIsoDateTime(d) → 'yyyy-MM-ddT00:00:00Z'
- ensureIsoDateTime(s) → нормализует submit-значение validFrom/validTo
Применил:
- validFrom/validTo на submit нормализуются (если YYYY-MM-DD → +T00:00:00Z)
- format=date-time data поля отдают datetime ISO, format=date — по-прежнему YYYY-MM-DD
Также добавил скаффолд для D-pack (history drawer + schema diff util) — не
интегрировано в роуты, доделаем в следующем коммите.
Несколько связанных багов в форме записей:
DatePicker:
- launch_date / decay_date (format=date) и validFrom/validTo рендерились как
raw text input → теперь @nstart/ui DatePicker с RU локалью.
- DatePickerProvider добавлен в main.tsx с DATE_PICKER_LOCALE_RU.
- Far-future infinity dates (year>=9999) скрываются (пустое поле).
- ISO date format на submit: yyyy-MM-dd.
Nested object → JsonField fallback:
- orbit (type=object с raan_deg/eccentricity/...) и antennas (array of object)
показывали [object Object] в text input. Добавлен JsonField — TextArea
с JSON.stringify/parse, локальный state для невалидного промежуточного
ввода + inline parse error.
Edit raw record fetch:
- Read-api flatten'ит i18n в строку выбранной локали → form для edit получал
data.name = 'Terra' вместо {ru-RU, en-US}. На Edit теперь fetch raw record
через writer GET /api/v1/dictionaries/{name}/records/{businessKey}.
- nginx regex location ~ ^/api/v1/dictionaries/ → writer (вместо узкого regex
только для /dictionaries/{name}). GET schema, GET raw record, mutations —
всё на writer. Read-api держит /api/v1/{name}/records/* (другой URL pattern).
- LoadingBlock пока тащим raw запись + Alert на ошибке.
Поля авто-распределяются по 3 табам:
- Идентификация: businessKey + validFrom/validTo + required scalar поля
- Описание: все x-localized поля (i18n)
- Дополнительно: optional scalar поля
UX:
- Все табы mounted (через hidden) — RHF state сохраняется при переключении.
- Бейдж с числом ошибок на каждом табе (variant=error).
- На submit: ajv валидация → если есть ошибки, форма автопереключает на таб
первой ошибки + setError по всем затронутым полям.
- Таб «Описание» / «Дополнительно» скрывается если нет соответствующих полей.
Дроп FormSection — табы заменяют секционирование. Layout стал плоский
(Tabs + grid 1/2 cols + FormActions).
pnpm install в Docker падал т.к. .npmrc отсутствовал — @nstart/* резолвились
в public npm registry (404). Добавил .npmrc + pnpm-lock.yaml в первый COPY до
install, чтобы scope mapping на repo.nstart.cloud сработал.
Заодно --frozen-lockfile вместо --no-frozen-lockfile (lockfile в репо есть,
CI воспроизводимый install).
Свои Modal/Button/inputs выкинуты — берём nstart-ui v0.1.3 как в других corp
React приложениях. Установка через .npmrc → repo.nstart.cloud/repository/npm-hosted/.
Что сделано:
- .npmrc: scope @nstart на corp Nexus + min-release-age=7
- deps: @nstart/ui ^0.1.3, @phosphor-icons/react ^2.1.10
- bumped peer deps: i18next ^26 + react-i18next ^17 (требование nstart-ui)
- styles.css: импорт @nstart/ui/styles/{fonts,theme}.css + critical @source
директива (без неё Tailwind v4 tree-shaking дропнул бы все классы из dist либы)
- main.tsx: <NStartUiProvider> на корне
- DROP src/components/ui/Modal.tsx (свой Modal больше не нужен)
Routes:
- __root: LanguageSwitch вместо двух кнопок RU/EN, токены ultramarain/regolith/carbon
- dictionaries.index: PageHeader + Badge для scope chip + Alert/EmptyState/LoadingBlock
- dictionaries.$name: PageHeader с actions, Modal/Panel/Table/TableRow/Cell,
IconButton с Phosphor (PencilSimple, XCircle), Button variant=primary/danger/ghost
Form (SchemaDrivenForm):
- TextInput/TextArea/SingleSelect/Checkbox + FieldLabel/Hint/Error из @nstart/ui
- FormSection + FormGrid columns=1|2 для группировки
- FormActions align=end с Button loading state
- Alert variant=error для server-side ошибок (422 IdempotencyConflict / 409 InProgress)
- ajv валидация по schema_json остаётся такой же (Draft-07, как у backend)
Bundle: 192KB → 659KB gzip — тянет echarts/lottie/brand assets из nstart-ui,
для internal admin tool приемлемо (cached after first load).
Кнопка «+ Создать запись» использует bg-brand-600 / hover:bg-brand-700, но в
@theme были определены только brand-50/500/700 — bg-brand-600 резолвился в
ничто, кнопка отрисовывалась без фона + text-white = невидимая на белом.
Добавил полную шкалу 50-900 в OKLCH для консистентного hover/active.
После regen routeTree.gen.ts /dictionaries/$name стал child route /dictionaries.
dictionaries.tsx рендерил карточки без <Outlet/> — клик по карточке менял URL,
но child компонент негде было рендерить, поэтому видна та же страница со списком.
Разнёс по конвенции:
- dictionaries.tsx — layout с <Outlet/>
- dictionaries.index.tsx — карточки списка (matches /dictionaries)
- dictionaries.$name.tsx — без изменений (matches /dictionaries/{name})
Nginx с переменной в proxy_pass требует resolver. Ставим nodelocal-dns
(169.254.25.10 на kubespray-кластерах) с публичным fallback (1.1.1.1).
Это снимает 502 Bad Gateway на /api/v1/* — раньше nginx не мог
зарезолвить ordinis-read-api/ordinis-app в момент запроса.
- TS errors на cold-build: routeTree типы unknown пока plugin не сгенерил
реальный файл, import.meta.env без vite/client typing.
- Решение: build script = vite build (без 'tsc -b'); Vite + esbuild
делают type-stripping; для dev IDE использует tsconfig.
- Добавлен src/vite-env.d.ts с reference vite/client.
- routeTree.gen.ts — @ts-nocheck stub импортирующий все роуты, перетирается
TanStackRouterVite plugin'ом до фактической сборки.
Build только при изменениях ordinis-admin-ui/** или каждом push в main.
Использует тот же DinD pattern, push в repo.nstart.cloud/terravault/ordinis-admin-ui.
@Scheduled(cron='0 17 * * * *') запускает deleteExpired раз в час (17-я
минута, чтобы не совпало с другими cron). Конфигурируется через
ordinis.idempotency.cleanup-cron. Counter ordinis_idempotency_cleaned_total
для observability.
Multi-module Maven build падал на 'Child module ordinis-auth does not exist'
потому что Dockerfile'ы копировали только конкретные используемые модули.
Добавлен COPY ordinis-auth ./ordinis-auth перед COPY ordinis-validation.
Новый модуль ordinis-auth:
- OrdinisAuthProperties — public-key, issuer, requireAuthentication, allowQueryScope
- ScopeContext — резолвит current scopes из JWT claim 'scopes' (приоритет),
потом legacy ?as_scope=, потом anonymous=PUBLIC
- SecurityConfig — Spring Security OAuth2 Resource Server + RSA public-key
decoder. Если public-key пуст — JWT validation отключён (permissive
fallback); rollout без auth-сервера не ломает API.
Read controller теперь делегирует scope resolution в ScopeContext вместо
прямого парсинга ?as_scope=. Backward compat: legacy query параметр
работает пока ordinis.auth.allow-query-scope=true (по умолчанию true в
dev/staging, false в prod после интеграции с @nstart/auth).
Vault: public-key читается из secret/ordinis/cuod/jwt-public-key (key=key).
Spring Cloud Vault flatten'ит в property 'key', resolved в
ordinis.auth.public-key=${key:}.
ContentCachingRequestWrapper только cache'ит body ПОСЛЕ того как handler
прочитает getInputStream(). Если filter читает body первым (для hash),
handler потом получает 'Required request body is missing' — буфер пустой.
CachedBodyHttpServletRequest при construction читает body в byte[] и
getInputStream() возвращает свежий ByteArrayInputStream при каждом вызове.
Filter и handler читают одни и те же байты независимо.
OncePerRequestFilter перехватывает POST/PUT/PATCH с заголовком
Idempotency-Key. Алгоритм:
- sha-256(body) → request_hash
- key найден с тем же hash → возврат cached response (status + body)
- key с другим hash → 422 idempotency_conflict
- key зарезервирован но без response → 409 idempotency_in_progress
- новый key → reserve, запустить handler, кэшировать 2xx ответ
Race-safety: reserve через PK saveAndFlush, DataIntegrityViolation
поднимается до 409. retention 30 дней (cleanup job — будущая работа).
Filter автоматически регистрируется через @Component и
scanBasePackages в OrdinisApplication.
0008 dropped IF EXISTS dictionary_records_data_scope_check, но реальный
constraint в 0003 назывался dictionary_records_scope_check (без _data_).
Старый lowercase constraint остался жив — записи с UPPERCASE
data_scope не проходили. 0009 удаляет оба варианта имени и создаёт
UPPERCASE constraint.
Importer бросал early-return для уже существующих definitions, поэтому
records никогда не наполнялись на повторных стартах. Records seed
теперь вызывается всегда (idempotent — skip по businessKey).
CuodBundleImporter теперь после создания DictionaryDefinition подгружает
records/<name>.records.json (relative to bundle resourceRoot) и создаёт
DictionaryRecord для каждой записи. Идемпотентно: пропускает business_key,
который уже существует.
Bundle:
- 5 satellite_type (SAR_X_HIRES, OPT_HIRES_VIS, OPT_MIDRES_MS, HYPER, METEO_GEO)
- 4 spacecraft (Terra, Sentinel-1A, Sentinel-2A, Кондор-ФКА №2)
- 3 ground_station (Отрадное, SvalSat, Байконур)
Все с локализованными name/description (ru-RU + en-US), валидируемые
JSON Schema на pre-save. Outbox events RecordCreated отправляются для
projection-writer.
Without spring-boot-starter-web, Spring Boot не запускает Tomcat, и
actuator endpoints (/health, /metrics) недоступны. Probes в чарте не
могут пройти. Добавляем web starter — Tomcat поднимется на server.port
(8082), actuator/health/{liveness,readiness} ответят, probes работают.
- ci: resolve UPSTREAM_IMAGE_TAG via dotenv artifact (resolve-tag job)
GitLab quirk — $IMAGE_TAG в trigger.variables не интерполируется
напрямую от upstream global vars; dotenv делает значение job-local,
тогда interpolation работает.
- projection-writer: management.server.port=8082 + web-application-type=none
Возврат actuator/health probes — Spring Boot non-web app не имеет
HTTP сервера; вынесли actuator на отдельный management server.
- docker-compose.yml: локальный dev-стек (PG + Kafka + Redis + 3 services)
через repo.nstart.cloud/terravault images. Включается профилем 'dev'.
Stack trace showed io.opentelemetry.exporter.internal.http.HttpExporter
sending to :4317 — Tempo on 4317 expects gRPC frames, gets HTTP, sends
RST. Switch endpoint to :4318 (Tempo OTLP HTTP receiver) and protocol to
http/protobuf so the SDK and Tempo speak the same protocol.
The gRPC config never took effect — Spring Boot SDK on micrometer-tracing-
otlp pulls in only the HTTP exporter by default; gRPC needs the explicit
opentelemetry-exporter-otlp jar.
Switch to the working pattern provided by infra team:
- image: repo.nstart.cloud/nstart/docker-cli-buildx:1.0.0 (corp-built
client with proper Harbor credentials baked in / runner-friendly)
- service: docker:29.1.2-dind (matches runner-tested version)
- explicit --tls=false + DOCKER_HOST=tcp://docker:2375 + DOCKER_TLS_CERTDIR=''
The earlier docker:26 + custom TLS attempts hit shared-volume issues
specific to this runner setup.
GitLab CI YAML <<: doesn't deep-merge — per-job 'variables:' overwrites
the anchor's variables block, so DOCKER_HOST/DOCKER_TLS_CERTDIR were
lost and the docker client fell back to default tcp://docker:2375 (no
TLS). 'extends:' merges variables key-by-key, so per-job SVC/DOCKERFILE
add to the base instead of replacing it.
Non-TLS DinD on tcp://docker:2375 had a race where docker login succeeded
but the next command got 'Cannot connect to the Docker daemon'. Switch
to the documented TLS pattern with DOCKER_TLS_CERTDIR=/certs and
DOCKER_HOST=tcp://docker:2376 — both client and DinD share /certs via
the implicit volume.
Single registry for all clusters — staging (161), prod (142), local dev.
- Removed registry.k8s.265.nstart.local (no longer needed)
- Removed --insecure-registry flag from DinD service
- Wait-loop for DinD readiness before docker login (fixes race that
caused 'Cannot connect to the Docker daemon' on first build command)
- Inlined docker login into the publish_script (kept tied to the build,
not a separate before_script that ran before DinD was ready)
Prod cluster (192.168.100.142) cannot reach the local staging registry
registry.k8s.265.nstart.local. The corp Harbor at repo.nstart.cloud is
the shared image source — push there in addition to the staging-internal
registry so both clusters can pull.
- Added EXTERNAL_REGISTRY=repo.nstart.cloud
- before_script does docker login $EXTERNAL_REGISTRY (REGISTRY_USER /
REGISTRY_PASSWORD already set as group-level CI variables)
- Refactored 4 nearly identical docker-* jobs into a shared
.dual_push_script anchor; per-job vars (SVC, DOCKERFILE, BUILD_CONTEXT)
- Build once with both registry tags, push to both
JPA persists enum DataScope as @Enumerated(EnumType.STRING) by default,
which writes the enum name verbatim — UPPERCASE. The original CHECK
constraint allowed only lowercase ('public','internal','restricted'),
making every insert violate it.
Drop and recreate constraint with UPPERCASE values to match enum names.
Applies to dictionary_definitions.scope and dictionary_records.data_scope.
DictionaryDefinition and DictionaryRecord entities have @Version (BIGINT)
fields for optimistic locking, but the initial migrations did not create
the column. App fails with 'column dd1_0.version does not exist' on first
query.
Add 0007-add-version-columns.xml with idempotent addColumn changesets
(preConditions guard against re-add).
Hibernate validate mode threw 'wrong column type [supported_locales]:
found [_text (Types#ARRAY)] but expecting [text[] (Types#OTHER)]' against
the Liquibase-managed schema. This is a well-known hypersistence-utils +
PostgreSQL TEXT[] friction with Hibernate validate.
Schema is owned by Liquibase migrations; validate adds no safety here. Set
to 'none' (matches read-api and projection-writer).
K8s profile relied on shared/dev defaults for spring.datasource.url, but those
live inside the dev profile block — invisible when k8s profile is active.
Result: 'APPLICATION FAILED TO START — Failed to configure a DataSource'.
Move full datasource (and kafka/redis for projection-writer) wiring into the
k8s profile section. ENV vars come from Vault Agent injector
(ORDINIS_PG_USER/PASSWORD, ORDINIS_KAFKA_USER/PASSWORD, ORDINIS_REDIS_PASSWORD)
and chart-supplied ENV (ORDINIS_PG_HOST, ORDINIS_KAFKA_BOOTSTRAP, etc).
application.yml fallback URL was http://config-server.ordinis-dev:8888 — namespace
removed. Default now points to staging; SPRING_CLOUD_CONFIG_URI env still overrides
via Helm chart for any deployment.