Frontend verify / verify (pull_request) Canceled after 0s
Frontend security / security (pull_request) Canceled after 0s
Frontend security / security (push) Canceled after 0s
Frontend verify / verify (push) Canceled after 0s
Conventional commits / commits (pull_request) Canceled after 0s
Use owner enums for known codes; preserve wire values and unknown fallbacks. Verified npm ci and verify: 379 tests, 5 lint checks, app and Storybook builds.
197 lines
6.4 KiB
TypeScript
197 lines
6.4 KiB
TypeScript
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
|
|
import {
|
|
ApiErrorCode,
|
|
apiErrorCode,
|
|
apiQueryClient,
|
|
invalidateResourceAccess,
|
|
isApiErrorCode,
|
|
type paths,
|
|
useAuthenticatedQueries,
|
|
} from "@/shared/api";
|
|
|
|
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";
|
|
};
|
|
};
|
|
const STATUS_INTERVAL_MS = 1000;
|
|
|
|
function observeResource(
|
|
resource: ScreenResource,
|
|
latest: Map<string, ResourceMeta>,
|
|
completedId: string | undefined,
|
|
observed: Map<string, string>,
|
|
) {
|
|
if (!resource.meta) {
|
|
if (!completedId || resource.transport.fetchStatus !== "idle") return;
|
|
const key = `query:${JSON.stringify(resource.queryKey)}`;
|
|
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;
|
|
observed.set(resource.meta.resourceKey, notification);
|
|
const own = changed.find(
|
|
meta => meta.resourceKey === resource.meta?.resourceKey,
|
|
);
|
|
void resource.observe(own?.stateVersion).catch(() => {});
|
|
}
|
|
|
|
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
|
const mutation = useTbankSync();
|
|
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 requestInFlightRef = useRef<null | string>(null);
|
|
const currentScopeRef = useRef(scopeKey);
|
|
useLayoutEffect(() => {
|
|
currentScopeRef.current = scopeKey;
|
|
}, [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] : []),
|
|
...resources
|
|
.flatMap(({ meta }) =>
|
|
meta ? [meta, ...(meta.dependencies ?? [])] : [],
|
|
)
|
|
.flatMap(meta =>
|
|
meta?.sync.id
|
|
&& (meta.sync.state === "running" || meta.sync.state === "queued")
|
|
? [meta.sync.id]
|
|
: [],
|
|
),
|
|
]),
|
|
];
|
|
const statuses = useAuthenticatedQueries(
|
|
ids.map(id => ({
|
|
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
|
|
params: { path: { id } },
|
|
}),
|
|
refetchInterval: (query: {
|
|
state: { data?: { state: string }; error: unknown };
|
|
}) =>
|
|
!(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)) {
|
|
continue;
|
|
}
|
|
|
|
deniedRef.current.add(status.error);
|
|
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);
|
|
for (const status of orderedStatuses) {
|
|
const statusResources = status.data?.resources ?? [];
|
|
for (const meta of statusResources) {
|
|
latest.set(meta.resourceKey, meta);
|
|
const dependencies = meta.dependencies ?? [];
|
|
for (const dependency of dependencies)
|
|
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);
|
|
}, [client, statuses, resources, requested, scopeKey]);
|
|
const isRefreshing
|
|
= (isCurrentMutation && mutation.isPending)
|
|
|| statuses.some(
|
|
status => !status.isError && status.data?.state === "running",
|
|
)
|
|
|| 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 (
|
|
currentScopeRef.current === scopeKey
|
|
&& requestInFlightRef.current === scopeKey
|
|
)
|
|
setRequested({ id: accepted.id, scope: scopeKey });
|
|
}
|
|
catch {
|
|
/*
|
|
The mutation retains the transport error for presentation.
|
|
*/
|
|
}
|
|
finally {
|
|
if (requestInFlightRef.current === scopeKey) requestInFlightRef.current = /* eslint-disable-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([
|
|
...failed.map(resource => resource.observe()),
|
|
...failedStatuses.map(status => status.refetch()),
|
|
]);
|
|
}
|
|
else await refresh();
|
|
};
|
|
return {
|
|
refresh,
|
|
refreshing: isRefreshing,
|
|
retry,
|
|
sourceError:
|
|
statuses.some(status => status.data?.state === "failed")
|
|
|| resources.some(({ meta }) => meta?.sync.state === "failed"),
|
|
transportError:
|
|
(isCurrentMutation && mutation.isError)
|
|
|| statuses.some(status => status.isError),
|
|
};
|
|
}
|
|
|
|
export { useScreenSync };
|