refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -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,