Consume the provided resource-envelope API snapshot. Preserve financial snapshots during refresh, observe synchronization without importing, and fence late responses across account and connection changes.
149 lines
4.6 KiB
TypeScript
149 lines
4.6 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
apiErrorCode,
|
|
apiQueryClient,
|
|
invalidateResourceAccess,
|
|
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>;
|
|
};
|
|
const STATUS_INTERVAL_MS = 1000;
|
|
|
|
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
|
const mutation = useTbankSync();
|
|
const client = useQueryClient();
|
|
const denied = useRef(new Set<unknown>());
|
|
const scopeKey = JSON.stringify(scope);
|
|
const [requested, setRequested] = useState<{
|
|
scope: string;
|
|
id: string;
|
|
} | null>(null);
|
|
const requestInFlight = useRef<string | null>(null);
|
|
const currentScope = useRef(scopeKey);
|
|
currentScope.current = scopeKey;
|
|
const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
|
|
const observed = 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 } },
|
|
}),
|
|
retry: false,
|
|
refetchOnMount: false,
|
|
refetchOnWindowFocus: false,
|
|
refetchInterval: (query: {
|
|
state: { error: unknown; data?: { state: string } };
|
|
}) =>
|
|
query.state.error ||
|
|
(query.state.data && query.state.data.state !== "running")
|
|
? false
|
|
: STATUS_INTERVAL_MS,
|
|
})),
|
|
);
|
|
useEffect(() => {
|
|
for (const status of statuses) {
|
|
if (status.error && !denied.current.has(status.error)) {
|
|
denied.current.add(status.error);
|
|
if (apiErrorCode(status.error) !== "NOT_FOUND")
|
|
invalidateResourceAccess(client, "brokerage", status.error);
|
|
}
|
|
}
|
|
const latest = new Map<string, ResourceMeta>();
|
|
for (const status of [...statuses].sort(
|
|
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
|
|
))
|
|
for (const meta of status.data?.resources ?? []) {
|
|
latest.set(meta.resourceKey, meta);
|
|
for (const dependency of meta.dependencies ?? [])
|
|
latest.set(dependency.resourceKey, dependency);
|
|
}
|
|
for (const resource of resources) {
|
|
if (!resource.meta) continue;
|
|
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) continue;
|
|
const notification = JSON.stringify(
|
|
changed.map((meta) => [meta.resourceKey, meta.stateVersion]),
|
|
);
|
|
if (observed.current.get(resource.meta.resourceKey) === notification)
|
|
continue;
|
|
observed.current.set(resource.meta.resourceKey, notification);
|
|
const own = changed.find(
|
|
(meta) => meta.resourceKey === resource.meta?.resourceKey,
|
|
);
|
|
void resource.observe(own?.stateVersion).catch(() => undefined);
|
|
}
|
|
}, [client, statuses, resources]);
|
|
const refreshing =
|
|
(currentMutation && 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 (requestInFlight.current === scopeKey || refreshing) return;
|
|
requestInFlight.current = scopeKey;
|
|
try {
|
|
const accepted = await mutation.mutateAsync({ body: scope });
|
|
if (
|
|
currentScope.current === scopeKey &&
|
|
requestInFlight.current === scopeKey
|
|
)
|
|
setRequested({ scope: scopeKey, id: accepted.id });
|
|
} catch {
|
|
/* The mutation retains the transport error for presentation. */
|
|
} finally {
|
|
if (requestInFlight.current === scopeKey) requestInFlight.current = null;
|
|
}
|
|
};
|
|
return {
|
|
refresh,
|
|
refreshing,
|
|
transportError:
|
|
(currentMutation && mutation.isError) ||
|
|
statuses.some((status) => status.isError),
|
|
sourceError:
|
|
statuses.some((status) => status.data?.state === "failed") ||
|
|
resources.some(({ meta }) => meta?.sync.state === "failed"),
|
|
};
|
|
}
|
|
|
|
export { useScreenSync };
|