- Replace require('@/stores/auth') with require('@/features/auth/store/authStore')
- Aligns with INT-AUTH-002: single auth store migration
334 lines
8.7 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|
|
|