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:
Zimin A.N.
2026-05-08 11:23:46 +03:00
parent c06ae263e0
commit 3eeaba058f
10 changed files with 1055 additions and 9 deletions
+26
View File
@@ -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
+44
View File
@@ -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).
+26
View File
@@ -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 = (
@@ -0,0 +1,289 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, Badge, Button, LoadingBlock, Modal, TextInput } from '@nstart/ui'
import { WarningIcon, XCircleIcon } from '@phosphor-icons/react'
import { useCascadePreview } from '@/api/queries'
import { useCascadeCloseRecord } from '@/api/mutations'
import type { CascadeEntry, OnCloseAction } from '@/api/client'
/**
* Cascade close confirmation dialog — Phase 3 dict-relationships-v2.
*
* <p>Поток:
* <ol>
* <li>Открывается из existing close flow когда DELETE возвращает 409
* (x_references_blocked_by_dependents или x_references_cascade_required).</li>
* <li>Загружает preview через {@code GET /cascade-preview}.</li>
* <li>Если есть BLOCKERS — показывает их + объясняет что resolve first;
* confirm button disabled.</li>
* <li>Если есть только CASCADE/WARN — показывает per-source breakdown +
* sample 3 records per source, plus "Type CONFIRM" guard если N {@literal >} 50.</li>
* <li>Confirm → {@code POST /cascade-close?confirmed=true} → success message.</li>
* <li>Cancel → close dialog без изменений.</li>
* </ol>
*
* <p>UX guards:
* <ul>
* <li>"Type CONFIRM" gate когда total &gt; 50 (защита от accidental cascade).</li>
* <li>Reason field — optional; пустой = backend audit fields пустые.</li>
* </ul>
*/
type Props = {
open: boolean
onClose: () => void
/** Called after successful cascade close. */
onSuccess: () => void
dictionaryName: string
businessKey: string | undefined
}
const onCloseVariant = (
a: OnCloseAction,
): 'neutral' | 'warning' | 'error' => {
if (a === 'CASCADE') return 'error'
if (a === 'WARN') return 'warning'
return 'neutral'
}
const SAMPLE_PER_SOURCE = 3
const CONFIRM_GATE_THRESHOLD = 50
export const CascadeConfirmDialog = ({
open,
onClose,
onSuccess,
dictionaryName,
businessKey,
}: Props) => {
const { t } = useTranslation()
const preview = useCascadePreview(dictionaryName, open ? businessKey : undefined)
const cascadeMut = useCascadeCloseRecord(dictionaryName)
const [reason, setReason] = useState('')
const [confirmText, setConfirmText] = useState('')
// Reset state каждый раз когда dialog открывается с новой target.
useEffect(() => {
if (!open) {
setReason('')
setConfirmText('')
cascadeMut.reset()
}
}, [open, businessKey, cascadeMut])
const plan = preview.data
const hasBlockers = (plan?.blockers.length ?? 0) > 0
const totalToClose = 1 + (plan?.cascade.length ?? 0)
const requiresGate = totalToClose > CONFIRM_GATE_THRESHOLD
const gatePassed = !requiresGate || confirmText.trim().toUpperCase() === 'CONFIRM'
// Group cascade entries by source dict для compact display.
const cascadeBySource = useMemo(() => {
const map = new Map<string, CascadeEntry[]>()
for (const e of plan?.cascade ?? []) {
const key = `${e.sourceDict}.${e.sourceField}`
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(e)
}
return Array.from(map.entries())
}, [plan?.cascade])
const handleConfirm = () => {
if (!businessKey) return
cascadeMut.mutate(
{ businessKey, reason: reason.trim() || undefined, confirmed: true },
{
onSuccess: () => {
onSuccess()
onClose()
},
},
)
}
return (
<Modal
isOpen={open}
onClose={onClose}
title={t('cascade.title')}
maxWidth="max-w-2xl"
>
<div className="space-y-4">
{preview.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{preview.error && (
<Alert variant="error" title={t('error.failed')}>
{String(preview.error)}
</Alert>
)}
{plan && (
<>
{/* Header summary */}
<p className="text-sm text-carbon">
{hasBlockers
? t('cascade.summary.blocked', {
blockers: plan.blockers.length,
target: businessKey,
})
: t('cascade.summary.willClose', {
total: totalToClose,
target: businessKey,
})}
</p>
{/* BLOCKERS section — non-resolvable. */}
{plan.blockers.length > 0 && (
<Alert
variant="error"
title={t('cascade.blockers.title', { count: plan.blockers.length })}
>
<ul className="space-y-1.5 mt-2 text-2xs">
{plan.blockers.slice(0, 5).map((b) => (
<li key={b.recordId} className="flex items-center gap-2">
<XCircleIcon weight="bold" size={12} className="text-aurora shrink-0" />
<span className="font-mono">
{b.sourceDict}/{b.businessKey}
</span>
<span className="text-carbon/60">.{b.sourceField}</span>
</li>
))}
{plan.blockers.length > 5 && (
<li className="text-carbon/60">
{t('cascade.blockers.more', { count: plan.blockers.length - 5 })}
</li>
)}
</ul>
<p className="mt-2 text-2xs text-carbon/70">
{t('cascade.blockers.resolveFirst')}
</p>
</Alert>
)}
{/* CASCADE section — preview per-source. */}
{!hasBlockers && plan.cascade.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<WarningIcon weight="bold" size={14} className="text-horizon" />
<h4 className="text-sm font-primary">
{t('cascade.cascade.title', { count: plan.cascade.length })}
</h4>
</div>
<ul className="space-y-2">
{cascadeBySource.map(([key, entries]) => {
const first = entries[0]
const sample = entries.slice(0, SAMPLE_PER_SOURCE)
const overflow = entries.length - sample.length
return (
<li
key={key}
className="border border-regolith rounded-sm px-3 py-2 text-2xs"
>
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-ultramarain">
{first.sourceDisplayName ?? first.sourceDict}
</span>
<span className="font-mono text-carbon/70">
.{first.sourceField}
</span>
<Badge variant={onCloseVariant(first.onClose)}>
{t(`lineage.onClose.${first.onClose}`)}
</Badge>
<Badge variant="neutral">{entries.length}</Badge>
</div>
<ul className="text-2xs text-carbon/70 ml-1 space-y-0.5">
{sample.map((e) => (
<li key={e.recordId} className="font-mono">
· {e.businessKey}
</li>
))}
{overflow > 0 && (
<li className="text-carbon/50">
{t('cascade.cascade.more', { count: overflow })}
</li>
)}
</ul>
</li>
)
})}
</ul>
</section>
)}
{/* WARNINGS section — proceed but emit warnings. */}
{!hasBlockers && plan.warnings.length > 0 && (
<Alert
variant="warning"
title={t('cascade.warnings.title', { count: plan.warnings.length })}
>
<p className="text-2xs">{t('cascade.warnings.note')}</p>
</Alert>
)}
{/* Reason input — only when going to proceed. */}
{!hasBlockers && (plan.cascade.length > 0 || plan.warnings.length > 0) && (
<div>
<label
htmlFor="cascade-reason"
className="text-2xs text-carbon/70 uppercase tracking-label"
>
{t('cascade.reason.label')}
</label>
<TextInput
id="cascade-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('cascade.reason.placeholder')}
/>
</div>
)}
{/* Type CONFIRM gate when N > 50. */}
{!hasBlockers && requiresGate && (
<div>
<label
htmlFor="cascade-confirm"
className="text-2xs text-carbon/70 uppercase tracking-label"
>
{t('cascade.confirmGate.label', { word: 'CONFIRM' })}
</label>
<TextInput
id="cascade-confirm"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="CONFIRM"
/>
</div>
)}
{cascadeMut.error && (
<Alert variant="error" title={t('error.failed')}>
{String(cascadeMut.error)}
</Alert>
)}
</>
)}
{/* Footer actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-regolith">
<Button
variant="secondary"
onClick={onClose}
disabled={cascadeMut.isPending}
>
{t('cascade.action.cancel')}
</Button>
{!hasBlockers && plan && (plan.cascade.length > 0 || plan.warnings.length > 0) && (
<Button
variant="danger"
onClick={handleConfirm}
disabled={cascadeMut.isPending || !gatePassed}
loading={cascadeMut.isPending}
>
{t('cascade.action.confirm', { count: totalToClose })}
</Button>
)}
</div>
</div>
</Modal>
)
}
+56
View File
@@ -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',
@@ -29,6 +29,7 @@ import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { nowIsoLocal } from '@/lib/dates'
@@ -107,6 +108,9 @@ function DictionaryDetail() {
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
/** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
* на close, или явным action из record menu. */
const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)
// Multi-select state. Set<businessKey> — стабильное id'шник. При изменении
// records data (mutation success / refetch / filter change) — selection
@@ -333,13 +337,31 @@ function DictionaryDetail() {
const handleClose = () => {
if (edit.kind !== 'close-confirm') return
const targetKey = edit.record.businessKey
closeMut.mutate(
{ businessKey: edit.record.businessKey, reason: closeReason || undefined },
{ businessKey: targetKey, reason: closeReason || undefined },
{
onSuccess: () => {
setEdit({ kind: 'closed' })
setCloseReason('')
},
onError: (err: unknown) => {
// Phase 3 dict-relationships-v2: 409 с cascade-related code
// открывает CascadeConfirmDialog. Иначе error inline в close-confirm.
const status =
(err as { response?: { status?: number } })?.response?.status
const code = (err as { response?: { data?: { code?: string } } })
?.response?.data?.code
if (
status === 409 &&
(code === 'x_references_blocked_by_dependents' ||
code === 'x_references_cascade_required')
) {
setEdit({ kind: 'closed' })
setCloseReason('')
setCascadeKey(targetKey)
}
},
},
)
}
@@ -757,6 +779,17 @@ function DictionaryDetail() {
businessKey={historyKey}
/>
<CascadeConfirmDialog
open={Boolean(cascadeKey)}
onClose={() => setCascadeKey(undefined)}
onSuccess={() => {
setCascadeKey(undefined)
recordsResult.refetch()
}}
dictionaryName={name}
businessKey={cascadeKey}
/>
<AoiPickerDialog
isOpen={aoiOpen}
onClose={() => setAoiOpen(false)}