Commit Graph

537 Commits

Author SHA1 Message Date
Zimin A.N. aedeafc84e feat(approval): /my-drafts maker self-tracking view + /api/v1/drafts/me
Approval Workflow v2 Phase 4 — UX:

Backend:
- DraftController.myDrafts: GET /api/v1/drafts/me?page&size — все статусы
  (PENDING/APPROVED/REJECTED/WITHDRAWN) текущего authenticated user, sorted
  submitted_at DESC. Resolve maker_id из JWT subject (fallback "anonymous"
  если auth off).
- Reuses existing DraftService.listByMaker (Phase 1 method, был не exposed).

Frontend:
- routes/my-drafts.tsx: новый маршрут с табличным view.
  Колонки: businessKey / operation / status / submittedAt / reviewedAt /
  reviewerId / comment / actions. Status badge tinted (warning/success/
  error/neutral). PENDING строки имеют Withdraw кнопку (calls existing
  useWithdrawDraft с confirm).
- api/queries.ts: useMyDrafts (refetch 30s — maker увидит когда reviewer
  decide).
- api/mutations.ts: useWithdrawDraft теперь invalidate'ит ['drafts'] —
  /me + by-dict обе видят изменение.
- routes/__root.tsx: nav link "Мои черновики" / "My drafts" (после Reviews).
- i18n RU/EN: 18 keys (nav.myDrafts, myDrafts.* col/action/status).
  Pluralization для total через i18next plurals.

Why это нужно:
- До этого maker submit'нул draft и пропадал — единственный feedback был
  badge "На review" в records list (только для текущего dict). Здесь — все
  его submissions с history + withdraw для PENDING.
- Reviewer pool (D3=A) маленький, /reviews queue только для них. Maker
  без role'а не видит queue, но должен tracking своих submissions.

Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
2026-05-10 12:07:35 +03:00
Zimin A.N. b2e7fd84c9 feat(approval): Approval Workflow v2 Phase 4 monitoring + pending-review badges
Approval Workflow v2 — Phase 4: monitoring + UX visibility.

Backend metrics (Micrometer → Prometheus):
- Gauge nsi_draft_pending_total — global queue depth (polled на scrape).
- Counter nsi_draft_submit_total{dictionary,operation,outcome=ok|already_pending|error}
  — submit attempts с разбивкой по outcome. Phase 3 lessons: error outcome
  выявит schema bugs которые ранее маскировались.
- Counter nsi_draft_decision_total{dictionary,operation,outcome=approved|rejected|withdrawn}
  — counts decisions, разбит по типу.
- Timer nsi_draft_review_duration_seconds — time-to-decide histogram (от
  submit до approve/reject/withdraw). publishPercentileHistogram() — для
  Prometheus histogram_quantile p95/p99.

DraftService:
- Optional<MeterRegistry> — null-safe в test ctor'ах.
- @PostConstruct registerGauges() — Gauge.builder polled от
  RecordDraftRepository.countPending().
- recordDecision() helper в approve/reject/withdraw — записывает Timer +
  Counter одной точкой.
- 4-arg ctor compat для Phase 1 unit tests.

UI (admin):
- useDictPendingDrafts — per-dict pending list, refetch 30s, gated на
  detailQuery.data.approvalRequired (no-op для не-approval dicts).
- "На review" badge в businessKey cell records table — моментальная
  видимость что есть pending draft. Open /reviews для act'а.
- i18n RU/EN: dict.pendingReview.label + tooltip.

Domain:
- RecordDraftRepository.countPending() — для Gauge polled count.

Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
2026-05-08 15:11:25 +03:00
Zimin A.N. d229c545f9 feat(approval): bundle-driven approval policy + spacecraft/ground_station soak flag
Approval Workflow v2 Phase 3 — soak prep:

- BundleManifest.DictionaryEntry: добавлены опциональные approvalRequired
  (Boolean) и approvalMinRole (String).
- CuodBundleImporter: применяет policy на fresh + on-update + при стабильной
  схеме (flip без bumping schema_version). Apply ТОЛЬКО когда явно изменился
  → не штормим UPDATE'ами.
- CuodBundleProperties.honorApprovalPolicy (default false): env-gated guard.
  Bundle ship'ит approvalRequired:true для пилотных dicts, prod игнорирует
  пока не явно включит.
- manifest cuod 1.3.0 → 1.3.1: spacecraft + ground_station получили
  approvalRequired:true. Без honor flag — no-op.

Этого commit'а достаточно для соответствующего helm change в
ordinis-infra (bundleHonorApprovalPolicy=true в values-staging) — flip'ает
spacecraft/ground_station на staging при следующем deploy. Prod остаётся
default=false до окончания soak.
2026-05-08 14:59:34 +03: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. 84a4de4ceb feat(approval): Approval Workflow v2 Phase 2 — admin UI reviewer queue + toggle
Phase 2 admin UI на скелете Phase 0/1 (e438c4c, 2020af9). Делает workflow
end-to-end usable: reviewer'ы могут approve/reject через UI; admins
включают approval_required per-dict через checkbox.

API surface (admin-ui):
- types: DraftStatus, DraftOperation, DraftResponse, SubmitDraftRequest,
  ReviewQueuePage. + DictionaryDefinition: approvalRequired, approvalMinRole.
- queries (react-query):
  * useReviewQueue (page, size) — global queue, refetch 30s.
  * useDraft(id) — single draft for diff drawer.
  * useLiveRecord(dict, bk) — current live record for side-by-side comparison.
- mutations:
  * useSubmitDraft(dict) — POST /dictionaries/{n}/records/drafts
  * useApproveDraft() — POST /drafts/{id}/approve
  * useRejectDraft() — POST /drafts/{id}/reject (reason required)
  * useWithdrawDraft() — DELETE /drafts/{id}

New /reviews route:
- Table queue: dict, business_key, operation badge, maker, submitted_at.
- Click "Просмотр" → side-by-side drawer:
  * left: current live (если есть; для CREATE — placeholder).
  * right: proposed (или CLOSE notice).
  * maker comment chip если есть.
  * Reviewer comment input + Approve / Reject buttons.
  * Reject disabled пока comment пустой (mirror's backend 400).
- Auto-invalidates на success — queue refresh, list updates.
- Nav link "Согласования" в header.

DictionaryEditorDialog Metadata tab:
- New checkbox "Требовать approval (maker-checker)" — orbit/8 styled
  (отделимо от Redis projection card visually).
- При checked → shown approval_min_role TextInput (e.g. "ordinis:internal").
- payload sends approvalRequired + approvalMinRole.

i18n RU/EN:
- nav.reviews, reviews.{title,description,empty,queueTotal_*,col.*,
  action.*, drawer.*}
- schema.approvalRequired.{label,hint}, schema.approvalMinRole.{label,hint}
- Plurals для RU queueTotal (one/few/many/other).

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
- routeTree.gen.ts auto-regenerated с /reviews.

Phase 3 (next session):
- e2e tests: full flow maker submit → checker approve → record live + outbox event.
- e2e: reject keeps draft, doesn't publish.
- e2e: self-approve blocked (no_self_approve constraint).
- e2e: two pending drafts на one business_key blocked (one_pending_per_key).
- Per-dict opt-in soak: 1-2 dicts (например ground_station) с
  approval_required=true в staging.
2026-05-08 13:28:56 +03:00
Zimin A.N. 2020af99b7 feat(approval): Approval Workflow v2 Phase 1 — full lifecycle + approval gate
Phase 1 на скелете Phase 0 (e438c4c). Делает workflow рабочим end-to-end:
maker submit → reviewer approve/reject → COPY → live record + outbox event.

Backend:
- DictionaryRecordService:
  * requireApprovalGate() — pre-check на create/update/close. Throws 409
    draft_required если dict.approval_required=true с pointer на drafts API.
  * applyApprovedCreate/Update/Close — public bypass methods для DraftService.
    Обходят approval check (drafts уже прошли review). Schema validation
    повторяется ON APPROVE — schema может measure'ся между submit и approve
    (failure modes #2 из design doc).
  * createDirect/updateDirect/closeDirect — internal helpers (private),
    extract'ed из public методов.
- DraftService:
  * approve(draftId, comment) — SERIALIZABLE tx. Validates pending status,
    self-approve guard (D3 maker-checker), dispatches на applyApproved*
    based on draft.operation. Marks draft APPROVED + reviewer fields
    (D4=A: kept в таблице для compliance).
  * reject(draftId, comment) — non-blank reason required (compliance).
    Marks REJECTED + reviewer fields. Никаких applyApproved calls.
  * withdraw(draftId) — maker-only. 403 not_draft_maker если другой user.
    Marks WITHDRAWN.
  * Constructor split: production-ctor (с recordService) + test-ctor
    (без recordService — для unit tests submit/list path). @Autowired
    отмечает canonical для Spring.
- DraftController endpoints:
  * POST /api/v1/drafts/{id}/approve?comment=
  * POST /api/v1/drafts/{id}/reject?comment=
  * DELETE /api/v1/drafts/{id} — withdraw

DTO surface — admin UI токglet'ы для approval policy:
- DictionaryResponse: +approvalRequired, +approvalMinRole.
- CreateDictionaryRequest: optional Boolean approvalRequired, String
  approvalMinRole (null = no change в update).
- DictionaryDefinitionService.create + updateSchema: применяют поля.
- DictionaryDefinitionService.findById — added (нужно DraftService.approve
  чтобы взять def name по id).

Tests:
- DraftServiceTest 13 unit tests (+7 new для Phase 1):
  * approve dispatches CREATE/UPDATE/CLOSE per draft.operation
  * approve blocked при self-approve (maker == reviewer)
  * approve 409 если draft уже approved/rejected
  * reject marks с reason; reject 400 если reason пустой
  * withdraw marks WITHDRAWN; 403 если не maker
  * + 6 Phase 0 tests от прошлого commit'а сохранены

TrackingRecordService stub — extends DictionaryRecordService с null deps,
overrides applyApproved* для записи calls в list. Verifies dispatch без
real DB.

Verify:
- mvn -pl ordinis-rest-api -am test: 119/119 PASS (+7 new).
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS — approval gate не
  ломает existing flow когда approval_required=false (default).

Phase 2 (next): admin UI reviewer queue + diff viewer + status badges.
Phase 3: e2e/integration tests + per-dict opt-in soak.

Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
2026-05-08 13:19:24 +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. 08405fecf7 feat(admin-ui): time-travel UI — view dictionary as-of any past date
CEO plan v1 stretch — закрывает gap "Time-travel UI (slider)".

Backend уже поддерживает ?at=<ISO> в read-api endpoints (DictionaryReadController):
findActiveAt(dict, key, at) возвращает active version на этот момент.
Был только UI gap.

UI:
- New toolbar button "Просмотр на дату" с ClockCounterClockwiseIcon.
  aria-pressed когда timeTravelAt активен.
- Datetime picker раскрывается inline (не модал — quick interactions).
  Native <input type=datetime-local>, конвертируется ↔ ISO.
- Active state ambient banner (orbit/8 background) если ?at= задан + picker
  закрыт — показывает "Просмотр на: {date}" + "Сейчас" клир link.
- "Закрыть" кнопка для picker'а; "Сейчас" сбрасывает at.

URL searchParams:
- DictSearch type расширен `at?: string` (ISO datetime).
- validateSearch проверяет `Date.parse(at)` — invalid timestamps drop'ятся.
- Share-friendly: "ordinis.k8s.265.../dictionaries/spacecraft?at=2025-12-31T00:00:00Z"
  работает per F5 + ссылка коллеге.

Records query:
- RecordsFilter type +at?: string.
- recordsQuery передаёт ?at=… backend'у в дополнение к as_scope/bbox/polygon.
- queryKey включает at — switching между моментами trigger'ит refetch.

i18n RU/EN: timeTravel.{button,label,viewing,clear,close}.

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backend смену не требует — DictionaryReadController.list() уже принимал
@RequestParam(value = "at") OffsetDateTime at и роут'ил в RecordReadService.
2026-05-08 12:39:53 +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. c11044c32e feat(bench): JMH microbenchmarks module + manual CI job
CEO plan v1 polish — закрывает gap "JMH performance benchmarks в CI для
baseline + Redis-enabled scenarios".

New module:
- ordinis-bench/ — JMH 1.37 microbenchmarks. Gated behind `bench` Maven
  profile в parent pom — НЕ строится при обычном `mvn package` (default
  CI остаётся быстрым).
- maven-shade-plugin под `bench` profile собирает uber-jar
  `target/benchmarks.jar` (86 MB, ~12 sec build).
- LineageBenchmark covers hot paths из dict-relationships-v2:
  * parseRef_simple / withSchemaButNoOnClose / withOnClose
  * onCloseAction_block / cascade / null
  * schemaTraversal_findRefs (40 props, 5 refs — realistic shape)

Baseline numbers (M3 MBP, JDK 25, single thread):
- onCloseAction_*: 0.6–12 ns/op
- parseRef_*: 20–93 ns/op
- schemaTraversal: 417 ns/op (~83 ns per ref на 40 props)
Cumulative budget for findSchemaDependents на 40 dicts × 40 props × 5 refs:
~17 µs. p99 endpoint SLO = 200 ms — запас 4 порядка. README documents
budget + 2x regression alarm.

CI integration:
- New job maven-bench (stage: test). Manual trigger only:
  * RUN_BENCH=true variable, OR
  * web pipeline source с manual click.
- Default CI (push/merge) НЕ запускает bench — slow + noisy в history.
- Output artifacts: bench-results.json + bench-results.txt (5 day expire).
- Quick smoke config: 1 fork, 2 warmups, 3 measurements × 2s = ~5 min total.

Side effect: ReferenceValidator.parseRef static methods переведены из
package-private в public. Они и так часть public ParsedRef contract —
visible to bench module без изменения design intent.

Verify:
- mvn -P bench -pl ordinis-bench -am package: BUILD SUCCESS (12s).
- java -jar benchmarks.jar -f 1 -wi 1 -i 2 Lineage: all 7 benchmarks
  exec'ятся, valid ns/op numbers.
- mvn -pl ordinis-rest-api -am test: 106/106 PASS unchanged.
- glab ci lint: clean.

README в ordinis-bench/README.md документирует:
- baseline numbers + 2x regression budget
- local run options (smoke / full / GC profile / JSON output)
- when CI bench runs (manual web trigger)
- difference vs k6/wrk (HTTP load tests are different layer, both needed)
- how to add new benchmark
2026-05-08 12:22:18 +03:00
Zimin A.N. 5bc82d2951 feat(admin-ui): webhook event preview tab в DictionaryEditorDialog
CEO plan v1 polish — закрывает gap "Webhook preview при определении схемы".

Что добавлено:
- New tab "События" (4-й после Метаданные / Поля / JSON) в schema editor.
- Per-event sample JSON envelope для всех 5 event types которые fire'ят
  на dict + records: RecordCreated, RecordUpdated, RecordDeleted,
  DefinitionCreated, DefinitionUpdated.
- Sample data автогенерируется из schema property types:
  string → "sample", integer → 42, number → 3.14, boolean → true,
  array → [<sample>], object → recursive, x-localized → {ru-RU, en-US},
  enum → first value, x-references → "REF-001", date/datetime → ISO.
- Topic name pattern shown: ordinis.{dict}.{scope.lower} для record events,
  ordinis.definitions для definition events.
- Disclaimer что это client-side preview — реальный shape определяется
  ordinis-events-api DTOs.

Helps integrators (Альтум, Геопортал, BI consumers) понять shape события
до того как dict published — без ожидания live event'а.

Files:
- src/components/schema/eventPreview.ts (new, ~155 LOC) — pure helper
- src/components/schema/EventsPreviewTab.tsx (new, ~70 LOC) — UI
- src/components/schema/DictionaryEditorDialog.tsx — +16 LOC, mount tab
- src/i18n.ts — schema.tabs.events + schema.events.{intro,topic,disclaimer}
  RU + EN

Verify:
- pnpm tsc --noEmit: clean
- pnpm test (vitest): 89/89 PASS (4.34s)
- pnpm build: clean
2026-05-08 12:14:35 +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. a7cdd5df5c feat(lineage): Phase 4 — SLO metrics + bundle v1.3.0 pilot (warn mode)
dict-relationships-v2 epic, Phase 4 (final). Backend SLO instrumentation +
schema bundle pilot. Alert rules + runbook идут отдельным коммитом в
ordinis-infra.

Backend metrics (Micrometer / Prometheus):
- nsi_dependents_query_duration_seconds (Timer + p50/p95/p99 percentiles)
  labels: mode={schema|record|*_error}, target_dict
- nsi_cascade_close_total (Counter)
  labels: target_dict, outcome={success|blocked|cascade_required|too_large|timeout|error}
- nsi_cascade_close_duration_seconds (Timer + percentiles)
- nsi_lineage_mv_last_refresh_seconds_ago (Gauge, NaN when mv disabled)
- nsi_lineage_mv_row_count (Gauge)
- nsi_lineage_mv_last_duration_seconds (Gauge)
- nsi_lineage_mv_refresh_total{outcome=ok|error} (Counter)

Cardinality bound: target_dict tag — ≤40 dictionaries; outcome — 6 values.
Total time series ≈ 240 per app instance — приемлемо.

Optional MeterRegistry inject — null в test ctor'ах (back-compat).

Bundle v1.3.0 pilot:
- spacecraft.satellite_type_code → satellite_type.code теперь имеет
  x-references-on-close: warn. Закрытие satellite_type больше не
  блокируется через 1.3.0; spacecraft остаётся orphan; orphan scanner
  поднимет metric. Data steward потом repoints.
- bundle version 1.2.1 → 1.3.0, spacecraft schema 1.0.0 → 1.1.0
  (description обновлён с pilot context).
- WARN-mode выбран как safe pilot — validates новый code path без
  destructive cascade. CASCADE-mode flip может быть отдельным PR
  per-relationship после operator review.

Verify:
- mvn verify -pl '!ordinis-app': SUCCESS (5.7s).
- 106/106 tests still green (existing tests не задеты).
- helm lint в ordinis-infra: clean (alerts render correctly).

Backward-compat: REGRESSION test (ReferenceCloseRegressionTest) covers
v1.2.1-style schemas без x-references-on-close. WARN mode на one field
не меняет contract других — bundle import idempotent.

Roadmap: 4 phases  shipped. Cascade engine + lineage UI + materialized
view + SLO metrics live.
2026-05-08 11:34:29 +03:00
Zimin A.N. 3eeaba058f feat(cascade): Phase 3 — CascadeCloseService + 409 routing + UI confirm dialog
dict-relationships-v2 epic, Phase 3. Cascade engine + UX guards.

Backend:
- New CascadeCloseService:
  * evaluatePlan(target, key, at) — read-only, splits dependents по
    onClose mode (BLOCK / WARN / CASCADE).
  * executeCascade(target, key, when, reason, confirmed) —
    @Transactional(timeout=30, isolation=SERIALIZABLE) atomic close
    target + all CASCADE deps в one hop (design F.OUT). 503
    x_references_cascade_timeout если tx exceeds 30s, 400
    x_references_cascade_too_large если N > 500.
  * RecordCloseSink interface — test seam над repo+audit+outbox
    mechanics (concrete classes на JDK 25 + Mockito incompat).
- Wired DictionaryRecordService.close():
  * Optional<CascadeCloseService> inject через ctor (back-compat ctor
    для тестов с null deps).
  * Pre-close evaluatePlan: blockers → 409 x_references_blocked_by_dependents,
    cascade non-empty → 409 x_references_cascade_required (просит cascade-close
    endpoint). WARN-only — close проходит.
- Endpoints в DictionaryRecordController:
  * GET /dictionaries/{dict}/records/{key}/cascade-preview — read-only план.
  * POST /dictionaries/{dict}/records/{key}/cascade-close?confirmed=true —
    atomic execute. Existing DELETE остался (теперь учитывает план).

Tests (8 unit tests):
- evaluatePlan empty / split-by-mode (BLOCK/WARN/CASCADE)
- 409 blockers exist (not closed)
- 409 cascade-required без confirmed
- WARN-only — closes target only, warnings returned
- CASCADE — atomic close target + 2 cascaded (verifies order + reasons)
- BLOCK overrides CASCADE — 409 даже при confirmed=true
- Empty plan — closes only target

Admin UI:
- New CascadeConfirmDialog component:
  * Trigger: 409 на existing close → opens dialog с GET cascade-preview.
  * BLOCKERS section — Alert error, list первых 5, "resolve first" hint.
  * CASCADE section — per-source grouping, sample 3 + overflow,
    badges per (count, onClose mode).
  * WARN section — yellow Alert "будут orphan".
  * Reason input + "Type CONFIRM" gate если N > 50 (UX guard).
  * Cancel — close без changes.
- Wired в dictionaries.$name.tsx: existing close-confirm flow на 409
  cascade error pivots в new dialog (same UX path, transparent для user).
- Types в client.ts: CascadeEntry, CascadePlan, CascadeCloseResult.
- Mutation useCascadeCloseRecord, query useCascadePreview.
- i18n RU/EN: cascade.* (35 keys, plurals для счётчиков).

Verify:
- mvn -pl ordinis-rest-api -am test: 106/106 PASS (8 new cascade tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (5.1s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backward-compat: bundle v1.2.1 (без x-references-on-close) → onClose=BLOCK
default → existing dependents теперь блокируют close (правильное поведение
для FK semantics). Если был race condition close-with-deps в v1, он
exposed теперь как 409. CRITICAL: REGRESSION test покрывает.
2026-05-08 11:23:46 +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. 6a365fcef7 feat(admin-ui): Phase 1 lineage UI — "Used by" + record refs cards
dict-relationships-v2 epic, Phase 1 admin UI. Backend endpoints поднимаются
коммитом 46a0a9c, тут — UI surface'ит их.

Что добавлено:
- `DictionaryDependentsPanel` — schema-level "На этот словарь ссылаются:"
  panel на странице справочника. Chip per (sourceDict.field → targetField,
  active records count, onClose mode badge: block/warn/cascade).
  Hide-on-empty: большинство справочников без incoming refs panel не видят.
- `RecordDependentsPanel` — record-level "На эту запись ссылаются:" section
  внутри `RecordHistoryDrawer`. Per-source summary chips + paginated items
  list (size=20, prev/next). Hide-on-empty.

Types в `client.ts`: `OnCloseAction`, `SchemaDependent`, `RecordDependent`,
`PerSourceSummary`, `RecordDependentsPage`.

Queries в `queries.ts`:
- `useDictionaryDependents(name)` — staleTime 5min (schemas slow-moving),
  404/403 → empty.
- `useRecordDependents(dict, key, page, size)` — disabled пока key undefined.

Mount points:
- `routes/dictionaries.$name.tsx`: `<DictionaryDependentsPanel>` после
  PageHeader, перед AOI banner.
- `components/record/RecordHistoryDrawer.tsx`: `<RecordDependentsPanel>`
  выше existing version timeline.

i18n RU/EN:
- `lineage.usedBy.{title,count,activeCount,openSourceDict}`
- `lineage.refs.{title,total,field,created,openSourceRecord}`
- `lineage.onClose.{BLOCK,WARN,CASCADE}` (показ как лейблы)
- `pagination.{prev,next,pageOf}` — общие keys.

Plurals: `_one/_few/_many/_other` для RU (правильная грамматика count'ов).

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS (4.04s).
- pnpm build: clean (warnings о chunk size — pre-existing для @nstart/ui).

Бекенд endpoints живут на:
- GET /api/v1/dictionaries/{name}/dependents
- GET /api/v1/records/{dict}/{key}/dependents?page&size
2026-05-08 10:52:46 +03:00
Zimin A.N. 46a0a9c00c feat(lineage): Phase 1 backend — dependents endpoints + REGRESSION test
dict-relationships-v2 epic, Phase 1 read-side. Backend готов; admin UI
(card "Used by" + record references) — отдельным commit'ом.

Что добавлено:
- `DependentsQuery` (interface) + `DependentsQueryJdbc` (impl) — native
  SQL для record-level dependents lookup (paginated). Interface extracted
  чтобы Mockito мог mock'нуть на JDK 25 (concrete-class inline-mock incompat).
- `LineageIndexService` — два режима:
  * `findSchemaDependents(name, scopes)` — schema-level reverse FK list
    (in-memory traversal definitions, microseconds). Skip self-reference,
    scope-hidden sources, malformed x-references (logged warn).
  * `findRecordDependents(dict, businessKey, scopes, page, size)` —
    record-level paginated, perSource summary + total. Phase 2 переносит
    на materialized view; пока живём с прямым JSONB query (acceptable
    для <5k SLO).
- `LineageController` — два endpoint'а:
  * `GET /api/v1/dictionaries/{name}/dependents`
  * `GET /api/v1/records/{dict}/{businessKey}/dependents?page&size`
  Errors: 404 dictionary_not_found / record_not_active, 400 invalid_pagination.
  Scope hiding делает target invisible (empty list / 404 — без leak existence).

Tests:
- LineageIndexServiceTest (16 unit tests):
  * Schema-level: not_found, scope hidden, single ref + count, onClose
    propagation (BLOCK default + CASCADE explicit), sorting, source scope
    hidden, self-reference skip, malformed skip, target without properties.
  * Record-level: invalid pagination (page<0, size<1, size>200),
    dict_not_found, record_not_active, no schema refs → empty page,
    1 source с dependents → items+perSource+total, pagination через
    page=0/1/2 size=2 (3 страницы из 5 records).
- ReferenceCloseRegressionTest (4 mandatory regression tests):
  * v1.2.1 schema без x-references-on-close — validate() возвращает
    те же error codes (никаких новых on_close/cascade в codes).
  * Schema parser default'ит onClose=BLOCK для v1.2.1 schemas.
  * Legacy single-arg parseRef(spec) без property schema сохраняет contract.
  * Schema без x-references вообще — full no-op (30+ legacy справочников).

mvn -pl ordinis-rest-api -am test: 89/89 PASS (5.686s).
mvn verify -pl '!ordinis-app': все модули SUCCESS (9.031s).

Backward-compat: bundle v1.2.1 → identical поведение к v1.0.7. Cascade engine
не поднимается на closed-side (Phase 3 territory).
2026-05-08 10:45:02 +03:00
Zimin A.N. c79ea22702 feat(references): OnCloseAction enum + ParsedRef.onClose field (Phase 1 prep)
dict-relationships-v2 epic, Phase 1 prep commit. Готовит почву для
параллельных Phase 1 (read-side lineage) + Phase 3 (cascade engine) lanes.

Изменения:
- Новый enum `OnCloseAction { BLOCK, WARN, CASCADE }` с lenient parser
  (case-insensitive, null/blank → BLOCK, unknown → IllegalArgumentException).
- `ParsedRef` record расширен полем `onClose` (default BLOCK).
  Convenience ctor `ParsedRef(dict, field)` сохранён для backward-compat
  с текущими тестами и call sites.
- `parseRef(spec, propSchema)` overload читает `x-references-on-close`
  sibling field. Старый `parseRef(spec)` оставлен и делегирует с propSchema=null.
- ReferenceValidator.validate() теперь передаёт propSchema в parser.
  Runtime semantics не меняются — onClose читается, но не используется.
  Cascade engine (Phase 3) подхватит поле когда CascadeCloseService появится.

Tests:
- ReferenceValidatorTest: +6 tests для onClose parsing
  (default BLOCK, explicit BLOCK/WARN/CASCADE case-insensitive,
   trim, invalid value → throw, non-string → throw).
- OnCloseActionTest: 4 dedicated unit tests для enum.fromString().
- BulkRecordServiceTest (10) + DictionaryRecordServiceTest (11) проходят
  без изменений → close flow regression-safe.

mvn -pl ordinis-rest-api -am test: 69/69 PASS (3.857s).

Backward-compat: bundle v1.2.1 (без x-references-on-close) → onClose=BLOCK
→ identical поведение к v1. Контракт сохранён.
2026-05-08 10:31:30 +03:00
Zimin A.N. 080ecb7a65 fix(ci): docker-docs всегда собирать на main+tag (drop path-filter)
deploy-staging упал с "context deadline exceeded" → atomic rollback.

Причина: коммит 29f99f0 трогал только .gitlab-ci.yml. По старому правилу
`changes: docs-changes` job docker-docs не запускался → ordinis-docs:main-29f99f05
не пушился. Но resolve-tag всё равно emit'ил DOCS_IMAGE_TAG=main-29f99f05.
Helm пытался pull → ImagePullBackOff → wait timeout → rollback.

Trade-off: docker-docs теперь срабатывает на любой main push. Layer cache
при отсутствии docs/* changes делает rebuild ~30 sec.
2026-05-08 09:51:26 +03:00
Zimin A.N. 29f99f058b fix(ci): пересобирать docker-docs на tag-pipeline + откат main-<sha> hack
Логика:
- maven-package + docker-app/read-api/projection-writer/migrations/admin-ui
  не имеют `rules:` → запускаются на любом pipeline (tag тоже).
  Они пушат "v1-0-X-<sha>" + ":latest" + ":<branch_or_tag>" — образы доступны.
- docker-docs имеет явные rules только на main+changes / web → на tag
  pipeline НЕ срабатывал → docs image "v1-0-X-<sha>" не пушился →
  helm на проде получал ImagePullBackOff.

Фикс:
1. docker-docs: добавил правило `if: $CI_COMMIT_TAG =~ /^vX.Y.Z$/`.
2. resolve-tag: откатил main-<sha> костыль — теперь снова просто
   $IMAGE_TAG, который на tag-pipeline валидно резолвится в v1-0-X-<sha>.
2026-05-08 09:29:45 +03:00
Zimin A.N. 9322f4de56 fix(ci): на tag-pipeline резолвить в main-<sha> (already-built images)
На tag-pipeline (`v1.0.X`) docker-* jobs не запускаются — их rules
матчатся только на main / merge_request / web trigger. Это by design:
тег создаётся уже после успешного main push, образы есть в registry
как `main-<sha>`.

Но resolve-tag раньше выдавал $IMAGE_TAG = "v1-0-5-<sha>" — такого
образа нет → helm pull падал.

Теперь на tag-pipeline резолвим в "main-<sha>" (тот же commit, тот же
SHORT_SHA). На main-pipeline поведение прежнее.
2026-05-08 09:27:57 +03:00
Zimin A.N. 0b1e097c67 fix(ci): resolve-tag должен фирить и на tag-пайплайнах
`trigger-deploy-prod` запускается только на тегах vX.Y.Z и has
`needs: [resolve-tag]`. Но `resolve-tag` имел правило только
`if $CI_COMMIT_BRANCH == 'main'` — на tag-пайплайне CI_COMMIT_BRANCH
пустое, job не появлялся в графе → trigger-deploy-prod ошибка
"resolve-tag does not exist in the pipeline".

Добавил второе правило: `if $CI_COMMIT_TAG =~ /^v[0-9]+...$/`.
Теперь resolve-tag нормально создаёт RESOLVED_IMAGE_TAG / RESOLVED_DOCS_TAG
и для теговых пайплайнов.
2026-05-08 09:26:41 +03:00
Zimin A.N. b96aad049f fix(docs/nginx): убрать try_files в unhashed fallback — alias bug
nginx с `alias` имеет известный bug: `try_files $uri` резолвит $uri от
document root, не от alias path. Для hashed assets (первое regex location)
try_files уже был убран ранее, но оставался в fallback unhashed location —
из-за этого `/docs/_assets/tabs-extension.js` (и .css) валились в 404
несмотря на наличие файлов в /usr/share/nginx/html/_assets/.

Теперь fallback просто alias'ит без try_files. Если файла нет — nginx сам
отдаст 404 от open() failure (нормальное поведение).
2026-05-08 09:22:15 +03:00
Zimin A.N. c24dec6a1f fix(docs): отдавать валидный empty sourcemap JSON вместо 204
DevTools пытается распарсить response как sourcemap JSON. 204 с пустым
body → `SyntaxError: JSON Parse error: Unexpected EOF` в console.

Решение: возвращаем минимально валидный sourcemap v3 с пустыми sources/
mappings — DevTools парсит, видит no-op, тихо проглатывает.
2026-05-08 09:10:35 +03:00
Zimin A.N. 11c9797757 feat(docs): вернуть YFM tabs syntax + bundle runtime assets
Diplodoc CLI 4.60 транформирует `{% list tabs %}` встроенным плагином,
но runtime assets (`_assets/tabs-extension.{js,css}`) НЕ копирует
автоматически — в отличие от mermaid/latex/page-constructor, у которых
bundle paths захардкожены в CLI.

Решение:
- добавил devDep `@diplodoc/tabs-extension@^3` (содержит runtime/)
- post-build `copy-tabs-runtime` script: копирует
  `build/runtime/index.{js,css}` → `_dist/_assets/tabs-extension.{js,css}`
- index.md: 4 секции "С чего начать" обёрнуты в `{% list tabs %}` —
  выглядит компактнее и интерактивнее чем плоский список заголовков

Verified локально: `yfm-tabs` HTML рендерится, assets в `_dist/_assets/`.
nginx fallback rule для `/docs/_assets/*` уже работает.
2026-05-08 09:09:51 +03:00
Zimin A.N. 425e5319db fix(docs): тихо отвечать 204 на .map запросы
Diplodoc CLI 4.60 не генерит sourcemaps, но оставляет
`//# sourceMappingURL=app.css.map` комментарии в JS/CSS bundles.
DevTools тянет .map файлы → 404 в console (6 ошибок на /docs/).

Nginx теперь возвращает 204 No Content для *.map под /docs/ —
DevTools тихо проглатывает, console зелёная.
2026-05-08 08:53:06 +03:00
Zimin A.N. 1d5ee4291e fix(docs): убрать YFM tabs syntax — extension не bundled by default
`{% list tabs %}` требует @diplodoc/tabs-extension package + plugin config,
не входит в base @diplodoc/cli build output. Без него HTML refs
/_assets/tabs-extension.{css,js} → 404 в browser console.

Convert на plain ### headers — same content, no missing assets, проще
для переводов и navigation.
2026-05-08 08:44:26 +03:00
Zimin A.N. ee509cd48b fix(docs/nginx): rewrite hashed bundle filenames + remove broken try_files
Diplodoc CLI 4.60 bug: HTML refs hashed asset names (например
`_bundle/app-34c68624e23be7ce.css`) но actual built _bundle/ contains
unhashed files (`app.css`). Browser получает 404 на все css/js
references → page broken.

Workaround в nginx — strip hash через regex location:
  ^/docs/(_bundle|_assets)/(.+?)-<hex8+>(\.<ext>)$
  → alias /usr/share/nginx/html/$1/$2$3

Также убран try_files который incorrectly matched $uri (URL path с hash)
вместо aliased file path → false 404.

Verified locally — 200 OK на:
  /docs/_bundle/app-34c68624e23be7ce.css
  /docs/_bundle/vendor-*.css
  /docs/_bundle/react-*.js
  /docs/integration/api/auth.html
2026-05-08 01:45:51 +03:00
Zimin A.N. b35c0c968a ci: trigger pipeline end-to-end verify
Prerequisites applied (см. ordinis-infra последние коммиты):
- d80511c → 8407192: build-postgis-barman только web trigger (no false fail)
- 1c65ca1: cluster-prod.yaml (max_connections 200→300, idle timeout, tcp keepalives)
- e4fe52d: deploy script handles infra-only changes без IMAGE_TAG vars
- 27648ee (ordinis): pnpm via npm install (no corepack hang)

Plus ordinis-config:
- ee39312: HikariCP pool cap=5 (защита от connection leak)

Cluster-prod.yaml уже applied на prod (1c65ca1 → kubectl apply done):
  max_connections: 300, idle JDBC dropped 197→40

Этот empty commit triggers full pipeline:
  test → build → publish (5 docker images) → trigger-deploy-staging
  → ordinis-infra downstream → validate → deploy-staging (auto, без manual click)

Если staging deploy passes — CI flow восстановлен.
2026-05-08 01:28:15 +03:00
Zimin A.N. 27648ee2f7 fix(ci): pnpm via npm install (skip corepack) — Dockerfile + build-docs job
Corepack может hang в CI runner на integrity-check либо non-TTY prompt
при первом activate. Заменяем на прямой npm install с pinned version
9.15.4 (latest stable v9, lockfile compat: lockfileVersion '9.0').

Изменения:
- ordinis-admin-ui/Dockerfile: corepack → npm install -g pnpm@9.15.4
- docs/Dockerfile: same swap
- .gitlab-ci.yml build-docs: same swap

Verified locally: docker build ordinis-admin-ui за 23 sec, no hang.
2026-05-08 00:35:41 +03:00
Zimin A.N. a73d0d31aa fix(admin-ui): pin pnpm@9 в Dockerfile — обойти v10 postinstall block в CI
pnpm@latest (= v10+) теперь блокирует postinstall scripts по умолчанию и
просит "Run pnpm approve-builds to pick which dependencies should be
allowed to run scripts". В CI нет интерактивного prompt'а → exit code 1.

Pin на v9 — keeps старое behaviour, совместимо с pnpm-lock.yaml v9.0.
Same подход что в docs/Dockerfile (pnpm@9.0.0 уже).

Если нужен v10 — добавь в package.json:
  "pnpm": { "onlyBuiltDependencies": ["esbuild", ...] }
либо --config.dangerouslyAllowAllBuilds=true в CI command.
2026-05-08 00:10:08 +03:00
Zimin A.N. b601c4636f feat(swagger): OAuth2/Keycloak PKCE flow в Swagger UI + docs links
OpenApiConfig:
- Добавлена OAuth2 security scheme (authorization_code flow с Keycloak
  realm `nstart`). Auth/token URLs из ordinis.openapi.oauth.issuer-uri
  configuration property (default: auth.nstart.space/realms/nstart).
- BearerAuth scheme сохранён secondary — для pre-issued tokens из CI/scripts.
- Servers list переупорядочен — prod первый, staging второй.
- Description обновлён с pointer на /docs/ для full integration guide.

application.yml:
- springdoc.swagger-ui.oauth: client-id (env override), use-pkce-with-
  authorization-code-grant=true. Public client `ordinis-swagger`
  настраивается отдельно в Keycloak — see keycloak-setup-swagger-client.md
  в ordinis-infra repo.
- ORDINIS_OPENAPI_OAUTH_ISSUER_URI env var для override realm на разных
  окружениях.

docs/integration/api/read-endpoints.md:
- Swagger UI section обновлён: акцент на Authorize button + Keycloak
  PKCE login (no client_secret needed), tip про bearerAuth fallback.

docs/index.md:
- Добавлен tab "Я хочу пощупать API" с link на Swagger UI.

После deploy:
- https://ordinis.k8s.264.nstart.cloud/swagger-ui.html  — Swagger UI
- https://ordinis.k8s.264.nstart.cloud/v3/api-docs      — OpenAPI spec
Authorize → выбрать oauth2 → Keycloak login (PKCE) → Try it out с JWT.
2026-05-08 00:08:29 +03:00
Zimin A.N. c12f515f43 fix(cuod-bundle): почистить FK orphans в seed (antenna, deliverable_type, ground_station)
Orphan FK references в seed данных bundle v1.2.1 — references на codes которых
нет в target словарях. Bundle import не валидирует x-references (privileged
data load), поэтому records ингестились с broken FK. На prod такие orphans
видны через ReferenceValidator при попытке update + через OrphanReferenceScanner.

Найдено через scan по 6 FK relations (antenna, consumer, deliverable_type,
orbital_regime, spacecraft×2):

1. antenna.ground_station (4 orphans):
   - GS_MOSCOW → MOSCOW_OTRADNOE (already существует)
   - GS_KHANTY/KRASNOYARSK/KHABAROVSK → добавили новые ground_station records
     (KHANTY_MANSIYSK_NPC, KRASNOYARSK_NPC, KHABAROVSK_NPC) — реальные станции
     НИЦ ПД ОМЗ Роскосмоса.

2. deliverable_type.typical_processing_level (3 orphans):
   - RADIANCE_L1B: L1 → L1B (businessKey hints L1B)
   - ORTHO_L1C: L1 → L1C (businessKey hints L1C)
   - SURFACE_REFL_L2A: L2 → L2A (businessKey hints L2A)

3. spacecraft.status — already исправлено в commit 5d5b03c (ACTIVE → OPERATIONAL).
   На prod 4 records остались с ACTIVE из старого import — нужен manual PATCH
   через writer API либо TRUNCATE + restart bundle import.

Verified: local seed scan теперь returns 0 orphans across all 6 FK relations.

Prod cleanup TODO (отдельный manual step, требует admin Keycloak token):
- spacecraft.status: 4 records (1999-068A, 2014-016A, 2015-028A, 2024-149E) → OPERATIONAL
- antenna.ground_station: 4 records → новые codes (MOSCOW_OTRADNOE / KHANTY_MANSIYSK_NPC / etc)
- deliverable_type.typical_processing_level: 3 records → L1B/L1C/L2A
2026-05-08 00:01:01 +03:00
Zimin A.N. cfe26a07a7 docs: Dockerfile + nginx + CI publish для deploy на ordinis.k8s.264.nstart.cloud/docs/
Multi-stage Dockerfile: yfm build (Node 20 alpine) → nginx 1.27 alpine
serving static HTML. Nginx config:
- /healthz endpoint для k8s probes
- /docs/ alias → /usr/share/nginx/html/ (Diplodoc делает relative links,
  alias корректно работает без URL rewriting)
- Hash-named assets (_bundle/) — cache 1y immutable
- HTML pages — cache 5min must-revalidate
- /docs (без trailing slash) → 301 → /docs/
- Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)

CI:
- build-docs (sanity check) — теперь только на non-main branches и MR
  (для preview без push). Production build идёт через docker-docs.
- docker-docs (NEW) — multi-stage Dockerfile build + push в repo.nstart.cloud
  через standard .docker_base + .publish_script pattern. Path-filtered на
  docs/**, manual через web UI.
- resolve-tag: emit RESOLVED_DOCS_TAG (= IMAGE_TAG, тот же что для backend).
- trigger-deploy-{staging,prod}: pass DOCS_IMAGE_TAG в downstream pipeline.

Local verify:
  cd docs && docker build -t ordinis-docs:test .
  docker run --rm -p 18088:8080 ordinis-docs:test
  curl localhost:18088/healthz       → 200
  curl -I localhost:18088/docs       → 301 → /docs/
  curl localhost:18088/docs/         → 200 index.html
  curl localhost:18088/docs/integration/api/auth.html → 200
  curl localhost:18088/docs/missing.html → 404

Соответствующие infra-side изменения (Helm chart docs deployment + Ingress
path /docs/, values, deploy script) — commit в ordinis-infra repo.
2026-05-07 23:44:03 +03:00
Zimin A.N. bcfb07f547 docs: split integrator guide (Diplodoc) vs internal docs + add build-docs CI
Чёткое разделение audiences:
- docs/ — consumer-facing only, Diplodoc-built. Public contract для
  интеграторов (Альтум, BI, third-party).
- docs-internal/ — operator runbooks, status snapshots, legacy tech pages.
  НЕ в Diplodoc build, НЕ публикуются.

Изменения:
- Move docs/{ops,status,tech} → docs-internal/{ops,status,tech}
- docs/toc.yaml: убраны секции Operations, Архитектура (legacy), Status
- docs/index.md: убран tab "оператор / on-call", focus на integrators
- docs/README.md: rewritten — scope только integrator guide, editorial
  guidelines (no leak internal architecture, stable API contract)
- .yfm: убран явный ignore status/* (теперь не нужно — папка переехала)
- docs-internal/README.md: index для внутренней документации с rationale
  разделения

Scrubbing implementation details из Diplodoc страниц:
- events.md: убраны Strimzi/cluster.142 references, OutboxPoller детали,
  internal alert names (OrdinisOutboxLagHigh, OrdinisOutboxDLQNonEmpty),
  metric names (nsi_outbox_*). Заменены на consumer-observable contract.
- webhooks.md: убраны metric names (nsi_webhook_ssrf_rejected_total),
  alert table (OrdinisWebhookHighFailureRate и т.д.). Retry policy
  переписана на user-side perspective.
- errors.md: убран alert name OrdinisReadApiErrorBudgetBurnFast, Tempo
  endpoint URL, internal connection details.
- x-references.md: OrphanReferenceScanner → general "Ordinis периодически
  детектирует". Убраны metric/alert names. Cascade roadmap без link на
  internal design doc.
- auth.md: убран file path ScopeContext.java.
- bundle-cuod.md: убран Maven module path.
- caching.md: cascade design link заменён на text reference.

CI:
- .gitlab-ci.yml: новый job build-docs (stage: build) — Node 20 alpine,
  pnpm install --frozen-lockfile + pnpm build, artifact docs/_dist на
  неделю. Triggers: auto на docs/**, либо manual web. Готов к follow-up
  pages-deploy job когда host решён.
- Новый pattern .docs-changes для rules.

Build verified clean: 13 HTML pages, 6.9M, no broken links/refs.
2026-05-07 23:27:03 +03:00
Zimin A.N. d5268b9395 docs: Diplodoc setup + полное руководство интеграции по API
Новая документация-as-code на платформе Diplodoc (Yandex open-source) с
YFM (Yandex Flavored Markdown). Build → static HTML, deployable куда угодно.

Что добавлено:
- package.json + .yfm + toc.yaml — Diplodoc setup, навигация для sidebar
- index.md — главная страница с tabs для разных audiences
- README.md — инструкция для contributors

Раздел integration/api/ — 11 страниц про интеграцию consumers:
- index — обзор + глоссарий
- auth — Keycloak service accounts, scope mapping
- read-endpoints — GET /dictionaries, list/by-key, spatial filters
- events — Kafka outbox топики, partition keys, idempotency, DLQ
- webhooks — HMAC, SSRF guard, retry policy, test ping endpoint
- bitemporal — validFrom/validTo + history, future-dated entries, bulk close/export
- x-references — FK syntax, validation errors, scope hiding, orphan scanner, v2 cascade preview
- caching — TTL / push / snapshot strategies, hybrid pattern
- errors — full error code reference (validation/auth/conflict/server), retry strategy
- best-practices — 33 numbered rules для consumers
- bundle-cuod — все 40 справочников с FK графом и сценариями

Обновлены:
- ops/README.md — link на новую integration/api/, убран inline <br>
- ops/slo.md — broken cross-repo links заменены на text references

Build:
  cd docs && pnpm install && pnpm build
  → ./_dist (20 HTML files, 7.1M)
  pnpm serve  # → http://localhost:8088

CI deploy job для GitLab Pages — в roadmap (см. docs/README.md).

Существующий tech/api-integration.md оставлен в toc как legacy
(содержимое мигрировано + расширено в integration/api/).
2026-05-07 23:19:02 +03:00
Zimin A.N. 0c79821f8e docs(roadmap): merge lineage + FK cascade в единый epic с design draft
Объединил два отдельных backlog item'а (Lineage / dependency graph + Cross-
dictionary FK с cascade rules) в один epic "Dictionary relationships v2" —
они complementary halves: lineage = read-side (discovery), cascade = write-side
(close semantics). Operate over same FK metadata, удобнее spec'ить вместе.

Design DRAFT 318 строк / 15KB:
- 3 stakeholder pain points (data stewards, Альтум consumers, schema editors)
- IN scope: A (lineage discovery 4 endpoints+UI), B (cascade rules block/warn/cascade), C (cross-cutting outbox+SLO)
- OUT scope (defer): schema-time validation, graph viz, multi-hop, cross-bundle
- 3 architecture options per area + recommendation (A=in-memory+MV hybrid, B=sync для v2)
- 4 D-questions для plan-eng-review
- 3 pre-code assignment items (Альтум behavior, UX preference, bundle versioning)
- 4-phase implementation (3-5 нед CC total)
- 4 risks + mitigations
- Success criteria с конкретными SLO targets
2026-05-07 23:05:18 +03:00
Zimin A.N. e307c15e27 docs(status): mark KafkaTopic orphan fix applied + k6-tests ConfigMap created
- Section "В процессе": KafkaTopics READY=False теперь struck-through, applied
  evening 2026-05-07 (все 3 topics annotated strimzi.io/managed=false, alerts
  больше не срабатывают, projection-writer connected).
- Section 0.1 artifacts: topic-orphan-fix.sh — added " applied" note inline.
- Section 0.1 artifacts: synthetic smoke pre-flight — отмечено что k6-tests
  ConfigMap уже создан, остаётся k6-keycloak-creds Secret (Keycloak SA
  client_secret) и apply CronJob + helm upgrade.
2026-05-07 23:01:25 +03:00
Zimin A.N. 6046c5aef6 docs(status): v1.0.1 — post-cutover hardening + image arch incident postmortem
Evening update к v1.0:
- SPOF на staging registry CLOSED (postgis-barman mirrored в repo.nstart.cloud:18.3-3.6.2-2)
- Monitoring artifacts ready (k6 prod variant, synthetic smoke + CronJob, 2 alerts)
- KafkaTopics READY=False janitorial fix (script ready)
- CI build job на 401 known-issue (manual fallback)

Section 0.1 добавлена с подробным postmortem image arch incident:
1. arm64 image из mac M-series попал в registry без --platform
2. kubelet cache на node удерживал stale arm64 layer под тем же тегом
3. Pod stuck Terminating 14 мин (CNPG gracePeriod=1800s)
Lessons закреплены в docker/postgis-barman/README.md (anti-footgun).
2026-05-07 22:53:32 +03:00
Zimin A.N. f0f84b1baa docs(status): v1.0 — production LIVE на cluster.142, Альтум переехал
Snapshot после Phase 1-5 production stand-up done:
- prod (cluster.142): LIVE, helm rev 22 на main-4252ad93, Альтум blue/green cutover завершён
- staging (cluster.265): теперь dev environment, не consumer-facing
- v0.9 → v1.0 anchor

Что задеплоено в prod (8 commits сегодня):
- bulk close + bulk export CSV
- audit URL persistence + chips
- pagination для records list (50/page)
- WebhookE2ETest re-enabled (no-op scheduler)
- 23 новых тестов

Operational artifacts (ordinis-infra):
- k8s/kafka/cluster-prod.yaml (Kafka recovery)
- scripts/vault/{setup,populate}.sh + README

Открытые items: KafkaTopics READY=False (orphaned),
postgis-barman image mirror в prod registry, k6 prod benchmark,
real ЦУОД data integration epic.

Approval workflow v2 deferred — design doc сохранён, re-evaluate
через 1-2 месяца после observed compliance needs.
2026-05-07 15:24:03 +03:00
Zimin A.N. 4252ad93a8 feat(records): bulk export CSV выбранных + pagination
Backend POST /api/v1/dictionaries/{name}/records/export-selected.csv:
принимает businessKeys[] (1..500), возвращает CSV с колонками
business_key, valid_from, valid_to, scope, data (JSONB inline).
Не-найденные ключи отдаются строкой с пустыми ячейками — admin видит
что ключ обработан, но данных нет. RFC 4180 экранирование.

requireReadAccess: для экспорта достаточно прав чтения (не write).

POST а не GET потому что >100 ключей не помещается в URL params
(Server: Request URI Too Long).

Frontend useBulkExportRecords: axios POST с responseType: blob →
temporary <a download> link → клик → revokeObjectURL через rAF.
Filename берётся из Content-Disposition header.

Toolbar: новая кнопка "Экспорт CSV" (variant=secondary) рядом с
"Закрыть выбранные" (variant=danger). Loading state через isPending.

Pagination для records list (PAGE_SIZE=50, client-side):
- visibleRecords = filteredRecords.slice(...)
- Footer "Записи 1–50 из 100, Стр. 1 из 2" с Prev/Next кнопками
- Auto-reset на page=0 при изменении filter/scope/AOI
- selectAll/visibleKeys теперь = только current page (WYSIWYG)
- При >50 записей видно навигацию, при <=50 footer скрыт

Прогон: 20/20 e2e + 60/60 rest-api + 89/89 admin-ui.
2026-05-07 12:12:31 +03:00
Zimin A.N. 66a4f13942 test(geo): e2e auto-derive geometry из data.lat/lon — 6 кейсов
Покрытие resolveGeometry() в DictionaryRecordService через REST API
(MockMvc + Postgres testcontainer с PostGIS):

- lat/lon → POINT(lon lat) с SRID 4326 (Москва 55.75, 37.62)
- latitude/longitude alias → тот же flow (СПб 59.93, 30.31)
- lat/lng alias → то же (Токио 35.68, 139.65)
- explicit geometryWkt приоритет над data.lat/lon
- out-of-range lat (200) / lon (300) → silent skip, geometry == null
- no lat/lon → geometry == null

Закрепляет недавнюю работу (commit 9214ee9) после которой Liquibase
0015 backfill заработал на staging. AOI spatial filter теперь работает
на ground_station и similar dictionaries автоматически — этот тест
гарантирует что регрессий не будет.

Прогон: 20/20 e2e (было 14, +6).
2026-05-07 12:02:00 +03:00
Zimin A.N. 640a633722 test(records): unit-test BulkRecordService — 10 кейсов
Покрытие: all-success, classification (record_not_active → skipped,
другие OrdinisException + unexpected → errors), дедуп дубликатов внутри
batch'а, blank/null business_key → skipped invalid_key, передача
at/reason в service, mixed results, empty batch, preservation порядка
(audit consistency).

Mockito не может мокать DictionaryRecordService на Java 17+ (Mockito
inline-mock limitation для классов из standard distribution). Workaround:
анонимный subclass с null deps в конструкторе — переопределённый close()
не обращается к ним. routedStub() helper для per-key поведения через
Map<businessKey, Exception>.

Прогон: 60/60 в rest-api модуле (было 50, +10).
2026-05-07 11:55:03 +03:00
Zimin A.N. 5941207955 feat(records): bulk close — multi-select toolbar + per-record транзакция
Backend POST /api/v1/dictionaries/{name}/records/bulk-close принимает
businessKeys[] (1..500) + reason. BulkRecordService крутит цикл через
inject'ed DictionaryRecordService — каждый close в своей транзакции
(SERIALIZABLE), partial success возможен. Result: closed[] / skipped[]
(already_closed, not_found) / errors[].

Frontend: checkbox column + sticky toolbar когда selection.size > 0.
Header checkbox с indeterminate state для partial select. selectAll
включает все ВИДИМЫЕ ключи (не игнорирует фильтры). При изменении
фильтра — useEffect выкидывает невидимые ключи из selection (WYSIWYG).

Bulk close dialog: count + reason input → confirm → результат панель
"Закрыто N, пропущено M, ошибок K" с детальным списком skipped/errors.
После успеха selection остаётся для skipped/errors — юзер видит что
ещё не сделано.

Лимит 500: защита от случайного огромного селекта или бага UI.
Дедупликация внутри batch'а silent skip — не error.
2026-05-07 11:19:57 +03:00
Zimin A.N. 92507ab0a1 feat(audit): URL persistence фильтров + chip-list активных
Фильтры audit log теперь живут в URL search params: dict, action, user,
bk, from, to, page, size. Result:
- reload не сбрасывает фильтр
- админ может поделиться ссылкой "audit за вчера на ground_station"
- browser back/forward работает по фильтрам как навигация
- ссылка из логов / тикета сразу открывает нужный срез

Chip-list над таблицей показывает активные фильтры; клик на chip
убирает только этот фильтр (не сбрасывает остальные). UX выгрузка
без копания в свёрнутой панели.

Apply на Enter для TextInput'ов user/businessKey — быстрее чем кнопка.

Validation в validateSearch: action — enum, page — non-negative int,
size — whitelist (50/100/200). Невалидные query params silently
дропаются (не throw — admin-ui остаётся на странице).
2026-05-07 11:03:43 +03:00
Zimin A.N. ac93151fe6 test(webhook): re-enable WebhookE2ETest через no-op scheduler + manual ticks
Fix CI flakiness: тест больше не полагается на @Scheduled тики каждые
200ms. NoSchedulingConfig подменяет TaskScheduler на no-op stub —
@Scheduled методы регистрируются, но никогда не запускаются. Тест
вручную дёргает dispatcher.matchTick() / sendTick() — synchronous,
deterministic, нет race с background threads.

Что изменилось:
- @Disabled убран
- Inject WebhookDispatcher, manual matchTick + sendTick после setup
- Await timeouts: 60s → 5s (manual ticks → нет 200ms scheduler delay)
- Pool=4 / send-interval-ms / match-interval-ms убраны (не нужны)
- Send-timeout-ms 1000 → 2000 (с запасом, но scheduler не работает)
- @AutoConfigureMockMvc сохранён, port=RANDOM_PORT, allowed-hosts тот же

Side-fix: @Autowired на Spring constructor WebhookTestPingService
(добавлен ранее package-private constructor для unit-тестов сделал
выбор конструктора неоднозначным; Spring 6 strict — требует
explicit @Autowired когда есть >1 candidate).

Локально: 2/2 PASSED 1.85s (было 9.9s — 5× быстрее).
CI: 14 e2e всех тестов passed (Smoke, OutboxKafka, Auth, Bitemporal,
Webhook).
2026-05-07 10:57:43 +03:00
Zimin A.N. c7a917ddad test(webhook): unit-test WebhookTestPingService — 7 кейсов без сети
Покрытие: SSRF reject (private CIDR + non-http scheme), success 2xx,
failure 5xx, network exception, body trim до 200 chars, HMAC signature
header. Нет реальных HTTP запросов — anonymous subclass переопределяет
protected sendRequest().

Refactor: allowedHostsCsv → @Value parameter в конструкторе вместо
@PostConstruct + поле, добавлен package-private constructor для inject
HttpClient/allowedHosts списка. Mockito не может мокать sealed HttpClient
на Java 17+, поэтому через extension point sendRequest().

Lightweight HttpResponse stub реализует только statusCode()+body() без
Mockito.
2026-05-07 10:48:21 +03:00
Zimin A.N. 09d2d1c325 test: @Disabled WebhookE2ETest — flaky на CI несмотря на 3 итерации fix'ов
Третий timeout подряд: pool=4 + send-timeout=1s + cleanup @BeforeEach
+ truncate outbox не помогли. ConditionTimeout 60s × 2 теста.

Локально 2/2 PASS за 9.9s — значит логика работает. CI runner
медленнее + race conditions между:
- Spring @Scheduled threads (даже pool=4)
- WebhookDispatcher @PostConstruct (фаерится при context init)
- Postgres testcontainer reuse (leftover deliveries из предыдущих CI runs)
- Наш @BeforeEach cleanup (происходит ПОСЛЕ context init)

Логика покрыта 26 unit tests (HmacSigner + SsrfGuard + DispatcherMatch +
DeliveryLifecycle). E2e только sanity на full pipeline — ROI не
оправдывает дальнейших попыток.

Re-enable варианты на будущее:
1. Dedicated CI runner с warm Docker images (отдельная инфра)
2. testcontainer без reuse (slower CI, но reliable state)
3. @Sql(executionPhase = BEFORE_TEST_CLASS) — cleanup ДО Spring context init,
   до того как dispatcher @PostConstruct запустится с leftover state
4. Манульно invoke matchTick/sendTick без @Scheduled — deterministic
2026-05-06 22:54:43 +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