refactor!: simplify query and session ownership #46

Merged
ksv741 merged 7 commits from codex/simplify-complexity into develop 2026-10-07 10:29:49 +03:00
25 changed files with 151 additions and 551 deletions
Showing only changes of commit 69997075e5 - Show all commits
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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",
@@ -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;
+3 -8
View File
@@ -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 %"],
+1 -41
View File
@@ -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",
},
+6 -54
View File
@@ -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,
};
+1 -12
View File
@@ -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;
-1
View File
@@ -6,7 +6,6 @@ export {
apiClient,
apiQueryClient,
configureAuthClient,
invalidateApiRequests,
} from "./client";
export type { paths } from "./generated/schema";
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
+31 -33
View File
@@ -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();
+21 -75
View File
@@ -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,
};
}
+1 -48
View File
@@ -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"
]
}
}
-1
View File
@@ -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,