diff --git a/apps/web/cli/api-doc-gen.js b/apps/web/cli/api-doc-gen.js index ffe1e79572..feeb29b90d 100644 --- a/apps/web/cli/api-doc-gen.js +++ b/apps/web/cli/api-doc-gen.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; const API_DIR = './src/api-clients'; -const OUTPUT_PATH = './src/api-clients/_common/constants/api-doc.ts'; +const OUTPUT_PATH = './src/api-clients/_common/constants/api-doc-constant.ts'; const generateAPIDocumentation = (basePath) => { diff --git a/apps/web/package.json b/apps/web/package.json index 2b9606448b..59600c142a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,7 +19,7 @@ "stylelint:fix": "stylelint --fix \"src/**/*.{css,vue,pcss,scss}\"", "format": "npm run eslint:fix && npm run stylelint:fix", "postcss": "node build/reset-css.js", - "api-doc": "node ./cli/api-doc-gen.js && npx eslint ./src/api-clients/_common/constants/api-doc.ts --fix" + "api-doc": "node ./cli/api-doc-gen.js && npx eslint ./src/api-clients/_common/constants/api-doc-constant.ts --fix" }, "main": "dist/cloudforet-component.common.js", "dependencies": { diff --git a/apps/web/src/api-clients/_common/constants/api-doc.ts b/apps/web/src/api-clients/_common/constants/api-doc-constant.ts similarity index 100% rename from apps/web/src/api-clients/_common/constants/api-doc.ts rename to apps/web/src/api-clients/_common/constants/api-doc-constant.ts diff --git a/apps/web/src/api-clients/_common/types/query-key-type.ts b/apps/web/src/api-clients/_common/types/query-key-type.ts index b8430ca461..fadaec94d7 100644 --- a/apps/web/src/api-clients/_common/types/query-key-type.ts +++ b/apps/web/src/api-clients/_common/types/query-key-type.ts @@ -1,4 +1,4 @@ -import type { API_DOC } from '@/api-clients/_common/constants/api-doc'; +import type { API_DOC } from '@/api-clients/_common/constants/api-doc-constant'; /** diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 3d8cc3687e..9d3377b8f2 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -10,6 +10,7 @@ import VTooltip from 'v-tooltip'; import SpaceDesignSystem from '@cloudforet/mirinae'; import directive from '@/directives'; +import { serviceQueryClient } from '@/query/clients'; import { SpaceRouter } from '@/router'; import { i18n } from '@/translations'; @@ -24,13 +25,12 @@ import '@/styles/style.pcss'; import '@cloudforet/mirinae/css/light-style.css'; import '@cloudforet/mirinae/dist/style.css'; - /** ********** SET VUE PLUGINS ************** */ Vue.use(Fragment.Plugin); Vue.use(VTooltip, { defaultClass: 'p-tooltip', defaultBoundariesElement: document.body }); Vue.use(PortalVue); Vue.use(PiniaVuePlugin); -Vue.use(VueQueryPlugin); +Vue.use(VueQueryPlugin, { defaultQueryClient: serviceQueryClient }); directive(Vue); diff --git a/apps/web/src/query/clients.ts b/apps/web/src/query/clients.ts new file mode 100644 index 0000000000..3efb0ec11d --- /dev/null +++ b/apps/web/src/query/clients.ts @@ -0,0 +1,15 @@ +import { QueryClient } from '@tanstack/vue-query'; + +/** + * Main query client for service-level data fetching and caching. + * This is the default query client used throughout the application. + * It's automatically injected when useQueryClient() is called in service context. + */ +export const serviceQueryClient = new QueryClient(); + +/** + * Dedicated query client for the reference data system. + * This client is used internally by the reference data system for managing its own cache. + * It's not meant to be used directly in service context. + */ +export const referenceQueryClient = new QueryClient(); diff --git a/apps/web/src/query/query-key/__tests__/use-service-query-key.test.ts b/apps/web/src/query/query-key/__tests__/use-service-query-key.test.ts new file mode 100644 index 0000000000..a610828907 --- /dev/null +++ b/apps/web/src/query/query-key/__tests__/use-service-query-key.test.ts @@ -0,0 +1,176 @@ +import { computed } from 'vue'; + +import { + describe, it, expect, vi, +} from 'vitest'; + +import { _useServiceQueryKey } from '../use-service-query-key'; + +// Mock useQueryKeyAppContext +vi.mock('@/query/query-key/_composable/use-app-context-query-key', () => ({ + useQueryKeyAppContext: () => ({ + value: ['workspace', 'workspace-123'] as const, + }), +})); + +describe('_useServiceQueryKey', () => { + it('should generate correct query key structure for basic usage', () => { + const { key } = _useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'list', + { + params: { page: 1, limit: 10 }, + }, + ); + + expect(key.value).toMatchInlineSnapshot(` +[ + "workspace", + "workspace-123", + "dashboard", + "public-data-table", + "list", + { + "limit": 10, + "page": 1, + }, +] +`); + }); + + it('should generate correct query key structure with contextKey', () => { + const { key } = _useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'get', + { + contextKey: computed(() => 'table-123'), + params: computed(() => ({ id: 'table-123' })), + }, + ); + + expect(key.value).toMatchInlineSnapshot(` +[ + "workspace", + "workspace-123", + "dashboard", + "public-data-table", + "get", + "table-123", + { + "id": "table-123", + }, +] +`); + }); + + it('should handle reactive values correctly', () => { + const params = computed(() => ({ id: 'table-123' })); + const contextKey = computed(() => 'table-123'); + + const { key } = _useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'get', + { + contextKey, + params, + }, + ); + + expect(key.value).toMatchInlineSnapshot(` +[ + "workspace", + "workspace-123", + "dashboard", + "public-data-table", + "get", + "table-123", + { + "id": "table-123", + }, +] +`); + }); + + it('should handle function getters correctly', () => { + const { key } = _useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'get', + { + contextKey: () => 'table-123', + params: computed(() => ({ id: 'table-123' })), + }, + ); + + expect(key.value).toMatchInlineSnapshot(` +[ + "workspace", + "workspace-123", + "dashboard", + "public-data-table", + "get", + "table-123", + { + "id": "table-123", + }, +] +`); + }); + + it('should maintain consistent key structure with different option orders', () => { + const params = computed(() => ({ id: 'table-123' })); + const contextKey = computed(() => 'table-123'); + + const { key: key1 } = _useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'get', + { contextKey, params }, + ); + + const { key: key2 } = _useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'get', + { params, contextKey }, + ); + + expect(key1.value).toEqual(key2.value); + expect(key1.value).toMatchInlineSnapshot(` +[ + "workspace", + "workspace-123", + "dashboard", + "public-data-table", + "get", + "table-123", + { + "id": "table-123", + }, +] +`); + }); + + it('should handle withSuffix correctly', () => { + const { withSuffix } = _useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'load', + ); + + const result = withSuffix('table-123'); + expect(result).toMatchInlineSnapshot(` +[ + "workspace", + "workspace-123", + "dashboard", + "public-data-table", + "load", + "table-123", +] +`); + }); +}); diff --git a/apps/web/src/query/query-key/_composable/use-app-context-query-key.ts b/apps/web/src/query/query-key/_composable/use-app-context-query-key.ts new file mode 100644 index 0000000000..2192def019 --- /dev/null +++ b/apps/web/src/query/query-key/_composable/use-app-context-query-key.ts @@ -0,0 +1,42 @@ +import { computed } from 'vue'; + +import { useAppContextStore } from '@/store/app-context/app-context-store'; +import { useUserWorkspaceStore } from '@/store/app-context/workspace/user-workspace-store'; + + + + +interface AdminModeState { + isAdminMode: true; + workspaceId?: undefined; +} + +interface WorkspaceModeState { + isAdminMode: false; + workspaceId: string; +} + +type QueryKeyState = AdminModeState | WorkspaceModeState; + + +export const useQueryKeyAppContext = () => { + const appContextStore = useAppContextStore(); + const userWorkspaceStore = useUserWorkspaceStore(); + + const _isAdminMode = computed(() => appContextStore.getters.isAdminMode); + const _workspaceId = computed(() => userWorkspaceStore.getters.currentWorkspaceId); + + return computed(() => { + const state: QueryKeyState = _isAdminMode.value + ? { isAdminMode: true } + : { + isAdminMode: false, + // workspaceId: _state.workspaceId! + workspaceId: _workspaceId.value ?? '', + }; + + return state.isAdminMode + ? ['admin'] + : ['workspace', state.workspaceId]; + }); +}; diff --git a/apps/web/src/query/query-key/_helpers/immutable-query-key-helper.ts b/apps/web/src/query/query-key/_helpers/immutable-query-key-helper.ts new file mode 100644 index 0000000000..299510c4e6 --- /dev/null +++ b/apps/web/src/query/query-key/_helpers/immutable-query-key-helper.ts @@ -0,0 +1,16 @@ +export const createImmutableObjectKeyItem = (obj: T): T => { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => createImmutableObjectKeyItem(item)) as unknown as T; + } + + const immutableObj = Object.entries(obj).reduce((acc, [key, value]) => ({ + ...acc, + [key]: createImmutableObjectKeyItem(value), + }), {}); + + return Object.freeze(immutableObj) as T; +}; diff --git a/apps/web/src/query/query-key/_types/query-key-type.ts b/apps/web/src/query/query-key/_types/query-key-type.ts new file mode 100644 index 0000000000..88695444ed --- /dev/null +++ b/apps/web/src/query/query-key/_types/query-key-type.ts @@ -0,0 +1,15 @@ +import type { API_DOC } from '@/api-clients/_common/constants/api-doc-constant'; + + +export type QueryKeyArray = unknown[]; + +export type QueryScope = 'service' | 'reference'; + + + +/** + * Extracts all possible keys for `{service-name}`, `{resource-name}`, and `{verb}` + */ +export type ServiceName = keyof typeof API_DOC; +export type ResourceName = keyof (typeof API_DOC)[S]; +export type Verb> = Extract<(typeof API_DOC)[S][R], string[]>[number] | string; diff --git a/apps/web/src/query/query-key/use-service-query-key.md b/apps/web/src/query/query-key/use-service-query-key.md new file mode 100644 index 0000000000..4714b1e55e --- /dev/null +++ b/apps/web/src/query/query-key/use-service-query-key.md @@ -0,0 +1,254 @@ +# Service Query Key Composable +A unified composable for generating structured query keys for TanStack Query in Vue + TypeScript apps. + +## Overview +`useServiceQueryKey` is a composable for generating query keys for API requests. It integrates with Tanstack Query's caching system to provide a consistent query key structure. + +## Basic Key Structure +The query key array follows a strict order to ensure consistent caching and invalidation patterns. The order is guaranteed to be: + +1. App Context (workspace/admin context) +2. Service Name +3. Resource Name +4. Verb +5. Context Key (if applicable) + - Used to differentiate queries with the same service/resource/verb/params but different UI or logical context + - Example: same API called with different favorite types, themes, or view states +6. Request Parameters + +This predictable structure allows for precise cache management and query invalidation at any level of the hierarchy. + +```typescript +[ + ...appContext, // ['workspace', 'workspaceId'] or ['admin'] + service, // service name (e.g., 'dashboard', 'opsflow') + resource, // resource name (e.g., 'public-data-table') + verb, // action (e.g., 'get', 'list', 'load') + contextKey?, // (optional) contextual data (string, array, or object) + params? // (optional) request parameters +] +``` + +## Key Options Interface + +```typescript +interface UseServiceQueryKeyOptions { + contextKey?: _MaybeRefOrGetter; // string | unknown[] | object + params?: ComputedRef; +} +``` + +## Return Value + +```typescript +interface UseServiceQueryKeyResult { + key: ComputedRef; // full query key + params?: Readonly; // parameters + withSuffix: (arg: ContextKeyType) => QueryKeyArray; // dynamic namespace builder +} +``` + +## Query Key Management + +### Importance of Key Structure +The query key structure is carefully designed to ensure consistent cache management. It should be used as-is without modification. + +### Correct Usage Pattern +```typescript +// ✅ Recommended: Using key as-is +const { key, params } = useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'list', + { + params: computed(() => ({ page: 1, limit: 10 })) + } +); + +const { data } = useQuery({ + queryKey: key, // Use the key directly + queryFn: () => publicDataTableAPI.list(params) +}); + +// ❌ Not Recommended: Modifying query key structure +const { data } = useQuery({ + queryKey: [...key.value, 'additional', 'parts'], // Avoid modifying the key structure + queryFn: () => publicDataTableAPI.list(params) +}); +``` + +### Why This Matters +1. **Cache Consistency**: Modifying the key structure can lead to cache misses or invalid cache entries +2. **Cache Invalidation**: Proper cache invalidation relies on consistent key structure +3. **Predictability**: The predefined structure ensures predictable cache behavior + +### Best Practices +1. Always use the `key` returned from `useServiceQueryKey` without modification +2. Use `withSuffix` for dynamic namespace creation instead of modifying the key structure +3. Maintain the predefined key structure for consistent cache management + +## Usage + +### Basic Usage +```typescript +const { key } = useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'list', + { + params: computed(() => ({ page: 1, limit: 10 })), + } +); + +// Key Result : ['workspace', 'workspace-123', 'dashboard', 'public-data-table', 'list', { page: 1, limit: 10 }] +``` + +### With Context Key +```typescript +const { key } = useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'get', + { + contextKey: computed(() => state.dataTableId), + params: computed(() => ({ page: 1, limit: 10 })), + } +); + +// Key Result : ['workspace', 'workspace-123', 'dashboard', 'public-data-table', 'get', 'table-123', { page: 1, limit: 10 }] +``` + +### With useQuery +```typescript +const { key, params } = useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'list', + { + params: computed(() => state.params) + } +); + +const { data } = useQuery({ + queryKey: key, + queryFn: () => publicDataTableAPI.list(params) +}); + +// Key Result : ['workspace', 'workspace-123', 'dashboard', 'public-data-table', 'list', { page: 1, limit: 10 }] +// Params Result : { page: 1, limit: 10 } (ComputedRef Value) +``` + +## Query Parameters Management + +### Importance of params +The `params` returned from `useServiceQueryKey` is crucial for maintaining consistent cache management. It should always be used as the parameters for the `queryFn` to ensure proper cache key generation and invalidation. + +### Correct Usage Pattern +```typescript +// ✅ Recommended: Using params from useServiceQueryKey +const { key, params } = useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'list', + { + params: computed(() => ({ page: 1, limit: 10 })) + } +); + +// ✅ Use params from useServiceQueryKey +const { data } = useQuery({ + queryKey: key, + queryFn: () => publicDataTableAPI.list(params) +}); + +// ❌ Not Recommended: Creating params separately +const { data } = useQuery({ + queryKey: key, + queryFn: () => publicDataTableAPI.list({ page: 1, limit: 10 }) // Avoid creating params separately +}); +``` + +### Why This Matters +1. **Cache Consistency**: Using the same params object ensures that the cache key matches the actual API request parameters +2. **Cache Invalidation**: Proper cache invalidation relies on consistent parameter handling +3. **Type Safety**: The params object maintains type safety throughout the query lifecycle + +### Best Practices +1. Always use the `params` returned from `useServiceQueryKey` in your `queryFn` +2. Maintain type safety by properly typing your params + +## Dynamic Namespace with withSuffix + +`withSuffix` is a method designed for imperative cache invalidation scenarios, particularly useful when you need to create dynamic query key namespaces that weren't available at the declaration time. + +### Key vs withSuffix + +| Purpose | Method | Example | When to Use | +|--------------------------|--------------|--------------------------------------------------------|------------------------------------------------| +| Declarative data fetch | `key` | `useQuery({ queryKey: key })` | During component setup or reactive queries | +| Contextual invalidation | `withSuffix` | `queryClient.invalidateQueries({ queryKey: withSuffix(id) })` | When additional runtime context is needed | + +- `key` is used for declarative data fetching and most standard cache invalidations. +- `withSuffix` should be used **only when additional dynamic context (like an ID or variant) is needed** at runtime for imperative cache control. +- It is not required for every cache invalidation — use it selectively. +- Avoid modifying the `key` manually; prefer `withSuffix` when runtime extensions are necessary. + +### Use Cases +1. Cache invalidation in mutation callbacks +2. Dynamic namespace creation in imperative code +3. Context-specific cache management + +### Example +```typescript +// In use-data-table-cascade-update.ts +const { withSuffix } = useServiceQueryKey( + 'dashboard', + 'public-data-table', + 'load' +); + +// Dynamic cache invalidation based on data table type +const _invalidateLoadQueries = async (data: DataTableModel) => { + await queryClient.invalidateQueries({ + queryKey: withSuffix(data.data_table_id), // ['admin', 'dashboard', 'public-dashboard', 'load', 'dt-123'] + }); +}; +``` + +### Benefits +1. **Imperative Cache Control**: Aligns with Tanstack Query's imperative invalidation philosophy +2. **Dynamic Context**: Allows creation of context-specific namespaces at runtime +3. **Type Safety**: Maintains type safety while providing flexibility +4. **Performance**: Includes built-in caching for object-based suffixes + +### Best Practices +1. Use `withSuffix` primarily for cache invalidation scenarios +2. Leverage object caching for frequently used suffixes +3. Consider the performance implications of suffix caching +4. Use type-safe context keys when possible + +## Maintenance Guide + +### Type Definitions +- `ServiceName`: Available service names +- `ResourceName`: Available resource names per service +- `Verb`: Available actions for service and resource +- `ContextKeyType`: string | unknown[] | object + +### Development Environment Validation +- Runtime validation only in development environment +- Validation for required parameters and types +- Debugging support through warning messages + +## Important Notes +1. Query keys must maintain immutability +2. Object parameters are automatically converted to immutable objects +3. Runtime validation only runs in development environment +4. Option object key order does not affect the result +5. `withSuffix` results are cached for object-based suffixes + +## References +- Integrates with Tanstack Query's caching system +- Supports Vue's reactivity system +- Ensures TypeScript type safety +- Provides logging for debugging in development environment 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 new file mode 100644 index 0000000000..90e8fcbce7 --- /dev/null +++ b/apps/web/src/query/query-key/use-service-query-key.ts @@ -0,0 +1,138 @@ +import { toValue } from '@vueuse/core'; +import type { Ref, ComputedRef } from 'vue'; +import { computed } from 'vue'; + +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초 + + +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: + * + * ```typescript + * [ + * ...globalContext, + * service, + * resource, + * verb, + * contextKey?, // Optional, appears first in namespace if present + * params?, // Optional, appears second if present + * ] + * + * 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. + * + * @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; +} +type ContextKeyType = string|unknown[]|object; + +type UseServiceQueryKeyResult = { + key: ComputedRef; + params: T extends undefined ? undefined : Readonly; + withSuffix: (arg: ContextKeyType) => QueryKeyArray; +}; + +export const _useServiceQueryKey = , V extends Verb, T extends object = object>( + service: S, + resource: R, + verb: V, + options: UseServiceQueryKeyOptions = {}, +): UseServiceQueryKeyResult => { + // Runtime validation for development environment + if (import.meta.env.DEV) { + if (!service || !resource || !verb) { + console.warn('Required parameters (service, resource, verb) must be provided'); + } + if (options.params) { + const rawParams = toValue(options.params); + if (rawParams === null || typeof rawParams !== 'object') { + console.warn('params must be a non-null object'); + } + } + } + + const { params, contextKey } = options; + const queryKeyAppContext = useQueryKeyAppContext(); + + + const memoizedContextKey = computed(() => { + const resolvedContextKey = toValue(contextKey); + return resolvedContextKey + ? _normalizeQueryKeyPart(createImmutableObjectKeyItem(resolvedContextKey)) + : []; + }); + + const queryKey = computed(() => { + const resolvedParams = toValue(params); + + return [ + ...queryKeyAppContext.value, + service, resource, verb, + ...memoizedContextKey.value, + ...(resolvedParams ? [createImmutableObjectKeyItem(resolvedParams)] : []), + ]; + }); + + // NOTE: Only for development environment. After using tanstack query devtools, this will be removed. + // if (import.meta.env.DEV) { + // _logQueryKeyDebug(queryKey.value); + // } + + + const suffixCache = new WeakMap(); + return { + key: queryKey, + params: params + ? Object.freeze(toValue(params)) as Readonly + : undefined, + withSuffix: (arg) => { + if (typeof arg === 'object' && arg !== null) { + const cached = suffixCache.get(arg); + if (cached) return cached; + + const result = [...queryKey.value, ..._normalizeQueryKeyPart(createImmutableObjectKeyItem(arg))]; + suffixCache.set(arg, result); + return result; + } + return [...queryKey.value, arg]; + }, + } as UseServiceQueryKeyResult; +}; + + +const _normalizeQueryKeyPart = (key: unknown): QueryKeyArray => { + if (Array.isArray(key)) { + return key; + } + 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); +// } +// };