From 69997075e569da3edd43e242ef36f09311262412 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 3 Oct 2026 11:45:04 +0300 Subject: [PATCH] refactor!: simplify query and session ownership Use Query cancellation and native mutation results instead of HTTP registries, access epochs and copied scope state. Keep local auth ordering and mutation identity fences. Allow staged generated files to remain excluded from ESLint. Backend contract: WealthLedger/backend@d698ed9 BREAKING CHANGE: drop investment stateVersion/progress/resources from the client contract and use data/metadata readiness instead of rewritten query status. Cross-tab auth may require re-login. --- .ai/conventions/data.md | 13 ++ package.json | 2 +- scripts/check-release.cjs | 6 - .../BrokerageAccountList.stories.tsx | 2 - .../BrokerageAccountList.tsx | 3 +- src/features/auth/model/auth.vm.ts | 11 +- .../instrument-details/model/section.ts | 2 +- .../model/connection-boundary.ts | 2 - .../model/screen-sync.test.tsx | 51 +++---- .../tbank-connection/model/screen-sync.ts | 135 +++++------------- .../tbank-connection/model/sync.test.tsx | 22 +-- src/features/tbank-connection/model/sync.ts | 12 -- .../account-income/model/display.test.ts | 65 --------- src/pages/account-income/model/display.ts | 42 +----- .../AccountIncomePage.test.tsx | 30 +--- .../AccountIncomePage/AccountIncomePage.vm.ts | 6 - .../ui/AccountPage/AccountPage.test.tsx | 7 +- .../ui/AccountPage/AccountPage.vm.test.tsx | 7 +- src/shared/api/client.ts | 60 +------- src/shared/api/generated/schema.d.ts | 13 +- src/shared/api/index.ts | 1 - src/shared/api/resource-query.test.tsx | 64 ++++----- src/shared/api/resource-query.ts | 96 +++---------- src/shared/api/spec/openapi.json | 49 +------ src/test/resource.ts | 1 - 25 files changed, 151 insertions(+), 551 deletions(-) delete mode 100644 src/features/tbank-connection/model/sync.ts diff --git a/.ai/conventions/data.md b/.ai/conventions/data.md index a6ed4d1..8c07414 100644 --- a/.ai/conventions/data.md +++ b/.ai/conventions/data.md @@ -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. diff --git a/package.json b/package.json index dcf0843..8c0daa1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-release.cjs b/scripts/check-release.cjs index f8814a0..88ffd1c 100644 --- a/scripts/check-release.cjs +++ b/scripts/check-release.cjs @@ -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", diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx index e37c134..3e7dc7e 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx @@ -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" }, }, ], diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index 8d1e01b..a706ea1 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -64,8 +64,7 @@ const BrokerageAccountSourceStatus: FC = ( 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; diff --git a/src/features/auth/model/auth.vm.ts b/src/features/auth/model/auth.vm.ts index 191d160..4079923 100644 --- a/src/features/auth/model/auth.vm.ts +++ b/src/features/auth/model/auth.vm.ts @@ -38,14 +38,10 @@ function useAuthProviderVm() { const authQueueRef = useRef>(Promise.resolve()); const refreshPromiseRef = useRef | 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((run: () => Promise): Promise => { // 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; diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index d0d5d17..399d903 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -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)(); }, diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index 445e65c..22b86e8 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -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 }) => { diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index 037298a..dc39898 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -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", }, diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 5bfd3a7..00db54a 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -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; + observe: () => Promise; queryKey: QueryKey; - transport: { - fetchStatus: "fetching" - | "idle" - | "paused"; - status: "error" - | "pending" - | "success"; - }; }; const STATUS_INTERVAL_MS = 1000; -function observeResource( - resource: ScreenResource, - latest: Map, - completedId: string | undefined, - observed: Map, -) { - 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()); const scopeKey = JSON.stringify(scope); - const [requested, setRequested] = useState(null); - const requestInFlightRef = useRef(null); - const currentScopeRef = useRef(scopeKey); - useLayoutEffect(() => { - currentScopeRef.current = scopeKey; - }, [scopeKey]); const isCurrentMutation = JSON.stringify(mutation.variables?.body) === scopeKey; - const observedRef = useRef(new Map()); + 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(); + 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 }; diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx index b284931..61ad44a 100644 --- a/src/features/tbank-connection/model/sync.test.tsx +++ b/src/features/tbank-connection/model/sync.test.tsx @@ -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", diff --git a/src/features/tbank-connection/model/sync.ts b/src/features/tbank-connection/model/sync.ts deleted file mode 100644 index fadb494..0000000 --- a/src/features/tbank-connection/model/sync.ts +++ /dev/null @@ -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 }; diff --git a/src/pages/account-income/model/display.test.ts b/src/pages/account-income/model/display.test.ts index 60b7248..01ce9e7 100644 --- a/src/pages/account-income/model/display.test.ts +++ b/src/pages/account-income/model/display.test.ts @@ -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 %"], diff --git a/src/pages/account-income/model/display.ts b/src/pages/account-income/model/display.ts index 22b8d80..59a2679 100644 --- a/src/pages/account-income/model/display.ts +++ b/src/pages/account-income/model/display.ts @@ -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(); 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"); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index b52dbb8..106aef7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -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: "Повторить" })); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 3f22620..0d52a74 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -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); diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 1a8a691..94afba2 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -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" }, ), ), ); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index 091d099..e0bea05 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -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", }, diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 3d50171..ea8d312 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -23,14 +23,6 @@ type AuthHandlers = { refresh: () => Promise; }; -const inFlight = new Set<{ invalidated: boolean; pathname: string }>(); -const latestReads = new Map(); - -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 { - 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 { 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, }; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 13d1c34..17f40f5 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 49b5425..efdc54a 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -6,7 +6,6 @@ export { apiClient, apiQueryClient, configureAuthClient, - invalidateApiRequests, } from "./client"; export type { paths } from "./generated/schema"; export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; diff --git a/src/shared/api/resource-query.test.tsx b/src/shared/api/resource-query.test.tsx index 9ccbc2a..66a461d 100644 --- a/src/shared/api/resource-query.test.tsx +++ b/src/shared/api/resource-query.test.tsx @@ -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(); + 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(); diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 6a84724..0b663ef 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -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>(); - 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(); - accessEpochs.set(client, epochs); - epochs.set(group, (epochs.get(group) ?? 0) + 1); const affected = { predicate: (cached: { meta?: Record }) => @@ -81,33 +75,6 @@ function observationKey(key: K): K { }] as unknown as K; } -function unwrapResourceResult( - result: UseQueryResult, -) { - 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["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( options: UseQueryOptions, @@ -119,35 +86,13 @@ function useResourceQuery( ? 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) => { - 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( queryFn: async (context: QueryFunctionContext) => { 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, }; } diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 370fa4e..21e7694 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -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" ] } } diff --git a/src/test/resource.ts b/src/test/resource.ts index f6d7204..c78ce20 100644 --- a/src/test/resource.ts +++ b/src/test/resource.ts @@ -12,7 +12,6 @@ function resource(data: T, meta: Partial = {}) { freshness: "fresh", lastSuccessfulAt: "2026-08-14T00:00:00Z", resourceKey: "fixture", - stateVersion: "1", sync: { errorCode: null, id: null, state: "idle" }, ...meta, } satisfies Meta,