diff --git a/apps/web/src/query/composables/use-scoped-infinite-query.ts b/apps/web/src/query/composables/use-scoped-infinite-query.ts new file mode 100644 index 0000000000..a9d076521a --- /dev/null +++ b/apps/web/src/query/composables/use-scoped-infinite-query.ts @@ -0,0 +1,138 @@ +/** + * useScopedInfiniteQuery - A custom wrapper around `useInfiniteQuery` to enforce scope-based API fetching. + * + * ## Why this hook exists? + * This hook was created to integrate **scope-based API access control** with Vue Query. + * It ensures that queries are only executed when the user's granted scope matches the required scope. + * Additionally, it automatically handles loading states and prevents unnecessary queries. + * + * ## Functionality + * - Extends `useInfiniteQuery` with **grant scope validation**. + * - Runs queries only when **the user's scope is valid** and the app is **ready**. + * - Uses Vue's **reactivity** to dynamically compute the `enabled` state. + * - Supports both **static and reactive `enabled` values**. + * + * ## Parameters: + * - `options`: Standard **Vue Query options** (`UseInfiniteQueryOptions`). + * - `requiredScopes`: A list of **required grant scopes** to determine if the query should execute. + * + * ## Example: + * const query = useScopedInfiniteQuery( + * { + * queryKey: ['dashboard', dashboardId], + * queryFn: () => fetchDashboardData(dashboardId), + * initialPageParam: 1, + * getNextPageParam: (lastPage, allPages) => {} + * enabled: computed(() => isUserAuthorized.value), + * }, + * ['DOMAIN', 'WORKSPACE'] + * ); + */ + +import type { MaybeRef } from '@vueuse/core'; +import { toValue } from '@vueuse/core'; +import { computed, type ComputedRef } from 'vue'; + +import type { + InfiniteData, + QueryKey, +} from '@tanstack/vue-query'; +import { + useInfiniteQuery, type UseInfiniteQueryOptions, type UseInfiniteQueryReturnType, +} from '@tanstack/vue-query'; + +import type { GrantScope } from '@/api-clients/identity/token/schema/type'; +import type { QueryKeyArray } from '@/query/query-key/_types/query-key-type'; + +import { useAppContextStore } from '@/store/app-context/app-context-store'; +import { useAuthorizationStore } from '@/store/authorization/authorization-store'; + + +type ScopedEnabled = MaybeRef; + + +export const useScopedInfiniteQuery = , TQueryKey extends QueryKey = QueryKey, TPageParam = unknown>( + options: UseInfiniteQueryOptions, + requiredScopes: [GrantScope, ...GrantScope[]], +): UseInfiniteQueryReturnType => { + // [Dev Warning] This query is missing `requiredScopes`. + // All scoped queries must explicitly define at least one valid scope for clarity and safety. + if (import.meta.env.DEV && (!requiredScopes || requiredScopes.length === 0)) { + _warnOncePerTick(() => { + console.warn('[useScopedInfiniteQuery] `requiredScopes` is missing or empty.', { + queryKey: _extractQueryKey((options as any).queryKey), + suggestion: 'Pass at least one valid scope like [\'DOMAIN\'], [\'WORKSPACE\'], etc.', + }); + return true; + }); + } + + const appContextStore = useAppContextStore(); + const authorizationStore = useAuthorizationStore(); + + const currentGrantScope = computed( + () => authorizationStore.state.currentGrantInfo?.scope, + ); + const isAppReady = computed(() => !appContextStore.getters.globalGrantLoading); + + const isValidScope = computed(() => currentGrantScope.value !== undefined + && requiredScopes.includes(currentGrantScope.value)); + + const rawEnabled = (options as { enabled?: ScopedEnabled }).enabled; + const queryEnabled = computed(() => { + const inheritedEnabled = rawEnabled !== undefined ? toValue(rawEnabled) : true; + return inheritedEnabled && isValidScope.value && isAppReady.value; + }); + + // [Dev Warning] The current user's scope is not included in the allowed `requiredScopes`. + // This usually indicates a configuration mistake in the query declaration. + if (import.meta.env.DEV) { + const currentScope = currentGrantScope.value; + if (isAppReady.value && currentScope && toValue(rawEnabled) && !requiredScopes.includes(currentScope)) { + _warnOncePerTick(() => { + console.warn('[useScopedInfiniteQuery] Invalid requiredScopes for current scope:', { + queryKey: _extractQueryKey((options as any).queryKey), + requiredScopes, + currentScope, + }); + return true; + }); + } + } + + return useInfiniteQuery({ + ...options, + enabled: queryEnabled, + }); +}; + +const _extractQueryKey = (input: unknown): QueryKeyArray => toValue(input as ComputedRef); + + + +/* Warning Logger Utilities */ +const _warnedKeys = new Set(); +const _getCallerKey = (): string => { + try { + const err = new Error(); + const stack = err.stack?.split('\n') || []; + + const caller = stack.find((line, i) => i > 1 + && (line.includes('.ts') || line.includes('.vue')) + && !line.includes('use-scoped-infinite-query')); + + return caller?.trim() ?? 'UNKNOWN_CALLSITE'; + } catch { + return 'UNKNOWN_CALLSITE'; + } +}; +const _warnOncePerTick = (log: () => boolean) => { + const key = _getCallerKey(); + if (_warnedKeys.has(key)) return; + const didLog = log(); + + if (didLog) { + _warnedKeys.add(key); + queueMicrotask(() => _warnedKeys.delete(key)); + } +}; diff --git a/apps/web/src/query/pagination/pagination-query-helper.ts b/apps/web/src/query/pagination/pagination-query-helper.ts new file mode 100644 index 0000000000..ab16f0ad50 --- /dev/null +++ b/apps/web/src/query/pagination/pagination-query-helper.ts @@ -0,0 +1,65 @@ +import type { Query } from '@cloudforet/core-lib/space-connector/type'; + +import type { Page } from '@/api-clients/_common/schema/type'; + +type LoadParams = Record & { + page?: Page; +}; + + +export const omitPageFromLoadParams = (params: T): Omit => { + const copiedParams = { ...params }; + delete copiedParams.page; + return copiedParams; +}; + +type QueryParams = Record & { + query?: Query; +}; + +export const omitPageQueryParams = (params: QueryParams) => { + const copiedQuery = params.query ? { ...params.query } : undefined; + if (copiedQuery) delete copiedQuery.page; + + return { + ...params, + query: copiedQuery, + }; +}; + +const _addPageToLoadParams = ( + params: LoadParams, + page: Page, +): LoadParams => ({ + ...params, + page, +}); + +const _addPageToListParams = ( + params: QueryParams, + page: Page, +): QueryParams => ({ + ...params, + query: { + ...(params.query ?? {}), + page, + }, +}); + +export const addPageToVerbParams = ( + verb: 'load' | 'list' | 'analyze' | 'stat', + params: TParams, + page: Page, +): TParams => { + switch (verb) { + case 'load': + return _addPageToLoadParams(params as LoadParams, page) as TParams; + case 'list': + case 'analyze': + case 'stat': + return _addPageToListParams(params as QueryParams, page) as TParams; + default: + console.warn(`[addPageToVerbParams] Unsupported verb: ${verb}`); + return params; + } +}; diff --git a/apps/web/src/query/pagination/use-scoped-pagination-query.ts b/apps/web/src/query/pagination/use-scoped-pagination-query.ts new file mode 100644 index 0000000000..a1f20731e2 --- /dev/null +++ b/apps/web/src/query/pagination/use-scoped-pagination-query.ts @@ -0,0 +1,121 @@ +import type { ComputedRef } from 'vue'; +import { + computed, watch, +} from 'vue'; + +import type { InfiniteData } from '@tanstack/vue-query'; +import { type UseInfiniteQueryOptions } from '@tanstack/vue-query'; + +import type { GrantScope } from '@/api-clients/identity/token/schema/type'; +import { useScopedInfiniteQuery } from '@/query/composables/use-scoped-infinite-query'; +import { addPageToVerbParams } from '@/query/pagination/pagination-query-helper'; +import type { QueryKeyArray } from '@/query/query-key/_types/query-key-type'; + + +/** + * useScopedPaginationQuery + * + * A wrapper around `useInfiniteQuery` for paginated resource fetching with consistent queryKey and page parameter handling. + * Automatically appends page params (start, limit) to the query function based on the API verb structure. + * Supports dynamic fetching of missing pages based on the `thisPage` value. + * + * @template TParams - API parameter type (excluding page info) + * @template TPageData - Response data type, must include `results` and `total_count` + * @template TError - Optional error type + * + * @param options - Query config including: + * - queryFn: the fetcher function (expects page-added params) + * - params: the base API params (as a ComputedRef) + * - initialPageParam: (optional) starting page index, default is 1 + * - ...restOptions: all other `useInfiniteQuery` options (except those overridden) + * + * @param pageOptions - Pagination control: + * - thisPage: current page number (1-based) + * - pageSize: number of items per page + * - verb: one of 'list' | 'stat' | 'analyze' | 'load' (used to insert page info in correct param structure) + * + * @param requiredScopes - A list of **required grant scopes** to determine if the query should execute. + * + * +* @returns { +* data: ComputedRef - Data for the current page (1-based index) +* totalCount: ComputedRef - Total number of items (from first page) +* isReady: ComputedRef - Whether the current page is loaded +* isLoading: ComputedRef - Whether the current page is being fetched +* query: Return value of useScopedInfiniteQuery - includes all raw query states +* } +*/ + +type PaginatableBaseData = { + results?: any[]; + total_count?: number; +}; + +type UsePaginationQueryOptions = Omit< + UseInfiniteQueryOptions, TPageData, QueryKeyArray, number>, + 'initialPageParam' | 'queryFn' | 'getNextPageParam' +> & { + queryFn: (params: TParams) => Promise; + params: ComputedRef; + initialPageParam?: number; +}; + +interface UsePaginationQueryPageOptions { + thisPage: ComputedRef; + pageSize: ComputedRef; + verb: 'list' | 'stat' | 'analyze' | 'load'; +} + +export const useScopedPaginationQuery = ( + options: UsePaginationQueryOptions, + pageOptions: UsePaginationQueryPageOptions, + requiredScopes: [GrantScope, ...GrantScope[]], +) => { + const { thisPage, pageSize, verb } = pageOptions; + const { + queryFn, params, initialPageParam = 1, ...restOptions + } = options; + + + // Wraps the queryFn to inject pagination params (start, limit) into correct structure based on verb. + // For example: + // - 'load': params.page = { start, limit } + // - 'list': params.query.page = { start, limit } + const wrappedQueryFn = ({ pageParam }: { pageParam: number }) => queryFn(addPageToVerbParams(verb, params.value, { + start: pageParam, + limit: pageSize.value, + })); + + + const query = useScopedInfiniteQuery, QueryKeyArray, number>({ + ...restOptions, + queryFn: wrappedQueryFn, + initialPageParam, + getNextPageParam: (lastPage, allPages) => { + const loadedCount = allPages.reduce((acc, page) => acc + (page?.results?.length || 0), 0); + return loadedCount < (lastPage?.total_count || 0) ? loadedCount + 1 : undefined; + }, + // getPreviousPageParam: (firstPage, allPages) => { + // const prevStart = allPages[0]?.results?.length ?? 0; + // return prevStart > 1 ? Math.max(1, prevStart - pageSize.value) : undefined; + // }, + }, requiredScopes); + + // Watches the `thisPage` ref and automatically fetches all pages up to that index + // Ensures that the page requested by the consumer is available in the data list + watch(thisPage, async (val) => { + const currentLength = query.data.value?.pages?.length ?? 0; + if (val > currentLength && !query.isFetchingNextPage.value) { + const calls = Array.from({ length: val - currentLength }); + await Promise.all(calls.map(() => query.fetchNextPage())); + } + }); + + return { + data: computed(() => query.data.value?.pages?.[thisPage.value - 1]), + totalCount: computed(() => query.data.value?.pages?.[0]?.total_count ?? 0), + isReady: computed(() => !!query.data.value?.pages?.[thisPage.value - 1]), + isLoading: computed(() => !query.data.value?.pages?.[thisPage.value - 1] && query.isFetchingNextPage.value), + query, + }; +}; diff --git a/apps/web/src/query/query-key/use-service-query-key.ts b/apps/web/src/query/query-key/use-service-query-key.ts index 92796230dc..33cfbb8a62 100644 --- a/apps/web/src/query/query-key/use-service-query-key.ts +++ b/apps/web/src/query/query-key/use-service-query-key.ts @@ -2,46 +2,56 @@ import { toValue } from '@vueuse/core'; import type { Ref, ComputedRef } from 'vue'; import { computed } from 'vue'; +import { omitPageFromLoadParams, omitPageQueryParams } from '@/query/pagination/pagination-query-helper'; import { useQueryKeyAppContext } from '@/query/query-key/_composable/use-app-context-query-key'; import { createImmutableObjectKeyItem } from '@/query/query-key/_helpers/immutable-query-key-helper'; import type { QueryKeyArray, ResourceName, ServiceName, Verb, } from '@/query/query-key/_types/query-key-type'; + // Cache for debug logs // const debugLogCache = new Map(); -// const DEBUG_LOG_THROTTLE = 1000; // 1초 +// const DEBUG_LOG_THROTTLE = 1000; type _MaybeRefOrGetter = T | Ref | ComputedRef | (() => T); /** - * Options for generating service query keys. - * - * While the options are provided as an object where the order of keys doesn't matter, - * the generated query key will always follow this structure: + * Generates a stable query key for service-level queries using service, resource, verb, and optional context/params. * - * ```typescript + * Structure: * [ * ...globalContext, * service, * resource, * verb, - * contextKey?, // Optional, appears first in namespace if present - * params?, // Optional, appears second if present + * contextKey?, // optional + * params?, // optional (pagination params excluded if enabled) * ] * - * Note: The order of keys in the options object doesn't affect the final query key structure. - * The query key will always maintain the above order, ensuring predictable cache management. + * Options: + * - contextKey: contextual key for scoping (e.g. selected ID) + * - params: request parameters (ref, computed, or raw object) + * - pagination: if true, removes pagination fields (e.g. page/start/limit) from params by verb type + * + * Example: + * useServiceQueryKey('dashboard', 'data-table', 'load', { + * contextKey: selectedId, + * params: computed(() => ({ page, sort, granularity })), + * pagination: true, // removes 'page' from queryKey + * }); + * + * --- + * + * @property contextKey - Optional value (string | object | array) used to scope the cache context (e.g., ID or workspace) + * @property params - API parameters. Can be a ref, computed, or raw object. Automatically processed if `pagination` is enabled. + * @property pagination - When true, removes pagination-related params (`page`, `start`, `limit`) according to the verb ('load', 'list', 'analyze', etc.) * - * @property contextKey - Optional key for contextual data (string, array, or object). - * When present, it appears first in the namespace part of the query key, - * enabling contextual cache management. - * @property params - Optional parameters for the API request. - * When present, it appears second in the namespace part of the query key. */ interface UseServiceQueryKeyOptions { contextKey?: _MaybeRefOrGetter; params?: _MaybeRefOrGetter; + pagination?: boolean; } type ContextKeyType = string|unknown[]|object; @@ -57,7 +67,7 @@ export const useServiceQueryKey = = {}, ): UseServiceQueryKeyResult => { - const { params, contextKey } = options; + const { params, contextKey, pagination } = options; // Runtime validation for development environment if (import.meta.env.DEV) { @@ -83,11 +93,12 @@ export const useServiceQueryKey = { - const resolvedParams = toValue(params); + const resolvedParams = pagination ? _omitPageParamsByVerb(verb, toValue(params)) as T : toValue(params); return [ ...queryKeyAppContext.value, service, resource, verb, ...memoizedContextKey.value, + ...(pagination ? ['pagination'] : []), ...(resolvedParams ? [createImmutableObjectKeyItem(resolvedParams)] : []), ]; }); @@ -102,7 +113,7 @@ export const useServiceQueryKey = { - const resolvedParams = toValue(params); + const resolvedParams = pagination ? _omitPageParamsByVerb(verb, toValue(params)) as T : toValue(params); return createImmutableObjectKeyItem(resolvedParams); }), withSuffix: (arg) => { @@ -127,13 +138,8 @@ const _normalizeQueryKeyPart = (key: unknown): QueryKeyArray => { return [key]; }; -// const _logQueryKeyDebug = (queryKey: QueryKeyArray) => { -// const now = Date.now(); -// const key = queryKey.join('/'); -// const lastLogTime = debugLogCache.get(key) || 0; - -// if (now - lastLogTime >= DEBUG_LOG_THROTTLE) { -// console.debug('[QueryKey]', { queryKey }); -// debugLogCache.set(key, now); -// } -// }; +const _omitPageParamsByVerb = >(verb: Verb, params = {}) => { + if (verb === 'load') return omitPageFromLoadParams(params); + if (verb === 'list' || verb === 'analyze' || verb === 'stat') return omitPageQueryParams(params); + return params; +};