Backend #1 из pending-endpoints brief. Frontend HistoryTabContent +
TimeTravelModal используют этот endpoint для отображения версий схемы
во времени с per-version property-level diff summary.
Implementation:
- AuditLogger.definitionCreated / definitionUpdated — пишет в audit_log
с event_type DEFINITION_CREATED / DEFINITION_UPDATED. payload_before/
after = JsonNode schema. record_id / business_key — NULL (dict-level event).
- DictionaryDefinitionService.create / updateSchema hook в audit logger
(autowired, null-safe для tests).
- AuditLogRepository.findByDictionaryIdAndEventTypes — query by dict_id +
event_type IN (...), ORDER BY event_time DESC, paginated.
- ChangelogService.findChangelog — собирает entries, computes top-level
property-key diff (added/removed/changed). Первая entry (newest) =
isCurrent + schemaVersion из DictionaryDefinition; старые entries
возвращают version=null (semver-в-аудит backfill — separate migration).
- DictionaryChangelogController — GET /dictionaries/{name}/changelog?limit=50
Errors:
- 404 dictionary_not_found когда dict missing OR scope hidden (consistent
с другими endpoints).
Tests +9: dict-not-found, scope-hidden, empty-audit, first-is-current,
diff-added/removed/changed/null-before/identical.
CEO plan v1 stretch — закрывает gap "Smart JSONB search across all dictionaries".
Last gap из v1 list shipped.
Migration 0018:
- CREATE EXTENSION pg_trgm (CNPG initdb обычно не enable'ит, добавили
явно с MARK_RAN preCondition если уже есть).
- CREATE INDEX idx_dict_records_data_trgm
ON dictionary_records USING GIN ((data::text) gin_trgm_ops)
- Trigram-based ILIKE с минимум 3 символа в query — index используется.
ILIKE '%pat%' на data::text возвращает любые matches в JSONB serialized.
Backend:
- New RecordSearchQuery (ordinis-domain): native SQL c ROW_NUMBER()
per-dict cap. SQL injection защищён PreparedStatement param + extension
whitelist (allowed_scopes — text[]).
- New SearchController (ordinis-rest-api): GET /api/v1/search?q=&size&perDict.
Default size=100, perDict=10. Min q length=3 (silently empty if shorter).
Scope filtering through ScopeContext — каждый caller видит только свои
scope levels. Results grouped per dict с display name + count + items.
Admin UI:
- New /search route с SearchInput + grouped Panel results.
- URL state ?q=… — share-friendly link "/search?q=SAR-X".
- Per-result link → /dictionaries/{dict}?q={businessKey} для drill-down.
- Min-3 hint, empty state, loading + error.
- New "Поиск" / "Search" tab в navigation header.
- i18n RU (с правильными plurals search.totalHits) + EN.
Behavior:
- Active records only (valid_from <= now() < valid_to).
- Per-dict cap 10 — защита от dominant-dict skew (один dict с 1000 matches
не забьёт результат).
- Total cap 100 — admin "find this code" use case достаточно. Browser-style
search-as-you-type (10k+ matches, instant) — отложен на v2 с dedicated
index server (Elastic / Meili).
Verify:
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
После migration apply'я index size на dictionary_records будет ~30-40%
data column. На текущих 5k records ~3-5 MB, acceptable.
Per-dictionary opt-in для Redis projection materialization. Default false
— projection-writer пропускает event'ы того dict'а, никаких dead writes
в Redis. Включается админ'ом через UI checkbox в Metadata tab.
Migration 0017:
- ALTER TABLE dictionary_definitions ADD COLUMN redis_projection_enabled
BOOLEAN NOT NULL DEFAULT false.
- Index idx_dict_def_redis_proj для быстрого filtering в list queries.
Backend:
- DictionaryDefinition entity: new field + getter/setter.
- DictionaryResponse DTO: returns flag в response.
- CreateDictionaryRequest DTO: optional Boolean (null/missing = no change).
- DictionaryDefinitionService.create + updateSchema: применяет flag из request.
- ProjectionWriter (projection-writer service):
* upsert() — early-return + skip counter если flag=false на dict.
* delete() — symmetric: skip если flag=false (защита от stale keys
после flag-flip; полный cleanup TODO Phase 2).
* New metric: ordinis_projection_records_skipped_total.
Admin UI:
- DictionaryEditorDialog Metadata tab: checkbox "Включить Redis-проекцию"
с описанием. Apply через CreateDictionaryRequest payload.
- DictionaryDefinition + CreateDictionaryRequest types updated.
- i18n RU/EN: schema.redisProjection.{label,hint}.
Что НЕ делается (deferred Phase 2):
- read-api Redis routing — read-api сейчас всегда идёт в PG read replica.
Когда dict опт-инится в флаг, projection материализуется, но read-api
ещё не использует. Read routing — отдельная feature с benchmark proof
(через JMH + k6 SLO test).
- One-shot cleanup job для stale keys после flag-flip from true→false.
Behavior change в production:
- До patch: projection-writer пишет в Redis для ВСЕХ dict events (40
dictionaries). Default flag=false после migration → projection-writer
пропускает всё → Redis keys для existing dicts становятся stale.
Это OK потому что read-api НИКОГДА не читал из Redis (writes были
dead). Stale keys eventually evicted Redis maxmemory policy.
- После patch: только dicts с redis_projection_enabled=true получают
материализацию. По default — нет. Admin opt-ins per-dict через UI.
Verify:
- mvn test (full reactor): SUCCESS, all green.
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration 0017 applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
Раньше при page=50 admin не мог найти редкий dlq среди
delivered'ов: client-side filter показал бы 1 совпадение из 50,
а на page=1 могли быть ещё. Делаем server-side через ?status=.
Backend:
- WebhookDeliveryRepository.findBySubscriptionIdAndStatusOrderByCreatedAtDesc
— Spring Data derived query, JPA генерирует SQL.
- GET /admin/webhooks/subscriptions/{id}/deliveries?status=dlq —
опциональный param. Invalid status → 400 OrdinisException.
Allowed: pending, retrying, delivered, dlq.
UI:
- 4 chip'а pending/retrying/delivered/dlq над таблицей deliveries
(одинаковый паттерн со scope-чипами в dictionaries).
- Click toggle + reset page=0. 'Сбросить' появляется когда фильтр
активен.
- Status в URL пока не персистится — для одной session-страницы
не критично.
@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.
Дополнение к 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.
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 когда поиск пустой.
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.
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).
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.