fix(datasources): preserve governed CSV originals and fresh detail state
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
Release v0.1.26. Coordinated integrity review: GovOPlaN/govoplan-core#298.
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ import { FormGrid, DialogSection, ActionToolbar,
|
||||
WorkspaceFrame,
|
||||
WorkspaceLayout,
|
||||
hasScope,
|
||||
authAuthorityKey,
|
||||
isApiError,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
@@ -96,10 +98,10 @@ export default function DatasourcesPage({
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const [view, setView] = useState<CatalogueView>("catalogue");
|
||||
const [datasources, setDatasources] = useState<Datasource[]>([]);
|
||||
const [stages, setStages] = useState<DatasourceStage[]>([]);
|
||||
const [origins, setOrigins] = useState<DatasourceOrigin[]>([]);
|
||||
const [originsAvailable, setOriginsAvailable] = useState(false);
|
||||
const [loadedDatasources, setDatasources] = useState<Datasource[]>([]);
|
||||
const [loadedStages, setStages] = useState<DatasourceStage[]>([]);
|
||||
const [loadedOrigins, setOrigins] = useState<DatasourceOrigin[]>([]);
|
||||
const [loadedOriginsAvailable, setOriginsAvailable] = useState(false);
|
||||
const [selectedDatasourceRef, setSelectedDatasourceRef] = useState(
|
||||
initialDatasourceRef
|
||||
);
|
||||
@@ -110,6 +112,28 @@ export default function DatasourcesPage({
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailRevision, setDetailRevision] = useState(0);
|
||||
const authorityKey = authAuthorityKey(auth, settings);
|
||||
const authority = useRef(authorityKey);
|
||||
authority.current = authorityKey;
|
||||
const authorityEpoch = useRef({ key: authorityKey, revision: 0 });
|
||||
if (authorityEpoch.current.key !== authorityKey) {
|
||||
authorityEpoch.current = { key: authorityKey, revision: authorityEpoch.current.revision + 1 };
|
||||
}
|
||||
const dialogScope = authorityEpoch.current.revision;
|
||||
const isCurrentAuthority = () => authority.current === authorityKey
|
||||
&& authorityEpoch.current.revision === dialogScope;
|
||||
const [catalogueAuthority, setCatalogueAuthority] = useState<number | null>(null);
|
||||
const catalogueReady = catalogueAuthority === dialogScope;
|
||||
const datasources = catalogueReady ? loadedDatasources : [];
|
||||
const stages = catalogueReady ? loadedStages : [];
|
||||
const origins = catalogueReady ? loadedOrigins : [];
|
||||
const originsAvailable = catalogueReady && loadedOriginsAvailable;
|
||||
const detailScope = useMemo(() => ({}), [selectedDatasourceRef, dialogScope, detailRevision]);
|
||||
const currentDetailScope = useRef(detailScope);
|
||||
currentDetailScope.current = detailScope;
|
||||
const [detailResponseScope, setDetailResponseScope] = useState<object | null>(null);
|
||||
const reloadRequestId = useRef(0);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
@@ -132,6 +156,12 @@ export default function DatasourcesPage({
|
||||
const canAdmin = hasScope(auth, "datasources:source:admin");
|
||||
|
||||
const reload = useCallback(async (preferredDatasourceRef?: string) => {
|
||||
// An old mutation callback can invoke this closure AFTER authority changed.
|
||||
// Compare the closure's scope before any request, state write or generation bump.
|
||||
if (!isCurrentAuthority()) return;
|
||||
const requestId = ++reloadRequestId.current;
|
||||
const isCurrent = () => requestId === reloadRequestId.current
|
||||
&& isCurrentAuthority();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -140,10 +170,15 @@ export default function DatasourcesPage({
|
||||
listDatasourceStages(settings),
|
||||
listDatasourceOrigins(settings)
|
||||
]);
|
||||
if (!isCurrent()) return;
|
||||
setCatalogueAuthority(dialogScope);
|
||||
setDatasources(nextDatasources);
|
||||
setStages(nextStages);
|
||||
setOrigins(originCatalogue.origins);
|
||||
setOriginsAvailable(originCatalogue.available);
|
||||
// Reload also invalidates history/preview when the selected reference did
|
||||
// not change (refresh, freeze, promotion, governance and manual reload).
|
||||
setDetailRevision((current) => current + 1);
|
||||
setSelectedDatasourceRef((current) => {
|
||||
const preferred = preferredDatasourceRef || current;
|
||||
return nextDatasources.some((item) => item.ref === preferred)
|
||||
@@ -157,20 +192,38 @@ export default function DatasourcesPage({
|
||||
? current
|
||||
: originCatalogue.origins[0]?.ref ?? "");
|
||||
} catch (loadError) {
|
||||
setError(apiErrorMessage(loadError));
|
||||
if (isCurrent()) setError(apiErrorMessage(loadError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [settings]);
|
||||
}, [dialogScope]);
|
||||
|
||||
useEffect(() => {
|
||||
authority.current = authorityKey;
|
||||
setWorking(false);
|
||||
setSuccess("");
|
||||
// Confirmations and their input must not follow a selection into a new
|
||||
// authority context. Equivalent session refreshes keep this effect stable.
|
||||
setAddOpen(false);
|
||||
setFreezeOpen(false);
|
||||
setFreezeLabel("");
|
||||
setRetireOpen(false);
|
||||
setPromoteOpen(false);
|
||||
setGovernanceOpen(false);
|
||||
setDecisionOpen(false);
|
||||
setRetentionOpen(false);
|
||||
void reload();
|
||||
return () => {
|
||||
reloadRequestId.current += 1;
|
||||
if (authority.current === authorityKey) authority.current = "";
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDatasourceRef) {
|
||||
if (!selectedDatasourceRef || loading || !catalogueReady) {
|
||||
setPreview(null);
|
||||
setMaterializations([]);
|
||||
setDetailLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -179,7 +232,8 @@ export default function DatasourcesPage({
|
||||
previewDatasource(settings, selectedDatasourceRef),
|
||||
listDatasourceMaterializations(settings, selectedDatasourceRef)
|
||||
]).then(([previewResult, materializationResult]) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || currentDetailScope.current !== detailScope) return;
|
||||
setDetailResponseScope(detailScope);
|
||||
if (previewResult.status === "fulfilled") {
|
||||
setPreview(previewResult.value);
|
||||
} else {
|
||||
@@ -196,7 +250,7 @@ export default function DatasourcesPage({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedDatasourceRef, settings]);
|
||||
}, [selectedDatasourceRef, detailScope, loading, catalogueReady]);
|
||||
|
||||
const selectedDatasource = datasources.find((item) => item.ref === selectedDatasourceRef) ?? null;
|
||||
const selectedStage = stages.find((item) => item.ref === selectedStageRef) ?? null;
|
||||
@@ -219,46 +273,50 @@ export default function DatasourcesPage({
|
||||
);
|
||||
|
||||
const promoteStage = async () => {
|
||||
if (!selectedStage || selectedStage.state !== "ready") return;
|
||||
if (!isCurrentAuthority() || !selectedStage || selectedStage.state !== "ready") return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await promoteDatasourceStage(settings, selectedStage.ref);
|
||||
if (!isCurrentAuthority()) return;
|
||||
setSuccess(`Promoted ${result.datasource.name} as revision ${result.materialization.revision}.`);
|
||||
setView("catalogue");
|
||||
await reload(result.datasource.ref);
|
||||
} catch (operationError) {
|
||||
setError(apiErrorMessage(operationError));
|
||||
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
if (isCurrentAuthority()) setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSelected = async () => {
|
||||
if (!selectedDatasource) return;
|
||||
if (!isCurrentAuthority() || !selectedDatasource) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
if (selectedDatasource.governance.approval_policy.required === true) {
|
||||
const stage = await prepareDatasourceRefresh(settings, selectedDatasource.ref);
|
||||
if (!isCurrentAuthority()) return;
|
||||
await reload(selectedDatasource.ref);
|
||||
if (!isCurrentAuthority()) return;
|
||||
setSelectedStageRef(stage.ref);
|
||||
setView("staging");
|
||||
setSuccess(`Prepared refresh stage ${stage.name}. It must be approved before promotion.`);
|
||||
return;
|
||||
}
|
||||
const result = await refreshDatasource(settings, selectedDatasource.ref);
|
||||
if (!isCurrentAuthority()) return;
|
||||
setSuccess(`Refreshed ${result.datasource.name} as revision ${result.materialization.revision}.`);
|
||||
await reload(result.datasource.ref);
|
||||
} catch (operationError) {
|
||||
setError(apiErrorMessage(operationError));
|
||||
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
if (isCurrentAuthority()) setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const freezeSelected = async (): Promise<boolean> => {
|
||||
if (!selectedDatasource) return false;
|
||||
if (!isCurrentAuthority() || !selectedDatasource) return false;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -267,32 +325,34 @@ export default function DatasourcesPage({
|
||||
selectedDatasource.ref,
|
||||
freezeLabel
|
||||
);
|
||||
if (!isCurrentAuthority()) return false;
|
||||
setSuccess(`Created frozen revision ${materialization.revision}.`);
|
||||
setFreezeOpen(false);
|
||||
setFreezeLabel("");
|
||||
await reload(selectedDatasource.ref);
|
||||
return true;
|
||||
return isCurrentAuthority();
|
||||
} catch (operationError) {
|
||||
setError(apiErrorMessage(operationError));
|
||||
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
|
||||
return false;
|
||||
} finally {
|
||||
setWorking(false);
|
||||
if (isCurrentAuthority()) setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const retireSelected = async () => {
|
||||
if (!selectedDatasource) return;
|
||||
if (!isCurrentAuthority() || !selectedDatasource) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
await retireDatasource(settings, selectedDatasource.ref);
|
||||
if (!isCurrentAuthority()) return;
|
||||
setSuccess(`Retired ${selectedDatasource.name}.`);
|
||||
setRetireOpen(false);
|
||||
await reload();
|
||||
} catch (operationError) {
|
||||
setError(apiErrorMessage(operationError));
|
||||
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
if (isCurrentAuthority()) setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -539,9 +599,9 @@ export default function DatasourcesPage({
|
||||
selectedDatasource ? (
|
||||
<DatasourceDetail
|
||||
datasource={selectedDatasource}
|
||||
preview={preview}
|
||||
materializations={materializations}
|
||||
loading={detailLoading}
|
||||
preview={!loading && detailResponseScope === detailScope ? preview : null}
|
||||
materializations={!loading && detailResponseScope === detailScope ? materializations : []}
|
||||
loading={loading || detailLoading || detailResponseScope !== detailScope}
|
||||
/>
|
||||
) : <EmptyWorkspace icon={<DatabaseZap size={32} />} label="No datasource selected" />
|
||||
) : null}
|
||||
@@ -562,7 +622,8 @@ export default function DatasourcesPage({
|
||||
</WorkspaceLayout>
|
||||
|
||||
<AddDatasourceDialog
|
||||
open={addOpen}
|
||||
key={`add:${dialogScope}`}
|
||||
open={addOpen && catalogueReady}
|
||||
settings={settings}
|
||||
initialKind={view === "origins" ? "origin" : "upload"}
|
||||
initialOrigin={view === "origins" ? selectedOrigin : null}
|
||||
@@ -573,6 +634,7 @@ export default function DatasourcesPage({
|
||||
canManage={canManage}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onCreated={async (result) => {
|
||||
if (!isCurrentAuthority()) return;
|
||||
setAddOpen(false);
|
||||
if ("state" in result) {
|
||||
setView("staging");
|
||||
@@ -587,40 +649,47 @@ export default function DatasourcesPage({
|
||||
}}
|
||||
/>
|
||||
<GovernanceDialog
|
||||
open={governanceOpen}
|
||||
key={`governance:${dialogScope}`}
|
||||
open={governanceOpen && catalogueReady}
|
||||
settings={settings}
|
||||
datasource={selectedDatasource}
|
||||
onClose={() => setGovernanceOpen(false)}
|
||||
onSaved={async (updated) => {
|
||||
if (!isCurrentAuthority()) return;
|
||||
setGovernanceOpen(false);
|
||||
setSuccess(`Updated governance for ${updated.name}.`);
|
||||
await reload(updated.ref);
|
||||
}}
|
||||
/>
|
||||
<StageDecisionDialog
|
||||
open={decisionOpen}
|
||||
key={`decision:${dialogScope}`}
|
||||
open={decisionOpen && catalogueReady}
|
||||
settings={settings}
|
||||
stage={selectedStage}
|
||||
onClose={() => setDecisionOpen(false)}
|
||||
onDecided={async (updated) => {
|
||||
if (!isCurrentAuthority()) return;
|
||||
setDecisionOpen(false);
|
||||
await reload();
|
||||
if (!isCurrentAuthority()) return;
|
||||
setSelectedStageRef(updated.ref);
|
||||
setSuccess(`Recorded ${updated.approval.state ?? "approval"} decision state for ${updated.name}.`);
|
||||
}}
|
||||
/>
|
||||
<RetentionDialog
|
||||
open={retentionOpen}
|
||||
key={`retention:${dialogScope}`}
|
||||
open={retentionOpen && catalogueReady}
|
||||
settings={settings}
|
||||
onClose={() => setRetentionOpen(false)}
|
||||
onApplied={async (count) => {
|
||||
if (!isCurrentAuthority()) return;
|
||||
setRetentionOpen(false);
|
||||
setSuccess(`Applied retention to ${count} eligible target${count === 1 ? "" : "s"}.`);
|
||||
await reload(selectedDatasourceRef);
|
||||
}}
|
||||
/>
|
||||
<Dialog
|
||||
open={freezeOpen}
|
||||
open={freezeOpen && catalogueReady}
|
||||
title="Freeze datasource state"
|
||||
onClose={closeFreeze}
|
||||
footer={(
|
||||
@@ -644,7 +713,7 @@ export default function DatasourcesPage({
|
||||
</FormField>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={promoteOpen}
|
||||
open={promoteOpen && catalogueReady}
|
||||
title="i18n:govoplan-datasources.promote_title"
|
||||
message="i18n:govoplan-datasources.promote_message"
|
||||
confirmLabel="Promote"
|
||||
@@ -656,7 +725,7 @@ export default function DatasourcesPage({
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={retireOpen}
|
||||
open={retireOpen && catalogueReady}
|
||||
title="Retire datasource"
|
||||
message={`Retire ${selectedDatasource?.name ?? "this datasource"}? Existing materialization references remain in the database, but the datasource will no longer be available to new definitions.`}
|
||||
confirmLabel="Retire"
|
||||
@@ -686,7 +755,7 @@ function DatasourceDetail({
|
||||
<MetricCard density="compact" label="Mode" value={datasource.mode} valueTitle={datasource.mode} />
|
||||
<MetricCard density="compact" label="Rows" value={formatNumber(datasource.row_count)} />
|
||||
<MetricCard density="compact" label="Fields" value={String(datasource.schema.length)} />
|
||||
<MetricCard density="compact" label="Revisions" value={String(materializations.length)} />
|
||||
<MetricCard density="compact" label="Revisions" value={loading ? "…" : String(materializations.length)} />
|
||||
<MetricCard density="compact" label="Updated" value={formatDate(datasource.updated_at)} />
|
||||
</MetricGrid>
|
||||
{datasource.description ? (
|
||||
@@ -764,7 +833,9 @@ function DatasourceDetail({
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!materializations.length ? (
|
||||
{loading ? (
|
||||
<tr><td colSpan={5}>Loading materializations...</td></tr>
|
||||
) : !materializations.length ? (
|
||||
<tr><td colSpan={5}>Live source without materializations</td></tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
@@ -1428,6 +1499,7 @@ function AddDatasourceDialog({
|
||||
const [rowsText, setRowsText] = useState('[\n { "id": 1 }\n]');
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [delimiter, setDelimiter] = useState(";");
|
||||
const [csvValueMode, setCsvValueMode] = useState<"text" | "legacy_typed">("text");
|
||||
const [baselineKey, setBaselineKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -1446,6 +1518,7 @@ function AddDatasourceDialog({
|
||||
setRowsText('[\n { "id": 1 }\n]');
|
||||
setCsvText("");
|
||||
setDelimiter(";");
|
||||
setCsvValueMode("text");
|
||||
setBaselineKey(addDatasourceDraftKey({
|
||||
kind: initialKind,
|
||||
format: "csv",
|
||||
@@ -1457,7 +1530,8 @@ function AddDatasourceDialog({
|
||||
description: initialOrigin?.description ?? "",
|
||||
rowsText: '[\n { "id": 1 }\n]',
|
||||
csvText: "",
|
||||
delimiter: ";"
|
||||
delimiter: ";",
|
||||
csvValueMode: "text"
|
||||
}));
|
||||
setError("");
|
||||
}, [initialKind, initialOrigin, open]);
|
||||
@@ -1474,7 +1548,8 @@ function AddDatasourceDialog({
|
||||
description,
|
||||
rowsText,
|
||||
csvText,
|
||||
delimiter
|
||||
delimiter,
|
||||
csvValueMode
|
||||
}) !== baselineKey);
|
||||
|
||||
const chooseOrigin = (ref: string) => {
|
||||
@@ -1526,7 +1601,8 @@ function AddDatasourceDialog({
|
||||
...common,
|
||||
format: "csv",
|
||||
csv_text: csvText,
|
||||
delimiter
|
||||
delimiter,
|
||||
csv_value_mode: csvValueMode
|
||||
})
|
||||
: await createDatasourceStage(settings, {
|
||||
...common,
|
||||
@@ -1720,6 +1796,16 @@ function AddDatasourceDialog({
|
||||
<option value="|">Pipe</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="CSV values" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
|
||||
<select
|
||||
value={csvValueMode}
|
||||
onChange={(event) => setCsvValueMode(event.target.value as "text" | "legacy_typed")}
|
||||
disabled={busy}
|
||||
>
|
||||
<option value="text">Preserve text (no automatic conversion)</option>
|
||||
<option value="legacy_typed">Infer types (legacy)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="CSV data">
|
||||
<textarea
|
||||
value={csvText}
|
||||
@@ -1838,6 +1924,7 @@ function addDatasourceDraftKey(value: {
|
||||
rowsText: string;
|
||||
csvText: string;
|
||||
delimiter: string;
|
||||
csvValueMode: "text" | "legacy_typed";
|
||||
}): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user