Commit Graph

18 Commits

Author SHA1 Message Date
Александр Зимин 3b7b1bffe5 feat(notifications): Phase A foundation — module + schema 0021 2026-05-10 15:41:34 +00:00
Zimin A.N. 9ef9b8147e feat(approval): Approval Workflow v2 Phase 3 — e2e suite + 0020 case fix
Phase 3 e2e coverage (8 tests, ApprovalWorkflowE2ETest):
- happyFlow: maker submit → checker approve → live record + outbox event
- rejectFlow: keeps draft REJECTED, no live record, re-approve blocked
- selfApprove: same JWT subject blocked (self_approve_forbidden / self_reject_forbidden)
- twoPendingDrafts: exclusion constraint → 409 draft_already_pending
- directCRUD on approval_required dict: POST/PUT/DELETE → 409 draft_required (D2=A)
- regression: dict без approval_required — direct CRUD по-прежнему работает
- withdrawByMaker: maker может, reviewer не может (403 not_draft_maker)
- rejectWithoutComment: 400 reject_reason_required

E2e exposed real Phase 0 bug:
Hibernate @Enumerated(EnumType.STRING) пишет UPPERCASE ('PENDING'), а
Liquibase 0019 check constraint + exclusion WHERE использовали lowercase
('pending'). Каждый insert проваливался на check, маппился в
DataIntegrityViolation, и DraftService.submit маскировал его в
draft_already_pending — скрывая реальную причину.

Fixes:
1. Migration 0020: пересоздание record_drafts_status_check + exclusion
   с UPPERCASE значениями (matches enum serialization).
2. DraftService.submit: только SQLSTATE 23P01 (exclusion violation)
   маппится в draft_already_pending. Остальные DataIntegrity (check, FK,
   NOT NULL) пробрасываются как-есть — иначе schema bugs продолжат
   маскироваться.
3. DraftServiceTest StubDraftRepo: оборачивает SQLException 23P01 в
   DataIntegrityViolation cause chain — отражает прод поведение.

Test counts:
- ordinis-rest-api unit: 119/119 PASS
- ordinis-app e2e: 28/28 PASS (был 20, +8 новых ApprovalWorkflowE2ETest)
2026-05-08 13:52:17 +03:00
Zimin A.N. e438c4c8c8 feat(approval): Approval Workflow v2 Phase 0 — schema + skeleton API
Approval Workflow v2 epic, Phase 0 foundation. Maker-checker pattern для
критичных справочников через record_drafts отдельную таблицу (Approach A
из design doc).

Decisions made (auto, per design doc recommendations):
- D1=A: per-dict policy через колонки на dictionary_definitions
  (approval_required + approval_min_role).
- D2=A: legacy POST на approval-required dict вернёт 409 (Phase 1 wire'ит
  в DictionaryRecordService).
- D3=A: pool model для reviewer'ов — все ordinis:approver видят queue.
- D4=A: rejected drafts kept (status='rejected'); compliance audit trail.

Migration 0019:
- record_drafts table:
  * id, dictionary_id, business_key, parent_record_id, operation
  * data (jsonb), geometry, proposed_valid_from/to
  * status, maker_id, maker_comment, submitted_at
  * reviewer_id, reviewed_at, review_comment, version
- 4 constraints:
  * status_check: pending/approved/rejected/withdrawn
  * operation_check: CREATE/UPDATE/CLOSE
  * no_self_approve: maker_id != reviewer_id
  * one_pending_per_key: EXCLUDE WHERE status=pending
    (concurrent edits → последний wins на submit, prevents merge conflicts)
- 3 indices: pending_by_dict, by_maker, pending_global.
- dictionary_definitions: +approval_required (BOOLEAN, default false),
  +approval_min_role (VARCHAR 64, optional Keycloak role).

Backend:
- Entity RecordDraft + Repository с JPQL queries для:
  * findPendingByKey (single pending lookup)
  * findPending (global queue для approver pool, paginated)
  * findPendingByDictionary (per-dict list)
  * findByMaker (own drafts tracking, paginated)
- DTO SubmitDraftRequest, DraftResponse.
- DraftService skeleton:
  * submit() — creates pending draft. 409 на exclusion violation.
  * findById, listPendingByDictionary, listPendingGlobal — read-only.
  * approve / reject / withdraw / updateDraft — Phase 1 stubs (комментарии).
- DraftController с 4 endpoints:
  * POST /api/v1/dictionaries/{name}/records/drafts — submit
  * GET /api/v1/dictionaries/{name}/records/drafts — list per dict
  * GET /api/v1/drafts/{id} — single draft
  * GET /api/v1/admin/reviews/pending — global queue (paginated)

Tests:
- DraftServiceTest 6 unit tests:
  * submit creates with PENDING status
  * 409 на duplicate pending business_key (exclusion constraint)
  * invalid WKT → 400
  * dict not found → 404
  * findById unknown → 404
  * listPendingByDictionary returns multiple pending
- StubDraftRepo simulates DB exclusion constraint в memory.

Verify:
- mvn -pl ordinis-rest-api -am test: 112/112 PASS (+6 new).
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS — migration 0019
  applies clean, no regression.
- helm/yaml configs не трогали — Phase 0 чистый schema + code.

Phase 1 (next session) — full lifecycle:
- DraftService.approve(): SERIALIZABLE tx с COPY draft → live + outbox
  RecordCreated/Updated/Deleted event + audit. Validate against schema
  ON APPROVE (могла schema измениться с момента submit'а).
- reject() — mark status=rejected, kept (D4).
- withdraw() — maker отзывает свой pending.
- DictionaryRecordService.create/update/close: pre-check
  approval_required → 409 draft_required. JWT subject → maker_id.
- Reviewer queue + diff viewer admin UI.

Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
2026-05-08 13:08:21 +03:00
Zimin A.N. 48df5ceeb8 feat(search): smart JSONB search across all dictionaries
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.
2026-05-08 12:53:44 +03:00
Zimin A.N. 0a8275ef4c feat(redis-projection): per-dict feature flag (CEO plan E2 tiered perf)
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.
2026-05-08 12:34:58 +03:00
Zimin A.N. 7d2199a3f3 fix(lineage): 3 e2e CI failures — Liquibase XSD + JSONB ?-op + size cap
Phase 4 e2e suite (skipped в local mvn verify, гонится только на CI с
testcontainers) поймал 3 issues. Все исправлены, e2e теперь 20/20 PASS
локально (Postgres testcontainer):
- AuthE2ETest 6/6
- AutoDeriveGeometryE2ETest 6/6
- BitemporalE2ETest 4/4
- OutboxKafkaE2ETest 1/1
- SmokeE2ETest 1/1
- WebhookE2ETest 2/2

Fix #1 — Liquibase XSD ordering (0016 changeset):
- <comment> должен идти ПОСЛЕ <preConditions>. Liquibase XSD строгий:
  preConditions → comment → operations.
- "Invalid content was found starting with element preConditions" на
  startup → ApplicationContext fail → весь webserver не поднимается.

Fix #2 — JSONB ?-operator vs JDBC parameter substitution (0016):
- Postgres JDBC PreparedStatement обрабатывает любой `?` как $N
  placeholder. SQL "prop.value ? 'x-references'" → "prop.value $1
  'x-references'" → "syntax error at or near $1". Same для regex с $
  anchor.
- Replaced `?` operator на эквивалент `-> 'key' IS NOT NULL`,
  drop-or-replace regex anchors на `LIKE '%.%'` (split_part guards
  malformed values gracefully).
- Comment в migration документирует ловушку для будущих changesets.

Fix #3 — service size cap mismatch (LineageIndexService):
- CascadeCloseService.evaluatePlan вызывает findRecordDependents с
  size=CASCADE_MAX_PER_REQUEST=500. Но findRecordDependents enforced
  size 1..200 → IllegalArgumentException → 400 на DELETE record path.
- Bumped service cap до 500. Controller отдельно держит public cap=200
  (UI не нуждается в больше).
- Test updated: expects "size 1..500".

Lessons learned:
- Local pre-push discipline должна включать `mvn -P e2e` для миграций.
  Не запустил, потому что: e2e требует Docker, медленнее, не было в
  routine. Now corrected — будущие migration changes будут идти через
  e2e check.
- Liquibase JDBC рабочий принцип: SQL прогоняется через PreparedStatement
  → любой `?` или `$N` ловится. Documenting в migration comment.

Verify:
- mvn -pl ordinis-app -am -P e2e test: BUILD SUCCESS, 20/20 PASS (28s).
- mvn test: 106 unit tests + 20 e2e — все green.
2026-05-08 11:48:39 +03:00
Zimin A.N. c06ae263e0 feat(lineage): Phase 2 — materialized view + throttled refresh + UI staleness
dict-relationships-v2 epic, Phase 2. Precomputed reverse FK index +
throttled refresh + feature flag + UI staleness badge. Migration катится
всегда; query path активируется через ordinis.lineage.mv.enabled=true.

Backend:
- Liquibase 0016: CREATE MATERIALIZED VIEW record_dependents_index +
  UNIQUE INDEX(source_record_id, source_field) (REQUIRED для REFRESH
  CONCURRENTLY) + composite indices для record-level и schema-level lookup.
  Plus lineage_mv_meta table — single-row tracking (last_refreshed_at,
  duration_ms, row_count, status, error).
- DependentsQueryMv (interface impl, @Primary + @ConditionalOnProperty)
  — один SQL query через MV вместо N JSONB lookups (по одному per
  source dict).
- MaterializedViewRefreshService:
  * @Scheduled refresh каждые ordinis.lineage.mv.refresh-interval-sec
    (default 60). Storm prevention: AtomicReference guard для in-flight,
    cooldown через ordinis.lineage.mv.min-interval-sec (default 30).
  * REFRESH CONCURRENTLY с fallback на plain REFRESH когда MV пустой
    (initial state). Не блокирует concurrent reads.
  * MvGateway interface (extracted из-за JDK 25 + Mockito incompat для
    concrete JdbcTemplate). Production: JdbcMvGateway (nested static).
- LineageIndexService теперь возвращает LineageMeta в RecordDependentsPage
  — mvEnabled, lastRefreshedAt, lastStatus, lastDurationMs. Optional<MV>
  inject — works без MV (mvEnabled=false, всё null).

Tests:
- MaterializedViewRefreshServiceTest (9 tests):
  * scheduledRefresh first time → REFRESH CONCURRENTLY
  * skip when within cooldown
  * refreshNow throws RefreshThrottledException when throttled
  * refresh succeeds после cooldown
  * fallback на plain REFRESH когда CONCURRENTLY fails
  * STORM PREVENTION (R2): 100 concurrent refresh attempts → ровно 1
    actual refresh (via cooldown + in-flight guard)
  * lastRefresh returns meta correctly
  * updates lineage_mv_meta after refresh (status=ok)
  * updates with error если оба refresh'а fail (status=error)

Admin UI:
- RecordDependentsPage type расширен LineageMeta { mvEnabled,
  lastRefreshedAt, lastStatus, lastDurationMs }.
- RecordDependentsPanel показывает staleness badge "обновлено N мин
  назад" если MV active + last refresh > 2 min ago. Tooltip объясняет
  что closed-between-refreshes records отфильтрованы query-side.
- i18n RU/EN: lineage.refs.{stale, staleHint}.

Verify:
- mvn -pl ordinis-rest-api -am test: 98/98 PASS (+9 new MV tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (9.3s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.

Deploy strategy:
- Migration катится автоматом (initial REFRESH < 5 min на 5k records).
- Query path остаётся JSONB direct (Phase 1) до явного flip flag'а
  ordinis.lineage.mv.enabled=true. Это позволит верифицировать MV
  корректность на staging без риска для prod.
- После flip: refresh каждые 60 sec, stale window <= 2 min обычно.
2026-05-08 11:05:33 +03:00
Zimin A.N. 9214ee931b feat(geo): auto-derive geometry из data.lat/lon + backfill migration
Записи ground_station и similar dicts имеют lat/lon в data, но
geometryWkt не задан. Spatial filter (AOI) WHERE geometry IS NOT NULL
AND ST_Intersects(...) возвращал 0 records даже когда координаты
есть.

Три места починены:

1. DictionaryRecordService.resolveGeometry — для CRUD через REST.
   Если req.geometryWkt() задан — приоритет (explicit). Иначе
   читаем data.lat/lon (или latitude/longitude/lng), валидируем
   ranges, строим POINT(lon lat) с SRID 4326. Игнор out-of-range
   без throw — не ломаем legacy data.

2. CuodBundleImporter.deriveLatLonGeometry — bundle bypass'ит service,
   reuse'аем ту же логику локально (5 строк inline). Применяется при
   создании НОВЫХ records (existing skipped по businessKey).

3. Liquibase migration 0015-backfill-latlon-geometry — UPDATE existing
   records где geometry IS NULL AND data has lat/lon. Три changeset'а
   для разных aliases (lat/lon, latitude/longitude, lat/lng). Idempotent
   (только NULL→value), rollback supported (NULL обратно).

После deploy: existing 3 ground_station записей (Отрадное и др.) и
spacecraft с lat/lon получат POINT geometry. AOI bbox-фильтр заработает.
2026-05-06 22:39:45 +03:00
Zimin A.N. d6787f00e9 feat(webhook): v2 webhooks foundation — DB schema + JPA entities
Phase 1 v2 фичи Webhooks: HTTP callback delivery параллельно с Kafka
publishing для consumer'ов которые не могут Kafka subscribe (геопортал
PHP, legacy admin-ui'и, BI-системы).

Migrations:
- 0013-webhook-subscriptions: таблица + check constraints (URL scheme,
  scope enum) + partial index по active=true для hot poller path
- 0014-webhook-deliveries: tracking каждой попытки доставки (один
  subscription × event = uniq), partial index due (status IN pending,
  retrying), DLQ index, FK на outbox_events с CASCADE delete

JPA entities:
- WebhookSubscription: TEXT[] filters (scope, dictionary, event_type),
  HMAC-SHA256 hex secret, active flag, audit fields
- WebhookDelivery: lifecycle pending → retrying → delivered/dlq,
  exponential backoff schedule (1m → 5m → 15m → 1h → 6h → 24h capped),
  MAX_ATTEMPTS=1000 (consistent с outbox), markDelivered/markFailed/
  resetDlq business methods

Repositories:
- WebhookSubscriptionRepository: findActive() через partial index
- WebhookDeliveryRepository: findDue() для poller, INSERT ON CONFLICT
  DO NOTHING для idempotency, DLQ counts для метрик

Ещё впереди: WebhookDispatcher service (HTTP POST + HMAC + SSRF guard),
REST API CRUD (admin-only RBAC), admin-ui /webhooks page, тесты.
2026-05-06 14:42:10 +03:00
Zimin A.N. f535f7544d test(e2e): smoke test через Testcontainers + fix audit_log.ip_address INET bug
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.
2026-05-05 19:16:53 +03:00
Zimin A.N. e182b30c58 feat(outbox): attempt cap → DLQ + admin endpoint + 6 unit-тестов
После N (default 1000) неудачных попыток публикации событие маркируется
dlq_at и исключается из polling. Без cap poller бесконечно ретраил мёртвые
сообщения (orphan topic, ACL deny), забивая логи и lag-метрику.

- 0011-outbox-dlq.xml: dlq_at column + перестроенный idx_outbox_unpublished
  (исключает DLQ) + idx_outbox_dlq для admin-листинга.
- OutboxPoller: ordinis.outbox.attempt-max (default 1000), markDlq() при
  достижении cap, новый counter ordinis_outbox_dlq_total.
- OutboxLagGauge: ordinis_outbox_dlq_size gauge — алерт на > 0.
- OutboxEventRepository: countPending() (без DLQ), findByDlqAtIsNotNull(Pageable).
- OutboxAdminController: GET /admin/outbox/{stats,dlq} для UI.
- 6 unit-тестов через mock-maker-subclass (JDK 25 ломает Mockito inline).
2026-05-04 15:43:56 +03:00
Александр Зимин 07149cafd8 fix(migrations): add Envers revinfo + dictionary_records_aud (0010)
App падал на 'relation revinfo_seq does not exist' — Hibernate Envers
ожидает revinfo_seq + revinfo + <entity>_aud, а hibernate.hbm2ddl.auto=none
не позволяет автогенерацию. Создаём вручную:
  - revinfo_seq (sequence)
  - revinfo (rev INTEGER PK, revtstmp BIGINT)
  - dictionary_records_aud (зеркало + rev, revtype, id+rev composite PK)
2026-05-03 23:51:30 +03:00
Александр Зимин add0394e32 fix(migrations): drop actual records scope_check constraint (was named _scope_check, not _data_scope_check)
0008 dropped IF EXISTS dictionary_records_data_scope_check, но реальный
constraint в 0003 назывался dictionary_records_scope_check (без _data_).
Старый lowercase constraint остался жив — записи с UPPERCASE
data_scope не проходили. 0009 удаляет оба варианта имени и создаёт
UPPERCASE constraint.
2026-05-03 23:45:14 +03:00
Александр Зимин 82dd48ba52 fix(migrations): scope check constraint must accept UPPERCASE values
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.
2026-05-03 20:45:57 +03:00
Александр Зимин 0cabd81abf fix(migrations): add version column for JPA optimistic locking
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).
2026-05-03 20:38:43 +03:00
Zimin A.N. aa26724661 fix(migrations): preConditions ДО comment в changelog (XSD), relative include paths
- 0001-extensions.xml: переставил <preConditions> перед <comment> (XSD требует
  preConditions первым в changeSet)
- master.xml: include paths сделаны relative к самому master.xml (changes/...
  вместо db/changelog/changes/...) — CWD inside Docker image это /liquibase,
  Liquibase searchPath = /liquibase/changelog где master.xml лежит, includes
  должны идти от него

CI: insecure-registry: registry.k8s.265.nstart.local (HTTP без auth для
internal cluster), REGISTRY_USER/PASSWORD optional (skip docker login если
не заданы)
2026-05-03 17:54:30 +03:00
Zimin A.N. b778832f49 feat(domain,events): bitemporal entities + i18n-aware definitions + sealed event API
ordinis-domain:
- DataScope enum (PUBLIC/INTERNAL/RESTRICTED) с Kafka topic suffix + visibility logic
- DictionaryDefinition entity: scope, schema_json (JsonNode JSONB), schema_version,
  bundle, supported_locales (text[]), default_locale + Spring Data Auditing
- DictionaryRecord entity: bitemporal (Envers tx-time + valid_from/valid_to),
  data JSONB, geometry (JTS via hibernate-spatial), data_scope. EXCLUSION
  CONSTRAINT enforced в БД (Liquibase 0003).
- AuditLog entity для compliance (отдельно от Loki)
- OutboxEvent entity: aggregate_*, kafka_topic/kafka_key, attempt_count,
  markPublished/markFailed, partial index idx_outbox_unpublished
- IdempotencyKey entity для exactly-once семантики write API
- Repositories: findActiveAt, findActiveByDictionaryAndScopes, findUnpublished
- JpaAuditingConfig: ConditionalOnBean(DataSource) — dev profile без БД
  не активирует auditing, metamodel не строится

ordinis-events-api:
- Mirror DataScope enum (events не depend on domain)
- EventEnvelope<T> record: eventId, occurredAt, schemaVersion (semver),
  bundle, dictionaryName, scope, traceId, spanId, payload
- Sealed DictionaryRecordEvent + records Created/Updated/Deleted
- Sealed DictionaryDefinitionEvent + records Created/Updated
- Jackson polymorphism через @JsonTypeInfo discriminator
- Multi-locale payload: события возят ВСЕ локали (consumer сам сплющивает)

i18n design:
- В JSONB поле кодируется как { 'ru-RU': '...', 'en-US': '...' }
- JSON Schema справочника помечает локализуемые поля extension x-localized:true
- supported_locales + default_locale в dictionary_definitions
- Read API будет negotiate через Accept-Language (RFC 7231) и сплющивать

Validation:
- mvn install all 11 modules: BUILD SUCCESS
- ordinis-app boot dev profile: /actuator/health UP, / отвечает
2026-05-03 12:10:36 +03:00
Zimin A.N. a28fd0b352 feat: Maven multimodule scaffolding for Ordinis MDM service
- 10 модулей (domain, validation, events-api, outbox, rest-api, app, read-api, projection-writer, cuod-bundle, migrations)
- Parent pom + BOM с Spring Boot 3.4.2, Spring Cloud 2024.0.0, Vault Config 4.2.0
- ordinis-app: Spring Boot main с Actuator/Prometheus/Logstash JSON encoder/OpenTelemetry/Cloud Config/Vault skeleton
- ordinis-migrations: Liquibase changelogs (dictionary_definitions/records, audit_log, outbox_events, idempotency_keys, EXCLUSION CONSTRAINT через btree_gist)
- Multi-stage Dockerfiles для app и migrations
2026-05-03 00:41:53 +03:00