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:
@@ -286,6 +286,32 @@ export type LineageMeta = {
|
||||
lastDurationMs: number | null
|
||||
}
|
||||
|
||||
export type CascadeEntry = {
|
||||
sourceDict: string
|
||||
sourceDisplayName?: string | null
|
||||
businessKey: string
|
||||
sourceField: string
|
||||
onClose: OnCloseAction
|
||||
recordId: string
|
||||
}
|
||||
|
||||
export type CascadePlan = {
|
||||
targetDict: string
|
||||
targetKey: string
|
||||
blockers: CascadeEntry[]
|
||||
warnings: CascadeEntry[]
|
||||
cascade: CascadeEntry[]
|
||||
totalDependents: number
|
||||
}
|
||||
|
||||
export type CascadeCloseResult = {
|
||||
targetDict: string
|
||||
targetKey: string
|
||||
cascadedRecords: CascadeEntry[]
|
||||
warnings: CascadeEntry[]
|
||||
totalClosed: number
|
||||
}
|
||||
|
||||
export type RecordDependentsPage = {
|
||||
targetDict: string
|
||||
targetBusinessKey: string
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
apiClient,
|
||||
type AuditFilters,
|
||||
type AuditPage,
|
||||
type CascadePlan,
|
||||
type DictionaryDefinition,
|
||||
type DictionaryDetail,
|
||||
type DlqPage,
|
||||
@@ -331,6 +332,31 @@ export const useRecordDependents = (
|
||||
enabled: Boolean(businessKey),
|
||||
})
|
||||
|
||||
/**
|
||||
* Cascade preview — read-only план: blockers / warnings / cascade entries.
|
||||
* Используется UI'ем перед открытием cascade confirmation dialog.
|
||||
*
|
||||
* <p>Не cache'ится агрессивно (staleTime=0), потому что dependents
|
||||
* быстро меняются — preview всегда должен быть fresh.
|
||||
*/
|
||||
export const cascadePreviewQuery = (dict: string, businessKey: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['cascade-preview', dict, businessKey] as const,
|
||||
queryFn: async (): Promise<CascadePlan> => {
|
||||
const { data } = await apiClient.get<CascadePlan>(
|
||||
`/dictionaries/${encodeURIComponent(dict)}/records/${encodeURIComponent(businessKey)}/cascade-preview`,
|
||||
)
|
||||
return data
|
||||
},
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
export const useCascadePreview = (dict: string, businessKey: string | undefined) =>
|
||||
useQuery({
|
||||
...cascadePreviewQuery(dict, businessKey ?? ''),
|
||||
enabled: Boolean(businessKey),
|
||||
})
|
||||
|
||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||
export const useRecords = (
|
||||
|
||||
Reference in New Issue
Block a user