import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AgGridReact } from "ag-grid-react"; import type { ColDef, ICellRendererParams } from "ag-grid-community"; import { ArrowLeft, History, Info, Play, Plus, RefreshCw } from "lucide-react"; import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { ApiError, apiErrorMessage } from "../../api/client"; import { createCatalogRelationship, deleteCatalogRelationship, listCatalogRelationships, listCatalogColumns, listCatalogTables, rebuildGeneratedRelationships, setCatalogRelationshipStatus, startCatalogSync, type CatalogDatabase, type CatalogRelationship, type CatalogSyncRun, } from "../../api/catalog-databases"; import { FleetLedgerDrawer } from "./FleetLedgerShell"; import type { DatabaseNavigationState } from "./model"; interface Props { database: CatalogDatabase; canManage: boolean; onBackToDatabases: () => void; onOpenOverview: () => void; onOpenTables: () => void; onRunStarted: (run: CatalogSyncRun) => void; onOpenSync: () => void; onOpenDescriptionHistory?: () => void; onOpenSensitiveHistory?: () => void; onCatalogMetricsChanged?: () => void | Promise; onNavigationStateChange?: (state: DatabaseNavigationState) => void; presentation?: "legacy" | "fleet"; } interface RelationshipGridContext { presentation: "legacy" | "fleet"; onView: (relationship: CatalogRelationship, origin: HTMLButtonElement) => void; } type RelationshipStatusFilter = "active" | "excluded" | "all"; function titleCase(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } function relationshipLabel(relationship: CatalogRelationship): string { const pair = relationship.columns[0]; const source = `${relationship.sourceTableName}${pair ? `.${pair.sourceColumnName}` : ""}`; const target = `${relationship.targetTableName}${pair ? `.${pair.targetColumnName}` : ""}`; return `${source} to ${target}`; } function RelationshipActionsCell({ data, context }: ICellRendererParams) { if (!data || !context || context.presentation !== "fleet") return null; return (
event.stopPropagation()}>
); } export function DatabaseRelationships({ database, canManage, onBackToDatabases, onOpenOverview, onOpenTables, onRunStarted, onOpenSync, onOpenDescriptionHistory, onOpenSensitiveHistory, onCatalogMetricsChanged, onNavigationStateChange, presentation = "legacy", }: Props) { const databaseId = database.id!; const queryClient = useQueryClient(); const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey: ["catalog-relationships", databaseId], queryFn: () => listCatalogRelationships(databaseId), retry: false, }); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("active"); const [busy, setBusy] = useState(false); const [selectedAction, setSelectedAction] = useState<"" | "rebuild-generated" | "relationships">(""); const [activeRelationshipId, setActiveRelationshipId] = useState(null); const [addOpen, setAddOpen] = useState(false); const [sourceTableId, setSourceTableId] = useState(""); const [sourceColumnId, setSourceColumnId] = useState(""); const [targetTableId, setTargetTableId] = useState(""); const [targetColumnId, setTargetColumnId] = useState(""); const [addError, setAddError] = useState(null); const [targetColumnError, setTargetColumnError] = useState(null); const [pendingMutation, setPendingMutation] = useState<"exclude" | "delete" | null>(null); const originRef = useRef(null); const statusFilterRef = useRef(null); const activeRelationship = activeRelationshipId ? data.find((relationship) => relationship.id === activeRelationshipId) : undefined; const visibleRelationships = useMemo(() => statusFilter === "all" ? data : data.filter((relationship) => relationship.status === statusFilter), [data, statusFilter]); const tablesQuery = useQuery({ queryKey: ["catalog-tables", databaseId], queryFn: () => listCatalogTables(databaseId), enabled: addOpen, retry: false, }); const sourceColumnsQuery = useQuery({ queryKey: ["catalog-columns", databaseId, sourceTableId], queryFn: () => listCatalogColumns(databaseId, sourceTableId), enabled: addOpen && Boolean(sourceTableId), retry: false, }); const targetColumnsQuery = useQuery({ queryKey: ["catalog-columns", databaseId, targetTableId], queryFn: () => listCatalogColumns(databaseId, targetTableId), enabled: addOpen && Boolean(targetTableId), retry: false, }); const targetColumns = (targetColumnsQuery.data ?? []).filter((column) => column.isPrimaryKey); useEffect(() => { if (!pendingMutation) return; const timer = window.setTimeout(() => document .getElementById("relationship-mutation-confirm-button")?.focus(), 0); return () => window.clearTimeout(timer); }, [pendingMutation]); useEffect(() => { onNavigationStateChange?.({ dirty: false, busy }); return () => onNavigationStateChange?.({ dirty: false, busy: false }); }, [busy, onNavigationStateChange]); const selectedActionUnavailable = !selectedAction ? null : !canManage ? "You do not have permission to manage catalog relationships." : database.activeSyncRun ? "Wait for the active synchronization to finish." : null; const addUnavailable = !canManage ? "You do not have permission to add catalog relationships." : database.activeSyncRun ? "Wait for the active synchronization to finish." : busy ? "Wait for the current relationship operation to finish." : null; const synchronize = async (scope: "relationships") => { setBusy(true); try { const run = await startCatalogSync(databaseId, database.version, scope); onRunStarted(run); toast.success("Relationship synchronization started"); } catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); } }; const openRelationship = (relationship: CatalogRelationship, origin: HTMLButtonElement) => { originRef.current = origin; setPendingMutation(null); setAddOpen(false); setActiveRelationshipId(relationship.id); }; const closeRelationship = () => { if (busy) return; setPendingMutation(null); setActiveRelationshipId(null); window.setTimeout(() => { if (originRef.current?.isConnected) originRef.current.focus(); else statusFilterRef.current?.focus(); }, 0); }; const changeRelationshipStatus = async (status: "active" | "excluded") => { if (!activeRelationship || activeRelationship.origin === "physical") return; setBusy(true); try { await setCatalogRelationshipStatus(databaseId, activeRelationship.id, status); await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true }); await onCatalogMetricsChanged?.(); setPendingMutation(null); setActiveRelationshipId(null); window.setTimeout(() => { if (originRef.current?.isConnected) originRef.current.focus(); else statusFilterRef.current?.focus(); }, 0); toast.success(status === "excluded" ? "Relationship excluded. Future builds will leave it excluded." : "Relationship restored."); } catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); } }; const permanentlyDeleteRelationship = async () => { if (!activeRelationship || activeRelationship.origin === "physical") return; setBusy(true); try { await deleteCatalogRelationship(databaseId, activeRelationship.id); await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true }); await onCatalogMetricsChanged?.(); setPendingMutation(null); setActiveRelationshipId(null); window.setTimeout(() => { if (originRef.current?.isConnected) originRef.current.focus(); else statusFilterRef.current?.focus(); }, 0); toast.success("Relationship deleted permanently. A future build may infer it again."); } catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); } }; const resetAddForm = () => { setSourceTableId(""); setSourceColumnId(""); setTargetTableId(""); setTargetColumnId(""); setAddError(null); setTargetColumnError(null); }; const closeAdd = () => { if (busy) return; setAddOpen(false); resetAddForm(); }; const addRelationship = async () => { if (!sourceColumnId || !targetColumnId) return; setBusy(true); setAddError(null); setTargetColumnError(null); try { await createCatalogRelationship(databaseId, sourceColumnId, targetColumnId); await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true }); await onCatalogMetricsChanged?.(); setAddOpen(false); resetAddForm(); toast.success("Relationship added."); } catch (error) { if (error instanceof ApiError && error.code === "relationship_target_not_unique") { setTargetColumnError("The target column must be the only primary-key column of its table."); } else if (error instanceof ApiError && error.code === "relationship_type_incompatible") { setTargetColumnError("Source and target column types are not compatible."); } else if (error instanceof ApiError && error.code === "relationship_duplicate") { setAddError("This relationship already exists."); } else { setAddError(apiErrorMessage(error)); } toast.error(apiErrorMessage(error)); } finally { setBusy(false); } }; const runSelectedAction = async () => { if (!selectedAction) return; if (selectedAction === "rebuild-generated") { setBusy(true); try { const result = await rebuildGeneratedRelationships(databaseId); await queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId], exact: true }); await onCatalogMetricsChanged?.(); const noFindings = result.added === 0 && result.alreadyPresent === 0 && result.excluded === 0 && result.ambiguous === 0; toast.success(noFindings ? "Build complete: no new relationships found." : `Build complete: ${result.added} added, ${result.alreadyPresent} already present, ${result.excluded} excluded, ${result.ambiguous} ambiguous.`); } catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); } } else { await synchronize(selectedAction); } setSelectedAction(""); }; const columns = useMemo[]>(() => [ ...(presentation === "legacy" ? [{ field: "constraintName", headerName: "Constraint", minWidth: 220, flex: 1, cellClass: "font-mono text-xs" } satisfies ColDef] : []), { field: "sourceTableName", headerName: "Source table", minWidth: 200, flex: 1 }, { headerName: "Source column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.sourceColumnName).join(", ") ?? "" }, { field: "targetTableName", headerName: "Target table", minWidth: 200, flex: 1 }, { headerName: "Target column", minWidth: 180, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" }, { field: "origin", headerName: "Origin", minWidth: 120, width: 120, valueFormatter: ({ value }) => titleCase(String(value ?? "")) }, { field: "status", headerName: "Status", minWidth: 110, width: 110, valueFormatter: ({ value }) => titleCase(String(value ?? "")) }, ...(presentation === "legacy" ? [ { field: "updateRule", headerName: "On update", minWidth: 125, width: 125 }, { field: "deleteRule", headerName: "On delete", minWidth: 125, width: 125 }, ] satisfies ColDef[] : []), ...(presentation === "fleet" ? [{ colId: "actions", headerName: "Actions", width: 64, minWidth: 64, maxWidth: 64, pinned: "right" as const, lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: RelationshipActionsCell }] : []), ], [presentation]); const context = useMemo(() => ({ presentation, onView: openRelationship }), [data, presentation]); const fleetQueryError = presentation === "fleet" && isError ? (

Catalog relationships could not be loaded.

{apiErrorMessage(error)}

) : null; return (
{presentation === "legacy" ?

Database management

{database.workspaceName}

{database.databaseName} · {database.schema}

{database.activeSyncRun ? : null}
: null}
Relationship map setSearch(event.target.value)} /> {visibleRelationships.length} {presentation === "fleet" ? (
{selectedAction === "relationships" ? "Physical relationships in this database" : "This database"} Action scope
{onOpenDescriptionHistory ? : null} {onOpenSensitiveHistory ? : null} {addUnavailable ? {addUnavailable} : null} {selectedActionUnavailable ? {selectedActionUnavailable} : null}
) : }
{fleetQueryError ??
rowData={visibleRelationships} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate={statusFilter === "active" ? "No relationships yet. Build from column names or add one manually." : statusFilter === "excluded" ? "No excluded relationships." : "No relationships for this database."} />
}
{presentation === "fleet" && addOpen ? ( )} >
{tablesQuery.isError || sourceColumnsQuery.isError || targetColumnsQuery.isError ? (

Catalog tables or columns could not be loaded.

) : null} {addError ?

{addError}

: null}
) : null} {presentation === "fleet" && activeRelationship ? ( { if (event.key === "Escape" && !busy) setPendingMutation(null); }} >

{pendingMutation === "exclude" ? "The relationship will move to Excluded. Builds will not recreate it, and you can restore it later." : "The relationship record will be erased. A future build may infer it again."}

) : ( <> {activeRelationship.origin !== "physical" && activeRelationship.status === "active" ? ( ) : null} {activeRelationship.origin !== "physical" && activeRelationship.status === "excluded" ? ( ) : null} {activeRelationship.origin !== "physical" ? ( ) : null} )} >
Origin
{titleCase(activeRelationship.origin)}
Status
{titleCase(activeRelationship.status)}
{activeRelationship.constraintName ?
Constraint
{activeRelationship.constraintName}
: null}
Source table
{activeRelationship.sourceTableName}
Target table
{activeRelationship.targetTableName}
{activeRelationship.origin === "physical" ? <>
On update
{activeRelationship.updateRule ?? "Not specified"}
On delete
{activeRelationship.deleteRule ?? "Not specified"}
: null}
Column mapping
{activeRelationship.columns.map((pair) => )}
SourceTarget
{pair.sourceColumnName}{pair.targetColumnName}
) : null}
); }