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:
@@ -3,6 +3,7 @@ import {
|
||||
apiClient,
|
||||
type BulkCloseRequest,
|
||||
type BulkCloseResponse,
|
||||
type CascadeCloseResult,
|
||||
type CreateDictionaryRequest,
|
||||
type CreateRecordRequest,
|
||||
type CreateWebhookSubscriptionRequest,
|
||||
@@ -103,6 +104,49 @@ export const useCloseRecord = (dictionaryName: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade close — Phase 3 dict-relationships-v2. Атомарно закрывает target +
|
||||
* все CASCADE dependents в одной transaction. UI должен сначала вызвать
|
||||
* `cascade-preview` query, показать confirmation, и только потом запускать
|
||||
* этот мутатор с `confirmed=true`.
|
||||
*
|
||||
* <p>Backend errors:
|
||||
* <ul>
|
||||
* <li>409 x_references_blocked_by_dependents — есть BLOCK deps. UI отображает
|
||||
* список и просит resolve.</li>
|
||||
* <li>409 x_references_cascade_required — confirmed=false при наличии cascade.</li>
|
||||
* <li>503 x_references_cascade_timeout — tx timeout (>30s). User retry'ит.</li>
|
||||
* </ul>
|
||||
*/
|
||||
export const useCascadeCloseRecord = (dictionaryName: string) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (params: {
|
||||
businessKey: string
|
||||
reason?: string
|
||||
confirmed: boolean
|
||||
}): Promise<CascadeCloseResult> => {
|
||||
const queryParams: Record<string, string | boolean> = {
|
||||
confirmed: params.confirmed,
|
||||
}
|
||||
if (params.reason) queryParams.reason = params.reason
|
||||
const { data } = await apiClient.post<CascadeCloseResult>(
|
||||
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}/cascade-close`,
|
||||
null,
|
||||
{ params: queryParams },
|
||||
)
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate всё что может быть affected: records list + dependents
|
||||
// queries (target и cascaded sources меняют состояние).
|
||||
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||||
qc.invalidateQueries({ queryKey: ['record-dependents'] })
|
||||
qc.invalidateQueries({ queryKey: ['records'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk close: закрыть N records одним запросом. Backend per-record транзакция,
|
||||
* partial success возможен (already_closed / not_found попадает в skipped).
|
||||
|
||||
Reference in New Issue
Block a user