refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -15,15 +15,21 @@ import { type InvestmentScreen, useTbankSync } from "./sync";
|
||||
|
||||
type ResourceMeta
|
||||
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
||||
|
||||
type ScreenResource = {
|
||||
meta?: ResourceMeta;
|
||||
observe: (version?: string) => Promise<unknown>;
|
||||
queryKey: QueryKey;
|
||||
transport: {
|
||||
fetchStatus: "fetching" | "idle" | "paused";
|
||||
status: "error" | "pending" | "success";
|
||||
fetchStatus: "fetching"
|
||||
| "idle"
|
||||
| "paused";
|
||||
status: "error"
|
||||
| "pending"
|
||||
| "success";
|
||||
};
|
||||
};
|
||||
|
||||
const STATUS_INTERVAL_MS = 1000;
|
||||
|
||||
function observeResource(
|
||||
@@ -38,27 +44,34 @@ function observeResource(
|
||||
if (observed.get(key) === completedId) return;
|
||||
observed.set(key, completedId);
|
||||
void resource.observe().catch(() => {});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = [
|
||||
resource.meta,
|
||||
...(resource.meta.dependencies ?? []),
|
||||
].flatMap((current) => {
|
||||
const notified = latest.get(current.resourceKey);
|
||||
|
||||
return notified && notified.stateVersion !== current.stateVersion
|
||||
? [notified]
|
||||
: [];
|
||||
});
|
||||
|
||||
if (changed.length === 0) return;
|
||||
|
||||
const notification = JSON.stringify(
|
||||
changed.map(meta => [meta.resourceKey, meta.stateVersion]),
|
||||
);
|
||||
if (observed.get(resource.meta.resourceKey) === notification)
|
||||
return;
|
||||
|
||||
if (observed.get(resource.meta.resourceKey) === notification) return;
|
||||
observed.set(resource.meta.resourceKey, notification);
|
||||
|
||||
const own = changed.find(
|
||||
meta => meta.resourceKey === resource.meta?.resourceKey,
|
||||
);
|
||||
|
||||
void resource.observe(own?.stateVersion).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -67,17 +80,24 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
const client = useQueryClient();
|
||||
const deniedRef = useRef(new Set<unknown>());
|
||||
const scopeKey = JSON.stringify(scope);
|
||||
const [requested, setRequested] = useState<null | {
|
||||
id: string;
|
||||
scope: string;
|
||||
}>(null);
|
||||
|
||||
const [requested, setRequested] = useState<null
|
||||
| {
|
||||
id: string;
|
||||
scope: string;
|
||||
}>(null);
|
||||
|
||||
const requestInFlightRef = useRef<null | string>(null);
|
||||
const currentScopeRef = useRef(scopeKey);
|
||||
useLayoutEffect(() => {
|
||||
currentScopeRef.current = scopeKey;
|
||||
}, [scopeKey]);
|
||||
const isCurrentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
|
||||
|
||||
const isCurrentMutation
|
||||
= JSON.stringify(mutation.variables?.body) === scopeKey;
|
||||
|
||||
const observedRef = useRef(new Map<string, string>());
|
||||
|
||||
const ids = [
|
||||
...new Set([
|
||||
...(requested?.scope === scopeKey ? [requested.id] : []),
|
||||
@@ -93,6 +113,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
const statuses = useAuthenticatedQueries(
|
||||
ids.map(id => ({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
|
||||
@@ -101,12 +122,16 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
refetchInterval: (query: {
|
||||
state: { data?: { state: string }; error: unknown };
|
||||
}) =>
|
||||
!(query.state.error || (query.state.data && query.state.data.state !== "running")) && STATUS_INTERVAL_MS,
|
||||
!(
|
||||
query.state.error
|
||||
|| (query.state.data && query.state.data.state !== "running")
|
||||
) && STATUS_INTERVAL_MS,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
for (const status of statuses) {
|
||||
if (!status.error || deniedRef.current.has(status.error)) {
|
||||
@@ -117,10 +142,16 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound))
|
||||
invalidateResourceAccess(client, "brokerage", status.error);
|
||||
}
|
||||
|
||||
const latest = new Map<string, ResourceMeta>();
|
||||
const orderedStatuses = statuses.toSorted((a, b) => a.dataUpdatedAt - b.dataUpdatedAt);
|
||||
|
||||
const orderedStatuses = statuses.toSorted(
|
||||
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
|
||||
);
|
||||
|
||||
for (const status of orderedStatuses) {
|
||||
const statusResources = status.data?.resources ?? [];
|
||||
|
||||
for (const meta of statusResources) {
|
||||
latest.set(meta.resourceKey, meta);
|
||||
const dependencies = meta.dependencies ?? [];
|
||||
@@ -128,10 +159,18 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
latest.set(dependency.resourceKey, dependency);
|
||||
}
|
||||
}
|
||||
const completed = statuses.find(status => requested?.scope === scopeKey
|
||||
&& status.data?.id === requested.id && status.data.state !== "running")?.data;
|
||||
for (const resource of resources) observeResource(resource, latest, completed?.id, observedRef.current);
|
||||
|
||||
const completed = statuses.find(
|
||||
status =>
|
||||
requested?.scope === scopeKey
|
||||
&& status.data?.id === requested.id
|
||||
&& status.data.state !== "running",
|
||||
)?.data;
|
||||
|
||||
for (const resource of resources)
|
||||
observeResource(resource, latest, completed?.id, observedRef.current);
|
||||
}, [client, statuses, resources, requested, scopeKey]);
|
||||
|
||||
const isRefreshing
|
||||
= (isCurrentMutation && mutation.isPending)
|
||||
|| statuses.some(
|
||||
@@ -139,15 +178,18 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
)
|
||||
|| resources.some(({ meta }) => {
|
||||
const status = statuses[ids.indexOf(meta?.sync.id ?? "")];
|
||||
|
||||
return (
|
||||
!status?.isError
|
||||
&& (!status?.data || status.data.state === "running")
|
||||
&& (meta?.sync.state === "running" || meta?.sync.state === "queued")
|
||||
);
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
if (isRefreshing || requestInFlightRef.current === scopeKey) return;
|
||||
requestInFlightRef.current = scopeKey;
|
||||
|
||||
try {
|
||||
const accepted = await mutation.mutateAsync({ body: scope });
|
||||
if (
|
||||
@@ -162,15 +204,20 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
*/
|
||||
}
|
||||
finally {
|
||||
if (requestInFlightRef.current === scopeKey) requestInFlightRef.current = /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
|
||||
if (requestInFlightRef.current === scopeKey)
|
||||
requestInFlightRef.current
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
= null;
|
||||
}
|
||||
};
|
||||
|
||||
// Retry failed HTTP reads without asking the source to import again.
|
||||
// A source failure (or explicit refresh with no read error) still needs a sync.
|
||||
const retry = async () => {
|
||||
const failed = resources.filter(
|
||||
resource => resource.transport.status === "error",
|
||||
);
|
||||
|
||||
const failedStatuses = statuses.filter(status => status.isError);
|
||||
if (failed.length > 0 || failedStatuses.length > 0) {
|
||||
await Promise.allSettled([
|
||||
@@ -180,6 +227,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
}
|
||||
else await refresh();
|
||||
};
|
||||
|
||||
return {
|
||||
refresh,
|
||||
refreshing: isRefreshing,
|
||||
|
||||
Reference in New Issue
Block a user