fix(datasources): preserve governed CSV originals and fresh detail state
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:
2026-09-08 12:19:38 +02:00
parent 8b8c6c548e
commit 9d067f1bad
20 changed files with 825 additions and 91 deletions
@@ -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);
}