veza/apps/web/src/utils/stateInvalidation.ts
senke badd652577 [INT-V2-001] Fix legacy auth store reference in stateInvalidation.ts
- Replace require('@/stores/auth') with require('@/features/auth/store/authStore')
- Aligns with INT-AUTH-002: single auth store migration
2025-12-26 09:54:08 +01:00

334 lines
8.7 KiB
TypeScript

/**
* State Invalidation Utilities
* FE-STATE-004: Invalidate stale state when data changes
*
* Provides utilities for invalidating cached state, queries, and stores
* when data changes on the server or locally
*/
import { responseCache } from '@/services/responseCache';
import { logger } from './logger';
/**
* Types of state that can be invalidated
*/
export type InvalidationTarget =
| 'cache' // Response cache
| 'queries' // TanStack Query cache
| 'stores' // Zustand stores
| 'all'; // All of the above
/**
* Resource types for granular invalidation
*/
export type ResourceType =
| 'tracks'
| 'playlists'
| 'users'
| 'conversations'
| 'roles'
| 'library'
| 'auth'
| 'ui'
| 'all';
/**
* Options for state invalidation
*/
export interface InvalidationOptions {
/** Target to invalidate */
target?: InvalidationTarget;
/** Resource type to invalidate */
resourceType?: ResourceType;
/** Specific resource ID */
resourceId?: string;
/** Whether to invalidate all state */
invalidateAll?: boolean;
/** Query keys to invalidate (for TanStack Query) */
queryKeys?: (string | number)[][];
/** Store names to invalidate (for Zustand stores) */
storeNames?: string[];
}
/**
* FE-STATE-004: Invalidate stale state
*
* Invalidates cached state, queries, and stores based on the provided options.
* This should be called after mutations or when data changes are detected.
*
* @param options Invalidation options
*
* @example
* ```typescript
* // Invalidate all cache for tracks
* invalidateState({ resourceType: 'tracks', target: 'cache' });
*
* // Invalidate specific playlist
* invalidateState({ resourceType: 'playlists', resourceId: '123', target: 'all' });
*
* // Invalidate after mutation
* await updatePlaylist(id, data);
* invalidateState({ resourceType: 'playlists', resourceId: id });
* ```
*/
export function invalidateState(options: InvalidationOptions = {}): void {
const {
target = 'all',
resourceType,
resourceId,
invalidateAll = false,
queryKeys = [],
storeNames = [],
} = options;
try {
// Invalidate response cache
if (target === 'cache' || target === 'all') {
if (invalidateAll) {
responseCache.clear();
logger.debug('[StateInvalidation] Cleared all response cache');
} else if (resourceType) {
invalidateCacheByResource(resourceType, resourceId);
}
}
// Invalidate TanStack Query cache
if (target === 'queries' || target === 'all') {
invalidateQueries(queryKeys, resourceType, resourceId);
}
// Invalidate Zustand stores
if (target === 'stores' || target === 'all') {
invalidateStores(storeNames, resourceType, resourceId);
}
logger.debug('[StateInvalidation] State invalidated', {
target,
resourceType,
resourceId,
invalidateAll,
});
} catch (error) {
logger.error('[StateInvalidation] Error invalidating state:', error);
}
}
/**
* Invalidate cache by resource type
*/
function invalidateCacheByResource(
resourceType: ResourceType,
resourceId?: string,
): void {
const patterns: Record<ResourceType, string[]> = {
tracks: ['/tracks', '/library/tracks'],
playlists: ['/playlists'],
users: ['/users', '/auth/me'],
conversations: ['/conversations'],
roles: ['/roles'],
library: ['/library', '/tracks'],
auth: ['/auth'],
ui: [],
all: [],
};
if (resourceType === 'all') {
responseCache.clear();
return;
}
const patternsToInvalidate = patterns[resourceType] || [];
for (const pattern of patternsToInvalidate) {
responseCache.invalidate(pattern);
}
// If resourceId is provided, invalidate specific resource
if (resourceId) {
for (const pattern of patternsToInvalidate) {
responseCache.invalidate(`${pattern}/${resourceId}`);
}
}
}
/**
* Invalidate TanStack Query queries
*
* Note: This function emits events that should be handled by components
* using TanStack Query. Since we can't access QueryClient directly outside
* of React components, we use a custom event system.
*/
function invalidateQueries(
queryKeys: (string | number)[][],
resourceType?: ResourceType,
resourceId?: string,
): void {
// Emit custom event for query invalidation
// Components using TanStack Query should listen to this event
if (typeof window !== 'undefined') {
const event = new CustomEvent('veza:invalidate-queries', {
detail: {
queryKeys,
resourceType,
resourceId,
},
});
window.dispatchEvent(event);
logger.debug('[StateInvalidation] Dispatched query invalidation event', {
queryKeys,
resourceType,
resourceId,
});
}
}
/**
* Invalidate Zustand stores
*/
function invalidateStores(
storeNames: string[],
resourceType?: ResourceType,
resourceId?: string,
): void {
// Map resource types to store names
const resourceToStores: Record<ResourceType, string[]> = {
tracks: ['library'],
playlists: ['library'],
users: ['auth'],
conversations: ['chat'],
roles: [],
library: ['library'],
auth: ['auth'],
ui: ['ui'],
all: ['auth', 'library', 'chat', 'ui'],
};
const storesToInvalidate = storeNames.length > 0
? storeNames
: resourceType
? resourceToStores[resourceType] || []
: [];
for (const storeName of storesToInvalidate) {
try {
invalidateStore(storeName, resourceType, resourceId);
} catch (error) {
logger.warn(`[StateInvalidation] Failed to invalidate store ${storeName}:`, error);
}
}
}
/**
* Invalidate a specific store
*/
function invalidateStore(
storeName: string,
resourceType?: ResourceType,
resourceId?: string,
): void {
try {
switch (storeName) {
case 'auth':
const { useAuthStore } = require('@/features/auth/store/authStore');
// Refresh user data
useAuthStore.getState().refreshUser?.();
break;
case 'library':
const { useLibraryStore } = require('@/stores/library');
const libraryStore = useLibraryStore.getState();
// Clear items and refetch if needed
if (resourceType === 'tracks' || resourceType === 'library') {
libraryStore.clearItems?.();
// Optionally refetch
// libraryStore.fetchItems?.();
}
break;
case 'chat':
const { useChatStore } = require('@/stores/chat');
const chatStore = useChatStore.getState();
// Refetch conversations if needed
if (resourceType === 'conversations') {
chatStore.fetchConversations?.();
}
break;
case 'ui':
// UI store doesn't need invalidation as it's user preferences
break;
default:
logger.warn(`[StateInvalidation] Unknown store: ${storeName}`);
}
} catch (error) {
logger.error(`[StateInvalidation] Error invalidating store ${storeName}:`, error);
}
}
/**
* FE-STATE-004: Helper to invalidate state after a mutation
*
* This is a convenience function that automatically determines what to invalidate
* based on the mutation endpoint.
*
* @param url The mutation URL
* @param method The HTTP method
*
* @example
* ```typescript
* // In apiClient interceptor
* if (isMutation) {
* invalidateStateAfterMutation(response.config.url, method);
* }
* ```
*/
export function invalidateStateAfterMutation(
url: string | undefined,
method: string,
): void {
if (!url) {
return;
}
// Determine resource type from URL
let resourceType: ResourceType | undefined;
let resourceId: string | undefined;
if (url.includes('/tracks/')) {
resourceType = 'tracks';
const match = url.match(/\/tracks\/([^/]+)/);
resourceId = match ? match[1] : undefined;
} else if (url.includes('/playlists/')) {
resourceType = 'playlists';
const match = url.match(/\/playlists\/([^/]+)/);
resourceId = match ? match[1] : undefined;
} else if (url.includes('/users/') || url.includes('/auth/')) {
resourceType = 'users';
const match = url.match(/\/(users|auth)\/([^/]+)/);
resourceId = match ? match[2] : undefined;
} else if (url.includes('/conversations/')) {
resourceType = 'conversations';
const match = url.match(/\/conversations\/([^/]+)/);
resourceId = match ? match[1] : undefined;
} else if (url.includes('/roles/')) {
resourceType = 'roles';
const match = url.match(/\/roles\/([^/]+)/);
resourceId = match ? match[1] : undefined;
}
if (resourceType) {
invalidateState({
resourceType,
resourceId,
target: 'all',
});
} else {
// If we can't determine the resource type, invalidate all cache
invalidateState({
target: 'cache',
invalidateAll: true,
});
}
}