refactor!: simplify query and session ownership #46
@@ -39,6 +39,10 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
|
||||
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
|
||||
Не добавляй собственный refresh/retry loop в caller.
|
||||
|
||||
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
|
||||
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
|
||||
token stores не добавляются.
|
||||
|
||||
Protected queries используют existing session-aware boundary; logout очищает
|
||||
session-scoped cache. Access token хранится только в памяти, refresh передаётся
|
||||
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
|
||||
@@ -50,6 +54,15 @@ GET subtree; ошибка mutation не меняет generation/cache. Это о
|
||||
от обычного фонового refetch. Даже повторное сохранение того же token начинает
|
||||
новую generation; поздний ответ прежней generation не попадает в новый кэш.
|
||||
|
||||
Отмена/reset принадлежат TanStack Query и generated queryFn с AbortSignal,
|
||||
без HTTP request registry, URL-wide latest-read counters и access epochs.
|
||||
Успешный HTTP с `data: null` остаётся query success; readiness выбирается по
|
||||
data/metadata, без подмены query status и отдельного transport state.
|
||||
Status синхронизации содержит только состояние работы. Resources читаются
|
||||
через `sync=observe` во время работы и после завершения, без hash comparison.
|
||||
Согласованность income entries/groups обеспечивает backend; frontend не
|
||||
дублирует её runtime validator.
|
||||
|
||||
Mutations используют существующий generated client и query-key owner;
|
||||
после успеха обновляй/invalidируй только связанные данные. Optimistic update
|
||||
нужен конкретному UX и обязан иметь rollback/error behavior.
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0"
|
||||
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
|
||||
@@ -6,12 +6,6 @@ const { version } = require("../package.json");
|
||||
const git = (...arguments_) =>
|
||||
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
|
||||
|
||||
assert.equal(
|
||||
git("status", "--porcelain"),
|
||||
"",
|
||||
"Commit all changes before preparing a release",
|
||||
);
|
||||
|
||||
const tag_ = git(
|
||||
"tag",
|
||||
"--merged",
|
||||
|
||||
-2
@@ -210,7 +210,6 @@ export const IndependentFreshness: Story
|
||||
freshness: "stale",
|
||||
lastSuccessfulAt: "2026-09-24T10:30:00Z",
|
||||
resourceKey: "portfolio/storybook-account",
|
||||
stateVersion: "failed",
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: "failed",
|
||||
@@ -223,7 +222,6 @@ export const IndependentFreshness: Story
|
||||
freshness: "unknown",
|
||||
lastSuccessfulAt: null,
|
||||
resourceKey: "portfolio/waiting",
|
||||
stateVersion: "waiting",
|
||||
sync: { errorCode: null, id: "waiting", state: "running" },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -64,8 +64,7 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
|
||||
const { source } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSyncing
|
||||
= source?.sync.state === "running" || source?.sync.state === "queued";
|
||||
const isSyncing = source?.sync.state === "running";
|
||||
|
||||
const errorText
|
||||
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
|
||||
|
||||
@@ -38,14 +38,10 @@ function useAuthProviderVm() {
|
||||
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
|
||||
|
||||
// Cookie-writing requests share ordering, including across tabs with Web Locks.
|
||||
// Order cookie-writing requests within this tab. Other tabs may reauthenticate.
|
||||
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
const next = authQueueRef.current.then(async () =>
|
||||
"locks" in navigator
|
||||
? navigator.locks.request("wealth-ledger-refresh", run)
|
||||
: run(),
|
||||
);
|
||||
const next = authQueueRef.current.then(run);
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
authQueueRef.current = next.catch(() => {});
|
||||
@@ -110,8 +106,7 @@ function useAuthProviderVm() {
|
||||
}
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||
}).finally(() => {
|
||||
if (refreshPromiseRef.current === pending)
|
||||
refreshPromiseRef.current = undefined;
|
||||
refreshPromiseRef.current = undefined;
|
||||
});
|
||||
|
||||
refreshPromiseRef.current = pending;
|
||||
|
||||
@@ -34,7 +34,7 @@ function section(
|
||||
Boolean(query.data)
|
||||
&& (query.isFetching
|
||||
|| query.meta?.sync.state === "running"
|
||||
|| query.meta?.sync.state === "queued"),
|
||||
),
|
||||
retry: () => {
|
||||
void (retry ?? query.refetch)();
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
apiQueryClient,
|
||||
invalidateApiRequests,
|
||||
type paths,
|
||||
} from "@/shared/api";
|
||||
|
||||
@@ -27,7 +26,6 @@ function commitTbankConnectionBoundary(
|
||||
queryClient: QueryClient,
|
||||
status: TbankConnectionStatus,
|
||||
) {
|
||||
invalidateApiRequests(isInvestmentPath);
|
||||
queryClient.setQueryData(tbankConnectionQueryKey, status);
|
||||
void queryClient.resetQueries({
|
||||
predicate: ({ queryKey }) => {
|
||||
|
||||
@@ -21,7 +21,7 @@ function auth() {
|
||||
}
|
||||
|
||||
describe("page-owned screen synchronization", () => {
|
||||
it("ignores a late sync acceptance after the account scope changes", async () => {
|
||||
it("keeps the current scope when an older sync acceptance arrives last", async () => {
|
||||
auth();
|
||||
|
||||
const { promise: blocked, resolve: release }
|
||||
@@ -37,7 +37,7 @@ describe("page-owned screen synchronization", () => {
|
||||
};
|
||||
|
||||
posted.push(scope.accountId);
|
||||
await blocked;
|
||||
if (scope.accountId === "old") await blocked;
|
||||
|
||||
return HttpResponse.json(
|
||||
{ id: scope.accountId, now: "2026-09-25T00:00:00Z", scope },
|
||||
@@ -49,7 +49,6 @@ describe("page-owned screen synchronization", () => {
|
||||
|
||||
return HttpResponse.json({
|
||||
id: params.id,
|
||||
resources: [],
|
||||
state: "succeeded",
|
||||
});
|
||||
}),
|
||||
@@ -69,12 +68,13 @@ describe("page-owned screen synchronization", () => {
|
||||
});
|
||||
await waitFor(() => expect(posted).toEqual(["old"]));
|
||||
rerender({ accountId: "new" });
|
||||
await act(() => result.current.refresh());
|
||||
await waitFor(() => expect(polled).toEqual(["new"]));
|
||||
await act(async () => {
|
||||
release();
|
||||
await pending;
|
||||
});
|
||||
await act(() => result.current.refresh());
|
||||
await waitFor(() => expect(polled).toEqual(["new"]));
|
||||
expect(polled).toEqual(["new"]);
|
||||
expect(posted).toEqual(["old", "new"]);
|
||||
});
|
||||
it.each([false, true])(
|
||||
@@ -91,7 +91,7 @@ describe("page-owned screen synchronization", () => {
|
||||
return isUnavailable
|
||||
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(
|
||||
resource([], { stateVersion: String(modes.length) }),
|
||||
resource([], { dataRevision: String(modes.length) }),
|
||||
);
|
||||
}),
|
||||
http.post("*/api/v1/tbank-sync", () => {
|
||||
@@ -129,7 +129,7 @@ describe("page-owned screen synchronization", () => {
|
||||
isUnavailable = false;
|
||||
await act(() => result.current.sync.retry());
|
||||
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
|
||||
expect(result.current.accounts.meta?.stateVersion).toBe(
|
||||
expect(result.current.accounts.meta?.dataRevision).toBe(
|
||||
cached ? "3" : "2",
|
||||
);
|
||||
expect(modes).toEqual(
|
||||
@@ -169,15 +169,15 @@ describe("page-owned screen synchronization", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", async ({ request }) => {
|
||||
if (boundary === "connection" && isChanged && !request.signal.aborted)
|
||||
return HttpResponse.json(resource([], { stateVersion: "current" }));
|
||||
return HttpResponse.json(resource([], { dataRevision: "current" }));
|
||||
if (!new URL(request.url).searchParams.has("sync"))
|
||||
return isChanged
|
||||
? HttpResponse.json(resource([], { stateVersion: "current" }))
|
||||
? HttpResponse.json(resource([], { dataRevision: "current" }))
|
||||
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
|
||||
isRecovering = true;
|
||||
await blocked;
|
||||
|
||||
return HttpResponse.json(resource([], { stateVersion: "old" }));
|
||||
return HttpResponse.json(resource([], { dataRevision: "old" }));
|
||||
}),
|
||||
);
|
||||
const { queryClient, wrapper } = createQueryWrapper();
|
||||
@@ -247,11 +247,11 @@ describe("page-owned screen synchronization", () => {
|
||||
if (boundary === "connection")
|
||||
await waitFor(() =>
|
||||
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
|
||||
expect(result.current.accounts.meta?.stateVersion).toBe("current"),
|
||||
expect(result.current.accounts.meta?.dataRevision).toBe("current"),
|
||||
);
|
||||
expect(published).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({ stateVersion: "old" }) as unknown,
|
||||
meta: expect.objectContaining({ dataRevision: "old" }) as unknown,
|
||||
}),
|
||||
);
|
||||
unsubscribe();
|
||||
@@ -281,7 +281,6 @@ describe("page-owned screen synchronization", () => {
|
||||
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json({
|
||||
id: "recover",
|
||||
resources: [resource([]).meta],
|
||||
state: "succeeded",
|
||||
});
|
||||
}),
|
||||
@@ -316,7 +315,8 @@ describe("page-owned screen synchronization", () => {
|
||||
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
|
||||
expect(result.current.accounts.data).toEqual([]);
|
||||
|
||||
expect(modes).toEqual([null, "observe"]);
|
||||
expect(modes[0]).toBeNull();
|
||||
expect(modes.slice(1).every(mode => mode === "observe")).toBe(true);
|
||||
expect(
|
||||
queryClient
|
||||
.getQueryCache()
|
||||
@@ -332,7 +332,7 @@ describe("page-owned screen synchronization", () => {
|
||||
let polls = 0;
|
||||
|
||||
const meta = () =>
|
||||
resource([], { stateVersion: isCompleted ? "2" : "1" }).meta;
|
||||
resource([], { dataRevision: isCompleted ? "2" : "1" }).meta;
|
||||
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
@@ -361,8 +361,6 @@ describe("page-owned screen synchronization", () => {
|
||||
errorCode: null,
|
||||
id: "screen",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
progress: { completed: 1, discovering: false, total: 1 },
|
||||
resources: [meta()],
|
||||
scope: { screen: "accounts" },
|
||||
state: "succeeded",
|
||||
},
|
||||
@@ -390,7 +388,7 @@ describe("page-owned screen synchronization", () => {
|
||||
]);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current.accounts.meta?.stateVersion).toBe("2"),
|
||||
expect(result.current.accounts.meta?.dataRevision).toBe("2"),
|
||||
);
|
||||
expect(bodies).toEqual([{ screen: "accounts" }]);
|
||||
|
||||
@@ -427,7 +425,7 @@ describe("page-owned screen synchronization", () => {
|
||||
? []
|
||||
: null,
|
||||
{
|
||||
stateVersion: isCompleted ? "covered" : "initial",
|
||||
dataRevision: isCompleted ? "covered" : "initial",
|
||||
sync: { errorCode: null, id: "history", state: "running" },
|
||||
},
|
||||
);
|
||||
@@ -446,12 +444,6 @@ describe("page-owned screen synchronization", () => {
|
||||
errorCode: null,
|
||||
id: "history",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
progress: {
|
||||
completed: isCompleted ? 1 : 0,
|
||||
discovering: true,
|
||||
total: 2,
|
||||
},
|
||||
resources: [snapshot().meta],
|
||||
scope: null,
|
||||
state: "running",
|
||||
},
|
||||
@@ -487,7 +479,8 @@ describe("page-owned screen synchronization", () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
});
|
||||
|
||||
expect(modes).toEqual([null, "observe"]);
|
||||
expect(modes[0]).toBeNull();
|
||||
expect(modes.slice(1).every(mode => mode === "observe")).toBe(true);
|
||||
if (boundary === "leave") unmount();
|
||||
else act(() => configureAuthClient());
|
||||
const stopped = polls;
|
||||
@@ -545,8 +538,8 @@ describe("page-owned screen synchronization", () => {
|
||||
|
||||
[],
|
||||
{
|
||||
dataRevision: isObserved ? "covered" : "initial",
|
||||
resourceKey: "operations",
|
||||
stateVersion: isObserved ? "covered" : "initial",
|
||||
sync: {
|
||||
errorCode: null,
|
||||
id: "dependency",
|
||||
@@ -570,7 +563,7 @@ describe("page-owned screen synchronization", () => {
|
||||
dependencies: [
|
||||
{
|
||||
...dependency,
|
||||
stateVersion: isObserved ? "covered" : "initial",
|
||||
dataRevision: isObserved ? "covered" : "initial",
|
||||
},
|
||||
],
|
||||
resourceKey: "projection",
|
||||
@@ -585,8 +578,6 @@ describe("page-owned screen synchronization", () => {
|
||||
errorCode: null,
|
||||
id: "dependency",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
progress: { completed: 1, discovering: false, total: 1 },
|
||||
resources: [{ ...dependency, stateVersion: "covered" }],
|
||||
scope: null,
|
||||
state: "succeeded",
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -8,13 +8,14 @@ import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import type { InvestmentScreen } from "./screen-sync";
|
||||
|
||||
import { useScreenSync } from "../index";
|
||||
import { commitTbankConnectionBoundary } from "./connection-boundary";
|
||||
import { type InvestmentScreen, useTbankSync } from "./sync";
|
||||
|
||||
describe("sync connection and session boundaries", () => {
|
||||
it.each(["connection", "session"])(
|
||||
"discards pending status and mutation after %s changes",
|
||||
"keeps old status out of the cache after %s changes",
|
||||
async (boundary) => {
|
||||
let generation = 0;
|
||||
configureAuthClient({
|
||||
@@ -57,8 +58,6 @@ describe("sync connection and session boundaries", () => {
|
||||
...accepted,
|
||||
errorCode: null,
|
||||
now,
|
||||
progress: { completed: 1, discovering: false, total: 1 },
|
||||
resources: [],
|
||||
state: "succeeded",
|
||||
},
|
||||
);
|
||||
@@ -84,7 +83,11 @@ describe("sync connection and session boundaries", () => {
|
||||
const accounts = useBrokerageAccounts();
|
||||
useScreenSync({ screen: "accounts" }, [accounts]);
|
||||
|
||||
return { mutation: useTbankSync() };
|
||||
return {
|
||||
mutation: apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
|
||||
retry: false,
|
||||
}),
|
||||
};
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
@@ -95,10 +98,8 @@ describe("sync connection and session boundaries", () => {
|
||||
mutation = result.current.mutation.mutateAsync({ body: screen });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||
const discarded = expect(mutation).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
// eslint-disable-next-line unicorn/prefer-await -- Handle rejection before changing the boundary and releasing the pending response.
|
||||
const settled = mutation.catch(() => {});
|
||||
|
||||
await waitFor(() => expect(started).toBe(2));
|
||||
await act(async () => {
|
||||
@@ -121,8 +122,7 @@ describe("sync connection and session boundaries", () => {
|
||||
release();
|
||||
await blocked;
|
||||
});
|
||||
await discarded;
|
||||
expect(result.current.mutation.data).toBeUndefined();
|
||||
await settled;
|
||||
|
||||
const key = apiQueryClient.queryOptions(
|
||||
"get",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { apiQueryClient, type paths } from "@/shared/api";
|
||||
|
||||
type InvestmentScreen
|
||||
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
|
||||
|
||||
function useTbankSync() {
|
||||
return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export { type InvestmentScreen, useTbankSync };
|
||||
@@ -44,71 +44,6 @@ function summary(currency: string, coupon: string, dividend: string) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("income membership validation", () => {
|
||||
const entry: IncomeAnalytics["entries"][number] = {
|
||||
accruedInterest: null,
|
||||
category: "income",
|
||||
commission: null,
|
||||
description: "Купон",
|
||||
figi: "FIGI",
|
||||
id: "coupon-1",
|
||||
instrumentKey: "income-group",
|
||||
instrumentType: "bond",
|
||||
instrumentUid: null,
|
||||
logoName: null,
|
||||
occurredAt: "2026-08-01T12:00:00Z",
|
||||
payment: money("RUB", "10"),
|
||||
price: null,
|
||||
quantity: null,
|
||||
ticker: "BOND",
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
};
|
||||
|
||||
const instrument: IncomeAnalytics["instruments"][number] = {
|
||||
...summary("RUB", "10", "0"),
|
||||
displayName: "BOND",
|
||||
figi: "FIGI",
|
||||
instrumentType: "bond",
|
||||
instrumentUid: null,
|
||||
key: entry.instrumentKey,
|
||||
receiptCount: 1,
|
||||
ticker: "BOND",
|
||||
};
|
||||
|
||||
const valid: IncomeAnalytics = {
|
||||
...analytics,
|
||||
entries: [entry],
|
||||
instruments: [instrument],
|
||||
totalsByCurrency: [summary("RUB", "10", "0")],
|
||||
};
|
||||
|
||||
it.each([
|
||||
["missing key", undefined],
|
||||
["empty key", ""],
|
||||
["whitespace key", " "],
|
||||
["non-string key", 12],
|
||||
["unknown key", "not-in-breakdown"],
|
||||
])("rejects an entry with %s", (_name, instrumentKey) => {
|
||||
// Deliberately malformed API data exercises the runtime validation boundary.
|
||||
const response = {
|
||||
...valid,
|
||||
entries: [{ ...entry, instrumentKey }],
|
||||
} as unknown as IncomeAnalytics;
|
||||
|
||||
expect(toIncomeDisplayModel(response)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["duplicate instrument keys", { instruments: [instrument, instrument] }],
|
||||
["currency mismatch", { entries: [{ ...entry, payment: money("USD", "10") }] }],
|
||||
["receipt count mismatch", { instruments: [{ ...instrument, receiptCount: 2 }] }],
|
||||
["missing entries", { entries: [] }],
|
||||
["empty instrument key", { instruments: [{ ...instrument, key: "" }] }],
|
||||
])("rejects %s", (_name, invalid) => {
|
||||
expect(toIncomeDisplayModel({ ...valid, ...invalid })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("income comparison display", () => {
|
||||
it.each([
|
||||
["25", "100", "+25 %"],
|
||||
|
||||
@@ -97,49 +97,14 @@ function formatIncomeChange(
|
||||
function toIncomeDisplayModel(
|
||||
analytics: IncomeAnalytics,
|
||||
meta?: IncomeAnalyticsMeta,
|
||||
): IncomeDisplayModel | null {
|
||||
const instruments = new Map<
|
||||
string,
|
||||
{
|
||||
currency: string;
|
||||
includedCount: number;
|
||||
receiptCount: number;
|
||||
}
|
||||
>();
|
||||
|
||||
const currencies = new Set(
|
||||
analytics.totalsByCurrency.map(({ currency }) => currency),
|
||||
);
|
||||
|
||||
for (const instrument of analytics.instruments) {
|
||||
if (
|
||||
typeof instrument.key !== "string"
|
||||
|| !instrument.key.trim()
|
||||
|| instruments.has(instrument.key)
|
||||
|| !currencies.has(instrument.total.currency)
|
||||
)
|
||||
|
||||
return null;
|
||||
instruments.set(instrument.key, {
|
||||
currency: instrument.total.currency,
|
||||
includedCount: 0,
|
||||
receiptCount: instrument.receiptCount,
|
||||
});
|
||||
}
|
||||
|
||||
): IncomeDisplayModel {
|
||||
const entries: IncomeDisplayModel["entries"] = [];
|
||||
const occurrences = new Map<string, number>();
|
||||
|
||||
for (const entry of analytics.entries) {
|
||||
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
|
||||
return null;
|
||||
const instrument = instruments.get(entry.instrumentKey);
|
||||
if (!instrument || entry.payment?.currency !== instrument.currency)
|
||||
return null;
|
||||
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
|
||||
const occurrence = occurrences.get(occurrenceKey) ?? 0;
|
||||
occurrences.set(occurrenceKey, occurrence + 1);
|
||||
instrument.includedCount += 1;
|
||||
entries.push({
|
||||
key: JSON.stringify([
|
||||
entry.instrumentKey,
|
||||
@@ -151,11 +116,6 @@ function toIncomeDisplayModel(
|
||||
});
|
||||
}
|
||||
|
||||
for (const instrument of instruments.values()) {
|
||||
if (instrument.includedCount !== instrument.receiptCount)
|
||||
return null;
|
||||
}
|
||||
|
||||
const dependencies = meta?.dependencies ?? [];
|
||||
const previous = dependencies.find(({ role }) => role === "previous");
|
||||
const year = dependencies.find(({ role }) => role === "year");
|
||||
|
||||
@@ -324,32 +324,6 @@ describe("AccountIncomePage", () => {
|
||||
expect(within(panel).getAllByRole("listitem")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects inconsistent analytics and recovers through retry", async () => {
|
||||
let response = {
|
||||
...analytics,
|
||||
entries: [
|
||||
{ ...analytics.entries[0], instrumentKey: "not-in-breakdown" },
|
||||
analytics.entries[1],
|
||||
],
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(resource(response)),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Выплаты" }),
|
||||
).not.toBeInTheDocument();
|
||||
response = analytics;
|
||||
fireEvent.click(retry);
|
||||
expect(
|
||||
await screen.findByRole("region", { name: "Выплаты" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts an empty analytics response", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
@@ -709,15 +683,13 @@ describe("income period and snapshot boundary", () => {
|
||||
errorCode: null,
|
||||
id: "income",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
progress: { completed: 1, discovering: false, total: 1 },
|
||||
resources: [resource(null, { stateVersion: "2" }).meta],
|
||||
scope: null,
|
||||
state: "succeeded",
|
||||
},
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
|
||||
HttpResponse.json(resource(analytics, { dataRevision: "2" })),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
|
||||
@@ -89,12 +89,6 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
}
|
||||
|
||||
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
||||
if (!display)
|
||||
return {
|
||||
period,
|
||||
retry: () => analytics.observe(),
|
||||
state: "error",
|
||||
};
|
||||
|
||||
const onSubmit = (values: IncomeForm) => {
|
||||
const from = parseDraftDate(values.from);
|
||||
|
||||
@@ -786,11 +786,6 @@ describe("AccountPage states", () => {
|
||||
errorCode: null,
|
||||
id: "overview",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
progress: { completed: 1, discovering: false, total: 1 },
|
||||
resources: [
|
||||
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
|
||||
.meta,
|
||||
],
|
||||
scope: { accountId: "account-1", screen: "account" },
|
||||
state: "succeeded",
|
||||
},
|
||||
@@ -871,7 +866,7 @@ describe("AccountPage states", () => {
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||||
},
|
||||
{ resourceKey: "portfolio", stateVersion: "2" },
|
||||
{ dataRevision: "2", resourceKey: "portfolio" },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -231,7 +231,7 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
observed.push(path);
|
||||
|
||||
return HttpResponse.json(
|
||||
resource(data, { resourceKey: path, stateVersion: version }),
|
||||
resource(data, { dataRevision: version, resourceKey: path }),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -256,11 +256,6 @@ it("refreshes every overview section through one account descriptor and observat
|
||||
errorCode: null,
|
||||
id: "overview",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
progress: { completed: 4, discovering: false, total: 4 },
|
||||
resources: Object.keys(sections).map(
|
||||
resourceKey =>
|
||||
resource(null, { resourceKey, stateVersion: version }).meta,
|
||||
),
|
||||
scope: null,
|
||||
state: "succeeded",
|
||||
},
|
||||
|
||||
@@ -23,14 +23,6 @@ type AuthHandlers = {
|
||||
refresh: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const inFlight = new Set<{ invalidated: boolean; pathname: string }>();
|
||||
const latestReads = new Map<string, { pending: number; sequence: number }>();
|
||||
|
||||
function invalidateApiRequests(isMatches: (pathname: string) => boolean) {
|
||||
for (const request of inFlight)
|
||||
if (isMatches(request.pathname)) request.invalidated = true;
|
||||
}
|
||||
|
||||
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
|
||||
hasSession: false,
|
||||
};
|
||||
@@ -38,48 +30,6 @@ const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
|
||||
const authSessionListeners = new Set<() => void>();
|
||||
|
||||
async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("sync");
|
||||
url.searchParams.sort();
|
||||
const key = `${request.method} ${url}`;
|
||||
const ticket = { invalidated: false, pathname: url.pathname };
|
||||
|
||||
const read
|
||||
= request.method === "GET"
|
||||
? (latestReads.get(key) ?? { pending: 0, sequence: 0 })
|
||||
: undefined;
|
||||
|
||||
if (read) {
|
||||
read.sequence += 1;
|
||||
read.pending += 1;
|
||||
latestReads.set(key, read);
|
||||
}
|
||||
|
||||
const sequence = read?.sequence;
|
||||
inFlight.add(ticket);
|
||||
|
||||
try {
|
||||
return await fetchCurrent(
|
||||
request,
|
||||
() => !ticket.invalidated && (!read || sequence === read.sequence),
|
||||
);
|
||||
}
|
||||
finally {
|
||||
inFlight.delete(ticket);
|
||||
if (read && --read.pending === 0) latestReads.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function configureAuthClient(handlers?: AuthHandlers) {
|
||||
if (!handlers) invalidateApiRequests(() => true);
|
||||
sessionState.handlers = handlers;
|
||||
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
|
||||
}
|
||||
|
||||
async function fetchCurrent(
|
||||
request: Request,
|
||||
isLatest: () => boolean,
|
||||
): Promise<Response> {
|
||||
const isAuthRequest = new URL(request.url).pathname.startsWith(
|
||||
"/api/v1/auth/",
|
||||
);
|
||||
@@ -91,7 +41,7 @@ async function fetchCurrent(
|
||||
(session?.generation
|
||||
? generation === session.generation()
|
||||
&& generation === sessionState.handlers?.generation?.()
|
||||
: session === sessionState.handlers) && isLatest();
|
||||
: session === sessionState.handlers);
|
||||
|
||||
const token = session?.accessToken();
|
||||
|
||||
@@ -107,7 +57,6 @@ async function fetchCurrent(
|
||||
|
||||
const retryRequest = authorized.clone();
|
||||
const response = await fetch(authorized);
|
||||
await response.clone().arrayBuffer();
|
||||
if (!isAuthRequest && !isCurrent())
|
||||
throw new DOMException("Session changed", "AbortError");
|
||||
if (
|
||||
@@ -139,12 +88,16 @@ async function fetchCurrent(
|
||||
const refreshedToken = sessionState.handlers.accessToken();
|
||||
if (!refreshedToken) return response;
|
||||
const retried = await fetch(withToken(retryRequest, refreshedToken));
|
||||
await retried.clone().arrayBuffer();
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
|
||||
return retried;
|
||||
}
|
||||
|
||||
function configureAuthClient(handlers?: AuthHandlers) {
|
||||
sessionState.handlers = handlers;
|
||||
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
|
||||
}
|
||||
|
||||
function hasAuthenticatedSession() {
|
||||
return sessionState.hasSession;
|
||||
}
|
||||
@@ -174,6 +127,5 @@ export {
|
||||
apiQueryClient,
|
||||
configureAuthClient,
|
||||
hasAuthenticatedSession as getAuthenticatedSessionSnapshot,
|
||||
invalidateApiRequests,
|
||||
subscribeAuthenticatedSession,
|
||||
};
|
||||
|
||||
Vendored
+1
-12
@@ -915,12 +915,11 @@ export interface components {
|
||||
InvestmentSyncDto: {
|
||||
id: string | null;
|
||||
/** @enum {string} */
|
||||
state: "idle" | "queued" | "running" | "failed";
|
||||
state: "idle" | "running" | "failed";
|
||||
errorCode: string | null;
|
||||
};
|
||||
InvestmentDependencyDto: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
unavailableReason?: string;
|
||||
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||
resourceKey: string;
|
||||
@@ -934,7 +933,6 @@ export interface components {
|
||||
};
|
||||
InvestmentResourceMetaDto: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
unavailableReason?: string;
|
||||
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||
resourceKey: string;
|
||||
@@ -1126,7 +1124,6 @@ export interface components {
|
||||
};
|
||||
IncomeDependencyDto: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
unavailableReason?: string;
|
||||
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||
resourceKey: string;
|
||||
@@ -1142,7 +1139,6 @@ export interface components {
|
||||
};
|
||||
IncomeAnalyticsMetaDto: {
|
||||
dataRevision: string;
|
||||
stateVersion: string;
|
||||
unavailableReason?: string;
|
||||
coverage?: components["schemas"]["InvestmentCoverageDto"];
|
||||
resourceKey: string;
|
||||
@@ -1622,11 +1618,6 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
now: string;
|
||||
};
|
||||
TbankSyncProgressDto: {
|
||||
completed: number;
|
||||
total: number;
|
||||
discovering: boolean;
|
||||
};
|
||||
TbankSyncStatusDto: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -1636,8 +1627,6 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
state: "running" | "succeeded" | "failed" | "cancelled";
|
||||
errorCode: string | null;
|
||||
progress: components["schemas"]["TbankSyncProgressDto"];
|
||||
resources: components["schemas"]["InvestmentResourceMetaDto"][];
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
|
||||
@@ -6,7 +6,6 @@ export {
|
||||
apiClient,
|
||||
apiQueryClient,
|
||||
configureAuthClient,
|
||||
invalidateApiRequests,
|
||||
} from "./client";
|
||||
export type { paths } from "./generated/schema";
|
||||
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
@@ -37,7 +37,7 @@ function auth() {
|
||||
}
|
||||
|
||||
describe("persistent resource query lifecycle", () => {
|
||||
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
|
||||
it("observes once into the canonical cache without another import", async () => {
|
||||
auth();
|
||||
const modes: (null | string)[] = [];
|
||||
let observations = 0;
|
||||
@@ -51,7 +51,7 @@ describe("persistent resource query lifecycle", () => {
|
||||
|
||||
return HttpResponse.json(
|
||||
resource(snapshot, {
|
||||
stateVersion: observations > 1 ? "new" : "old",
|
||||
dataRevision: observations > 0 ? "new" : "old",
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -60,16 +60,16 @@ describe("persistent resource query lifecycle", () => {
|
||||
const { queryClient, wrapper } = createQueryWrapper();
|
||||
const { result } = renderHook(() => usePrivateResource("a"), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
await act(() => result.current.observe("new"));
|
||||
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
|
||||
await act(() => result.current.observe());
|
||||
await waitFor(() => expect(result.current.meta?.dataRevision).toBe("new"));
|
||||
|
||||
expect(modes).toEqual([null, "observe", "observe"]);
|
||||
expect(modes).toEqual([null, "observe"]);
|
||||
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
|
||||
expect(
|
||||
queryClient.getQueryData(resourceOptions("a").queryKey),
|
||||
).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } });
|
||||
).toMatchObject({ data: snapshot, meta: { dataRevision: "new" } });
|
||||
});
|
||||
it("does not convert missing or failed source data to empty financial success", async () => {
|
||||
it("keeps successful HTTP state distinct from missing or failed source data", async () => {
|
||||
auth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
@@ -91,24 +91,17 @@ describe("persistent resource query lifecycle", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(result.current.isSuccess).toBe(false);
|
||||
expect(result.current.status).toBe("pending");
|
||||
expect(result.current.isPending).toBe(true);
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.status).toBe("success");
|
||||
expect(result.current.isPending).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
|
||||
expect(result.current.transport).toEqual(
|
||||
{
|
||||
error: null,
|
||||
fetchStatus: "idle",
|
||||
status: "success",
|
||||
},
|
||||
);
|
||||
expect(result.current.meta?.availability).toBe("missing");
|
||||
});
|
||||
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
|
||||
auth();
|
||||
let isReady = false;
|
||||
const refreshed = resource(snapshot, { stateVersion: "ready" });
|
||||
const refreshed = resource(snapshot, { dataRevision: "ready" });
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(isReady ? refreshed : resource(null)),
|
||||
@@ -120,7 +113,7 @@ describe("persistent resource query lifecycle", () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.transport.status).toBe("success"),
|
||||
expect(result.current.status).toBe("success"),
|
||||
);
|
||||
await act(async () => {
|
||||
const missing = await result.current.refetch();
|
||||
@@ -140,35 +133,40 @@ describe("persistent resource query lifecycle", () => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
|
||||
it("keeps the observed snapshot when an older query finishes later", async () => {
|
||||
auth();
|
||||
|
||||
const { promise: blocked, resolve: release }
|
||||
= Promise.withResolvers<void>();
|
||||
|
||||
let isStarted = false;
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||
async ({ request }) => {
|
||||
if (!new URL(request.url).searchParams.has("sync")) await blocked;
|
||||
if (!new URL(request.url).searchParams.has("sync")) {
|
||||
isStarted = true;
|
||||
await blocked;
|
||||
|
||||
return HttpResponse.json(resource({ ...snapshot, isStale: true }));
|
||||
}
|
||||
|
||||
return HttpResponse.json(resource(snapshot));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const old = apiClient.GET(
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{ params: { path: { accountId: "a" } } },
|
||||
);
|
||||
|
||||
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
|
||||
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
|
||||
params: { path: { accountId: "a" }, query: { sync: "observe" } },
|
||||
const { result } = renderHook(() => usePrivateResource("a"), {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
release();
|
||||
await rejected;
|
||||
|
||||
await waitFor(() => expect(isStarted).toBe(true));
|
||||
await act(() => result.current.observe());
|
||||
await act(async () => {
|
||||
release();
|
||||
await blocked;
|
||||
});
|
||||
expect(result.current.data).toEqual(snapshot);
|
||||
});
|
||||
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
|
||||
auth();
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type QueryFunctionContext,
|
||||
useQueryClient,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -13,8 +12,6 @@ import { useAuthenticatedQuery } from "./authenticated-query";
|
||||
import { getAuthenticatedSessionSnapshot } from "./client";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||
|
||||
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
|
||||
|
||||
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
|
||||
|
||||
type Resource = {
|
||||
@@ -50,9 +47,6 @@ function invalidateResourceAccess(
|
||||
].every(code => !isApiErrorCode(errorCode, code))
|
||||
)
|
||||
return;
|
||||
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
|
||||
accessEpochs.set(client, epochs);
|
||||
epochs.set(group, (epochs.get(group) ?? 0) + 1);
|
||||
|
||||
const affected = {
|
||||
predicate: (cached: { meta?: Record<string, unknown> }) =>
|
||||
@@ -81,33 +75,6 @@ function observationKey<K extends ResourceQueryKey>(key: K): K {
|
||||
}] as unknown as K;
|
||||
}
|
||||
|
||||
function unwrapResourceResult<T extends Resource, E>(
|
||||
result: UseQueryResult<T, E>,
|
||||
) {
|
||||
const isMissing = result.isSuccess && result.data.data === null;
|
||||
const data: T["data"] | undefined = result.data?.data;
|
||||
const meta: T["meta"] | undefined = result.data?.meta;
|
||||
|
||||
const status: UseQueryResult<T, E>["status"] = isMissing
|
||||
? "pending"
|
||||
: result.status;
|
||||
|
||||
// Project resource fields; refetch still returns the transport envelope.
|
||||
return {
|
||||
...result,
|
||||
data: data ?? undefined,
|
||||
isPending: result.isPending || isMissing,
|
||||
isSuccess: result.isSuccess && !isMissing,
|
||||
meta,
|
||||
status,
|
||||
transport: {
|
||||
error: result.error,
|
||||
fetchStatus: result.fetchStatus,
|
||||
status: result.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Observation changes transport mode, never the generated cache identity.
|
||||
function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
|
||||
options: UseQueryOptions<T, E, T, K>,
|
||||
@@ -119,35 +86,13 @@ function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
|
||||
? options.meta.accessGroup
|
||||
: undefined;
|
||||
|
||||
let epochs = accessEpochs.get(client);
|
||||
|
||||
if (!epochs) {
|
||||
epochs = new Map();
|
||||
accessEpochs.set(client, epochs);
|
||||
}
|
||||
|
||||
const groupEpochs = epochs;
|
||||
const queryFunction = options.queryFn;
|
||||
|
||||
const guarded
|
||||
= typeof queryFunction === "function"
|
||||
? async (context: QueryFunctionContext<K>) => {
|
||||
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
|
||||
const result = await queryFunction(context);
|
||||
if (group && epoch !== (groupEpochs.get(group) ?? 0))
|
||||
throw new DOMException("Access changed", "AbortError");
|
||||
|
||||
return result;
|
||||
}
|
||||
: queryFunction;
|
||||
|
||||
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
|
||||
const query = useAuthenticatedQuery(options);
|
||||
useEffect(() => {
|
||||
if (group) invalidateResourceAccess(client, group, query.error);
|
||||
}, [client, group, query.error]);
|
||||
|
||||
const observe = async (expectedStateVersion?: string) => {
|
||||
const queryFunction = guarded;
|
||||
const observe = async () => {
|
||||
const queryFunction = options.queryFn;
|
||||
if (
|
||||
typeof queryFunction !== "function"
|
||||
|| !query.isEnabled
|
||||
@@ -162,33 +107,34 @@ function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
|
||||
queryFn: async (context: QueryFunctionContext<K>) => {
|
||||
const queryKey = observationKey(context.queryKey);
|
||||
|
||||
const response = await queryFunction({ ...context, queryKey });
|
||||
|
||||
if (
|
||||
expectedStateVersion
|
||||
&& response.meta.stateVersion !== expectedStateVersion
|
||||
) {
|
||||
// One more observational read; never turn a stale notification into an import.
|
||||
const current = await queryFunction({ ...context, queryKey });
|
||||
if (current.meta.stateVersion !== expectedStateVersion)
|
||||
throw new DOMException("Resource changed", "AbortError");
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
return response;
|
||||
return queryFunction({ ...context, queryKey });
|
||||
},
|
||||
retry: false,
|
||||
// Observation validates raw resource metadata, before any observer projection.
|
||||
// Keep the cache envelope independent of observer projection.
|
||||
select: undefined,
|
||||
staleTime: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const data: T["data"] | undefined = query.data?.data;
|
||||
const meta: T["meta"] | undefined = query.data?.meta;
|
||||
|
||||
// Project resource fields; refetch still returns the transport envelope.
|
||||
return {
|
||||
...unwrapResourceResult(query),
|
||||
data: data ?? undefined,
|
||||
error: query.error,
|
||||
fetchStatus: query.fetchStatus,
|
||||
isEnabled: query.isEnabled,
|
||||
isError: query.isError,
|
||||
isFetching: query.isFetching,
|
||||
isLoading: query.isLoading,
|
||||
isPending: query.isPending,
|
||||
isSuccess: query.isSuccess,
|
||||
meta,
|
||||
observe,
|
||||
queryKey: options.queryKey,
|
||||
refetch: query.refetch,
|
||||
status: query.status,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5947,7 +5947,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"idle",
|
||||
"queued",
|
||||
"running",
|
||||
"failed"
|
||||
]
|
||||
@@ -5969,9 +5968,6 @@
|
||||
"dataRevision": {
|
||||
"type": "string"
|
||||
},
|
||||
"stateVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6008,7 +6004,6 @@
|
||||
},
|
||||
"required": [
|
||||
"dataRevision",
|
||||
"stateVersion",
|
||||
"resourceKey",
|
||||
"availability",
|
||||
"freshness",
|
||||
@@ -6022,9 +6017,6 @@
|
||||
"dataRevision": {
|
||||
"type": "string"
|
||||
},
|
||||
"stateVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6067,7 +6059,6 @@
|
||||
},
|
||||
"required": [
|
||||
"dataRevision",
|
||||
"stateVersion",
|
||||
"resourceKey",
|
||||
"availability",
|
||||
"freshness",
|
||||
@@ -6912,9 +6903,6 @@
|
||||
"dataRevision": {
|
||||
"type": "string"
|
||||
},
|
||||
"stateVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6959,7 +6947,6 @@
|
||||
},
|
||||
"required": [
|
||||
"dataRevision",
|
||||
"stateVersion",
|
||||
"resourceKey",
|
||||
"availability",
|
||||
"freshness",
|
||||
@@ -6974,9 +6961,6 @@
|
||||
"dataRevision": {
|
||||
"type": "string"
|
||||
},
|
||||
"stateVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableReason": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7020,7 +7004,6 @@
|
||||
},
|
||||
"required": [
|
||||
"dataRevision",
|
||||
"stateVersion",
|
||||
"resourceKey",
|
||||
"availability",
|
||||
"freshness",
|
||||
@@ -9017,25 +9000,6 @@
|
||||
"now"
|
||||
]
|
||||
},
|
||||
"TbankSyncProgressDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed": {
|
||||
"type": "number"
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
},
|
||||
"discovering": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"completed",
|
||||
"total",
|
||||
"discovering"
|
||||
]
|
||||
},
|
||||
"TbankSyncStatusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9087,15 +9051,6 @@
|
||||
"errorCode": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"progress": {
|
||||
"$ref": "#/components/schemas/TbankSyncProgressDto"
|
||||
},
|
||||
"resources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InvestmentResourceMetaDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -9103,9 +9058,7 @@
|
||||
"scope",
|
||||
"now",
|
||||
"state",
|
||||
"errorCode",
|
||||
"progress",
|
||||
"resources"
|
||||
"errorCode"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ function resource<T>(data: T, meta: Partial<Meta> = {}) {
|
||||
freshness: "fresh",
|
||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||
resourceKey: "fixture",
|
||||
stateVersion: "1",
|
||||
sync: { errorCode: null, id: null, state: "idle" },
|
||||
...meta,
|
||||
} satisfies Meta,
|
||||
|
||||
Reference in New Issue
Block a user