refactor!: simplify query and session ownership
Use Query cancellation and native mutation results instead of HTTP registries, access epochs and copied scope state. Keep local auth ordering and mutation identity fences. Allow staged generated files to remain excluded from ESLint. Backend contract: WealthLedger/backend@d698ed9 BREAKING CHANGE: drop investment stateVersion/progress/resources from the client contract and use data/metadata readiness instead of rewritten query status. Cross-tab auth may require re-login.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
ApiErrorCode,
|
||||
@@ -8,103 +8,49 @@ import {
|
||||
invalidateResourceAccess,
|
||||
type InvestmentResourceMeta,
|
||||
isApiErrorCode,
|
||||
type paths,
|
||||
useAuthenticatedQueries,
|
||||
} from "@/shared/api";
|
||||
|
||||
import { type InvestmentScreen, useTbankSync } from "./sync";
|
||||
type InvestmentScreen
|
||||
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
type ScreenResource = {
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
meta?: InvestmentResourceMeta;
|
||||
observe: (version?: string) => Promise<unknown>;
|
||||
observe: () => 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, InvestmentResourceMeta>,
|
||||
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]
|
||||
: [];
|
||||
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
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 observedRef = useRef("");
|
||||
|
||||
const ids = [
|
||||
...new Set([
|
||||
...(requested?.scope === scopeKey ? [requested.id] : []),
|
||||
...(isCurrentMutation && mutation.data ? [mutation.data.id] : []),
|
||||
...resources
|
||||
.flatMap(({ meta }) =>
|
||||
meta ? [meta, ...(meta.dependencies ?? [])] : [],
|
||||
)
|
||||
.flatMap(meta =>
|
||||
meta.sync.id
|
||||
&& (meta.sync.state === "running" || meta.sync.state === "queued")
|
||||
&& meta.sync.state === "running"
|
||||
? [meta.sync.id]
|
||||
: [],
|
||||
),
|
||||
@@ -140,33 +86,29 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
invalidateResourceAccess(client, "brokerage", status.error);
|
||||
}
|
||||
|
||||
const latest = new Map<string, InvestmentResourceMeta>();
|
||||
if (statuses.every(status => !status.data)) return;
|
||||
|
||||
const orderedStatuses = statuses.toSorted(
|
||||
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
|
||||
const observation = JSON.stringify([
|
||||
scopeKey,
|
||||
statuses.map(status => [
|
||||
status.data?.id,
|
||||
status.data?.state,
|
||||
status.dataUpdatedAt,
|
||||
]),
|
||||
resources.map(resource => resource.queryKey),
|
||||
]);
|
||||
|
||||
if (observedRef.current === observation) return;
|
||||
observedRef.current = observation;
|
||||
|
||||
const isCompleted = statuses.every(
|
||||
status => status.data && status.data.state !== "running",
|
||||
);
|
||||
|
||||
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]);
|
||||
if (isCompleted || !resource.isFetching)
|
||||
void resource.observe().catch(() => {});
|
||||
}, [client, statuses, resources, scopeKey]);
|
||||
|
||||
const isRefreshing
|
||||
= (isCurrentMutation && mutation.isPending)
|
||||
@@ -179,7 +121,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
return (
|
||||
!status?.isError
|
||||
&& (!status?.data || status.data.state === "running")
|
||||
&& (meta?.sync.state === "running" || meta?.sync.state === "queued")
|
||||
&& meta?.sync.state === "running"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -188,12 +130,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
requestInFlightRef.current = scopeKey;
|
||||
|
||||
try {
|
||||
const accepted = await mutation.mutateAsync({ body: scope });
|
||||
if (
|
||||
currentScopeRef.current === scopeKey
|
||||
&& requestInFlightRef.current === scopeKey
|
||||
)
|
||||
setRequested({ id: accepted.id, scope: scopeKey });
|
||||
await mutation.mutateAsync({ body: scope });
|
||||
}
|
||||
catch {
|
||||
/*
|
||||
@@ -212,7 +149,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
// 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",
|
||||
resource => resource.isError,
|
||||
);
|
||||
|
||||
const failedStatuses = statuses.filter(status => status.isError);
|
||||
@@ -238,4 +175,4 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
};
|
||||
}
|
||||
|
||||
export { useScreenSync };
|
||||
export { type InvestmentScreen, useScreenSync };
|
||||
|
||||
Reference in New Issue
Block a user