import { type QueryClient, type QueryFunctionContext, useQueryClient, type UseQueryOptions, } from "@tanstack/react-query"; import { useEffect } from "react"; import type { components, paths } from "./generated/schema"; import { useAuthenticatedQuery } from "./authenticated-query"; import { getAuthenticatedSessionSnapshot } from "./client"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; type Resource = { [key: string]: unknown; data: unknown; meta: InvestmentResourceMeta; sync?: unknown; }; type ResourcePath = { [P in keyof paths]: paths[P]["get"] extends { // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- HTTP success response in the generated contract. responses: { 200: { content: { "application/json": Resource } } }; } ? P : never; }[keyof paths]; type ResourceQueryKey = readonly [ "get", ResourcePath, { params?: { path?: object; query?: { sync?: "observe" } } }?, ]; function invalidateResourceAccess( client: QueryClient, group: string, error: unknown, ) { const errorCode = apiErrorCode(error); if ( [ ApiErrorCode.Forbidden, ApiErrorCode.InvalidTbankToken, ApiErrorCode.NotFound, ApiErrorCode.TbankNotConnected, ].every(code => !isApiErrorCode(errorCode, code)) ) return; const affected = { predicate: (cached: { meta?: Record }) => cached.meta?.accessGroup === group, }; void client.cancelQueries(affected); for (const cached of client.getQueryCache().findAll(affected)) cached.setState({ data: undefined, dataUpdatedAt: 0, error: error as Error, fetchStatus: "idle", status: "error", }); } // Keep endpoint and parameters intact. The generated queryFn accepts the // optional sync mode even for a cache key originally created without init. function observationKey(key: K): K { const [method, path, init] = key as unknown as [ string, string, { params?: { path?: object; query?: object } }?, ]; return [method, path, { ...init, params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } }, }] as unknown as K; } // Observation changes transport mode, never the generated cache identity. function useResourceQuery< T extends Resource, E = unknown, K extends readonly unknown[] = ResourceQueryKey, >( options: UseQueryOptions, ) { const client = useQueryClient(); const group = typeof options.meta?.accessGroup === "string" ? options.meta.accessGroup : undefined; const query = useAuthenticatedQuery(options); useEffect(() => { if (group) invalidateResourceAccess(client, group, query.error); }, [client, group, query.error]); const observe = async () => { const queryFunction = options.queryFn; if ( typeof queryFunction !== "function" || !query.isEnabled || !getAuthenticatedSessionSnapshot() ) return; // Cancellation is synchronous; start the replacement in the same boundary. void client.cancelQueries({ exact: true, queryKey: options.queryKey }); return client.query({ ...options, queryFn: async (context: QueryFunctionContext) => { const queryKey = observationKey(context.queryKey); return queryFunction({ ...context, queryKey }); }, retry: false, // 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 { 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, sync: query.data && "sync" in query.data ? query.data.sync : undefined, }; } export { type InvestmentResourceMeta, useResourceQuery, };