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 покрывает.
This commit is contained in:
@@ -251,6 +251,40 @@ i18n
|
||||
'lineage.refs.openSourceRecord': 'Открыть запись {{key}} в источнике',
|
||||
'lineage.refs.stale': 'обновлено {{minutes}} мин назад',
|
||||
'lineage.refs.staleHint': 'Данные из materialized view; обновляются раз в минуту. Закрытые между обновлениями записи отфильтрованы по valid_to.',
|
||||
// === Cascade close (Phase 3) ===
|
||||
'cascade.title': 'Каскадное закрытие',
|
||||
'cascade.summary.willClose_one': 'Будет закрыта {{total}} запись (включая «{{target}}»). Подтверждение требуется.',
|
||||
'cascade.summary.willClose_few': 'Будут закрыты {{total}} записи (включая «{{target}}»). Подтверждение требуется.',
|
||||
'cascade.summary.willClose_many': 'Будут закрыты {{total}} записей (включая «{{target}}»). Подтверждение требуется.',
|
||||
'cascade.summary.willClose_other': 'Будут закрыты {{total}} записей (включая «{{target}}»). Подтверждение требуется.',
|
||||
'cascade.summary.blocked_one': 'Закрытие «{{target}}» заблокировано: есть {{blockers}} активная зависимость.',
|
||||
'cascade.summary.blocked_few': 'Закрытие «{{target}}» заблокировано: есть {{blockers}} активные зависимости.',
|
||||
'cascade.summary.blocked_many': 'Закрытие «{{target}}» заблокировано: есть {{blockers}} активных зависимостей.',
|
||||
'cascade.summary.blocked_other': 'Закрытие «{{target}}» заблокировано: есть {{blockers}} активных зависимостей.',
|
||||
'cascade.blockers.title_one': '{{count}} запись блокирует закрытие',
|
||||
'cascade.blockers.title_few': '{{count}} записи блокируют закрытие',
|
||||
'cascade.blockers.title_many': '{{count}} записей блокируют закрытие',
|
||||
'cascade.blockers.title_other': '{{count}} записей блокируют закрытие',
|
||||
'cascade.blockers.more': 'и ещё {{count}}…',
|
||||
'cascade.blockers.resolveFirst': 'Сначала закройте или замените эти ссылки, затем повторите попытку.',
|
||||
'cascade.cascade.title_one': 'Будет каскадно закрыта {{count}} зависимая запись',
|
||||
'cascade.cascade.title_few': 'Будут каскадно закрыты {{count}} зависимых записи',
|
||||
'cascade.cascade.title_many': 'Будет каскадно закрыто {{count}} зависимых записей',
|
||||
'cascade.cascade.title_other': 'Будет каскадно закрыто {{count}} зависимых записей',
|
||||
'cascade.cascade.more': 'и ещё {{count}}…',
|
||||
'cascade.warnings.title_one': '{{count}} запись останется без ссылки (warn-mode)',
|
||||
'cascade.warnings.title_few': '{{count}} записи останутся без ссылки (warn-mode)',
|
||||
'cascade.warnings.title_many': '{{count}} записей останутся без ссылки (warn-mode)',
|
||||
'cascade.warnings.title_other': '{{count}} записей останутся без ссылки (warn-mode)',
|
||||
'cascade.warnings.note': 'После закрытия они станут orphan и будут видны на orphan scanner. Можно поправить ссылки потом.',
|
||||
'cascade.reason.label': 'Причина (необязательно)',
|
||||
'cascade.reason.placeholder': 'Например: декомиссия станции SAR-X',
|
||||
'cascade.confirmGate.label': 'Введите слово {{word}} для подтверждения',
|
||||
'cascade.action.cancel': 'Отмена',
|
||||
'cascade.action.confirm_one': 'Закрыть {{count}} запись',
|
||||
'cascade.action.confirm_few': 'Закрыть {{count}} записи',
|
||||
'cascade.action.confirm_many': 'Закрыть {{count}} записей',
|
||||
'cascade.action.confirm_other': 'Закрыть {{count}} записей',
|
||||
'lineage.onClose.BLOCK': 'block',
|
||||
'lineage.onClose.WARN': 'warn',
|
||||
'lineage.onClose.CASCADE': 'cascade',
|
||||
@@ -556,6 +590,28 @@ i18n
|
||||
'lineage.refs.openSourceRecord': 'Open referencing record {{key}}',
|
||||
'lineage.refs.stale': 'refreshed {{minutes}} min ago',
|
||||
'lineage.refs.staleHint': 'Data from materialized view, refreshed once per minute. Records closed between refreshes are filtered by valid_to.',
|
||||
// === Cascade close (Phase 3) ===
|
||||
'cascade.title': 'Cascade close',
|
||||
'cascade.summary.willClose_one': '{{total}} record will be closed (including «{{target}}»). Confirmation required.',
|
||||
'cascade.summary.willClose_other': '{{total}} records will be closed (including «{{target}}»). Confirmation required.',
|
||||
'cascade.summary.blocked_one': 'Cannot close «{{target}}»: {{blockers}} active dependency.',
|
||||
'cascade.summary.blocked_other': 'Cannot close «{{target}}»: {{blockers}} active dependencies.',
|
||||
'cascade.blockers.title_one': '{{count}} record blocks close',
|
||||
'cascade.blockers.title_other': '{{count}} records block close',
|
||||
'cascade.blockers.more': 'and {{count}} more…',
|
||||
'cascade.blockers.resolveFirst': 'Close or repoint these references first, then retry.',
|
||||
'cascade.cascade.title_one': '{{count}} dependent record will be cascaded',
|
||||
'cascade.cascade.title_other': '{{count}} dependent records will be cascaded',
|
||||
'cascade.cascade.more': 'and {{count}} more…',
|
||||
'cascade.warnings.title_one': '{{count}} record will become orphan (warn-mode)',
|
||||
'cascade.warnings.title_other': '{{count}} records will become orphan (warn-mode)',
|
||||
'cascade.warnings.note': 'After close they will become orphan and visible to orphan scanner. References can be fixed later.',
|
||||
'cascade.reason.label': 'Reason (optional)',
|
||||
'cascade.reason.placeholder': 'e.g. decommissioning station SAR-X',
|
||||
'cascade.confirmGate.label': 'Type {{word}} to confirm',
|
||||
'cascade.action.cancel': 'Cancel',
|
||||
'cascade.action.confirm_one': 'Close {{count}} record',
|
||||
'cascade.action.confirm_other': 'Close {{count}} records',
|
||||
'lineage.onClose.BLOCK': 'block',
|
||||
'lineage.onClose.WARN': 'warn',
|
||||
'lineage.onClose.CASCADE': 'cascade',
|
||||
|
||||
Reference in New Issue
Block a user