veza/apps/web/src/utils/stateCleanup.ts
senke 8e354048ac [FE-STATE-012] fe-state: Add state cleanup
- Created state cleanup system (stateCleanup.ts) with:
  * Size limit cleanup: Limit number of items in arrays/normalized state
  * Age limit cleanup: Remove items older than specified time
  * Custom cleanup: User-defined cleanup functions
  * Support for arrays, normalized state, and nested objects
- Added cleanupMiddleware for automatic periodic cleanup
- Added performCleanup function for manual cleanup
- Comprehensive test suite (9 tests, all passing)
- Prevents memory leaks by cleaning unused state data
2025-12-25 14:23:06 +01:00

434 lines
12 KiB
TypeScript

/**
* State Cleanup
* FE-STATE-012: Clean up unused state to prevent memory leaks
*
* Provides utilities and middleware for cleaning up unused state data
* in Zustand stores to prevent memory leaks.
*/
import { StateCreator } from 'zustand';
import { logger } from './logger';
/**
* Cleanup strategy
*/
export type CleanupStrategy =
| 'size_limit' // Limit by size (number of items)
| 'age_limit' // Limit by age (time-based)
| 'both' // Both size and age limits
| 'custom'; // Custom cleanup function
/**
* Cleanup configuration
*/
export interface CleanupConfig {
/** Strategy to use */
strategy: CleanupStrategy;
/** Maximum number of items to keep (for size_limit) */
maxSize?: number;
/** Maximum age in milliseconds (for age_limit) */
maxAge?: number;
/** Custom cleanup function */
cleanupFn?: (state: unknown) => unknown;
/** Fields to clean up (if not specified, cleans entire state) */
fields?: string[];
/** Enable automatic periodic cleanup */
autoCleanup?: boolean;
/** Interval for automatic cleanup in milliseconds (default: 5 minutes) */
cleanupInterval?: number;
}
/**
* Cleanup options for middleware
*/
export interface CleanupOptions {
/** Store name for logging */
storeName: string;
/** Cleanup configurations for different state fields */
configs: Record<string, CleanupConfig>;
/** Enable cleanup (default: true) */
enabled?: boolean;
}
/**
* Clean up state based on size limit
*/
function cleanupBySize<T>(
items: T[],
maxSize: number,
getTimestamp?: (item: T) => number,
): T[] {
if (items.length <= maxSize) {
return items;
}
// If we have timestamps, keep the most recent items
if (getTimestamp) {
const sorted = [...items].sort((a, b) => getTimestamp(b) - getTimestamp(a));
return sorted.slice(0, maxSize);
}
// Otherwise, keep the first items
return items.slice(0, maxSize);
}
/**
* Clean up state based on age limit
*/
function cleanupByAge<T>(
items: T[],
maxAge: number,
getTimestamp: (item: T) => number,
): T[] {
const now = Date.now();
const cutoff = now - maxAge;
return items.filter((item) => {
const timestamp = getTimestamp(item);
return timestamp >= cutoff;
});
}
/**
* Clean up normalized state by size
*/
function cleanupNormalizedBySize<T extends { id: string }>(
normalized: { byId: Record<string, T>; allIds: string[] },
maxSize: number,
getTimestamp?: (item: T) => number,
): { byId: Record<string, T>; allIds: string[] } {
if (normalized.allIds.length <= maxSize) {
return normalized;
}
let itemsToKeep: T[];
if (getTimestamp) {
// Sort by timestamp and keep most recent
const items = normalized.allIds.map((id) => normalized.byId[id]);
const sorted = items.sort((a, b) => getTimestamp(b) - getTimestamp(a));
itemsToKeep = sorted.slice(0, maxSize);
} else {
// Keep first items
itemsToKeep = normalized.allIds
.slice(0, maxSize)
.map((id) => normalized.byId[id]);
}
const newById: Record<string, T> = {};
const newAllIds: string[] = [];
for (const item of itemsToKeep) {
newById[item.id] = item;
newAllIds.push(item.id);
}
return { byId: newById, allIds: newAllIds };
}
/**
* Clean up normalized state by age
*/
function cleanupNormalizedByAge<T extends { id: string }>(
normalized: { byId: Record<string, T>; allIds: string[] },
maxAge: number,
getTimestamp: (item: T) => number,
): { byId: Record<string, T>; allIds: string[] } {
const now = Date.now();
const cutoff = now - maxAge;
const newById: Record<string, T> = {};
const newAllIds: string[] = [];
for (const id of normalized.allIds) {
const item = normalized.byId[id];
const timestamp = getTimestamp(item);
if (timestamp >= cutoff) {
newById[id] = item;
newAllIds.push(id);
}
}
return { byId: newById, allIds: newAllIds };
}
/**
* Apply cleanup to a state field
*/
function applyCleanup<T = unknown>(
value: T,
config: CleanupConfig,
): T {
if (!config.strategy || config.strategy === 'custom') {
if (config.cleanupFn) {
return config.cleanupFn(value) as T;
}
return value;
}
// Handle arrays
if (Array.isArray(value)) {
const getTimestamp = (item: unknown): number => {
if (typeof item === 'object' && item !== null) {
const obj = item as Record<string, unknown>;
if ('created_at' in obj && typeof obj.created_at === 'string') {
return new Date(obj.created_at).getTime();
}
if ('timestamp' in obj && typeof obj.timestamp === 'number') {
return obj.timestamp;
}
if ('updated_at' in obj && typeof obj.updated_at === 'string') {
return new Date(obj.updated_at).getTime();
}
}
return Date.now();
};
let cleaned = value;
// Apply age limit first (removes old items)
if (config.strategy === 'age_limit' || config.strategy === 'both') {
if (config.maxAge) {
cleaned = cleanupByAge(cleaned, config.maxAge, getTimestamp) as T;
}
}
// Then apply size limit (keeps most recent items)
if (config.strategy === 'size_limit' || config.strategy === 'both') {
if (config.maxSize) {
cleaned = cleanupBySize(cleaned, config.maxSize, getTimestamp) as T;
}
}
return cleaned;
}
// Handle normalized state (byId + allIds)
if (
typeof value === 'object' &&
value !== null &&
'byId' in value &&
'allIds' in value
) {
const normalized = value as { byId: Record<string, unknown>; allIds: string[] };
const getTimestamp = (item: unknown): number => {
if (typeof item === 'object' && item !== null) {
const obj = item as Record<string, unknown>;
if ('created_at' in obj && typeof obj.created_at === 'string') {
return new Date(obj.created_at).getTime();
}
if ('timestamp' in obj && typeof obj.timestamp === 'number') {
return obj.timestamp;
}
if ('updated_at' in obj && typeof obj.updated_at === 'string') {
return new Date(obj.updated_at).getTime();
}
}
return Date.now();
};
let cleaned = normalized;
// Apply age limit first (removes old items)
if (config.strategy === 'age_limit' || config.strategy === 'both') {
if (config.maxAge) {
cleaned = cleanupNormalizedByAge(
cleaned as { byId: Record<string, { id: string }>; allIds: string[] },
config.maxAge,
getTimestamp,
) as typeof normalized;
}
}
// Then apply size limit (keeps most recent items)
if (config.strategy === 'size_limit' || config.strategy === 'both') {
if (config.maxSize) {
cleaned = cleanupNormalizedBySize(
cleaned as { byId: Record<string, { id: string }>; allIds: string[] },
config.maxSize,
getTimestamp,
) as typeof normalized;
}
}
return cleaned as T;
}
// Handle objects with nested arrays (e.g., messages: { [conversationId]: Message[] })
if (typeof value === 'object' && value !== null) {
const obj = value as Record<string, unknown>;
const cleaned: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
if (Array.isArray(val)) {
const getTimestamp = (item: unknown): number => {
if (typeof item === 'object' && item !== null) {
const itemObj = item as Record<string, unknown>;
if ('created_at' in itemObj && typeof itemObj.created_at === 'string') {
return new Date(itemObj.created_at).getTime();
}
if ('timestamp' in itemObj && typeof itemObj.timestamp === 'number') {
return itemObj.timestamp;
}
}
return Date.now();
};
let cleanedArray = val;
// Apply age limit first (removes old items)
if (config.strategy === 'age_limit' || config.strategy === 'both') {
if (config.maxAge) {
cleanedArray = cleanupByAge(cleanedArray, config.maxAge, getTimestamp);
}
}
// Then apply size limit (keeps most recent items)
if (config.strategy === 'size_limit' || config.strategy === 'both') {
if (config.maxSize) {
cleanedArray = cleanupBySize(cleanedArray, config.maxSize, getTimestamp);
}
}
cleaned[key] = cleanedArray;
} else {
cleaned[key] = val;
}
}
return cleaned as T;
}
return value;
}
/**
* Clean up state based on configuration
*/
export function cleanupState<T extends object>(
state: T,
configs: Record<string, CleanupConfig>,
): T {
const cleaned = { ...state } as Record<string, unknown>;
for (const [field, config] of Object.entries(configs)) {
if (field in cleaned) {
try {
cleaned[field] = applyCleanup(cleaned[field], config);
} catch (error) {
logger.warn(`[StateCleanup] Failed to clean field ${field}:`, error);
}
}
}
return cleaned as T;
}
/**
* Zustand middleware for automatic state cleanup
*
* @example
* ```typescript
* export const useMyStore = create<MyState>()(
* cleanupMiddleware(
* (set, get) => ({
* // store implementation
* }),
* {
* storeName: 'MyStore',
* configs: {
* messages: {
* strategy: 'both',
* maxSize: 100,
* maxAge: 24 * 60 * 60 * 1000, // 24 hours
* },
* },
* }
* )
* );
* ```
*/
export function cleanupMiddleware<T extends object>(
config: StateCreator<T>,
options: CleanupOptions,
): StateCreator<T> {
const {
storeName,
configs,
enabled = true,
autoCleanup = false,
cleanupInterval = 5 * 60 * 1000, // 5 minutes default
} = options;
let cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
return (set, get, api) => {
const store = config(set, get, api);
if (!enabled) {
return store;
}
// Set up automatic cleanup if enabled
if (autoCleanup && typeof window !== 'undefined') {
cleanupIntervalId = setInterval(() => {
try {
const currentState = get();
const cleanedState = cleanupState(currentState, configs);
const stateChanged = JSON.stringify(currentState) !== JSON.stringify(cleanedState);
if (stateChanged) {
logger.debug(`[StateCleanup:${storeName}] Automatic cleanup performed`);
set(cleanedState as Partial<T>);
}
} catch (error) {
logger.error(`[StateCleanup:${storeName}] Automatic cleanup failed:`, error);
}
}, cleanupInterval);
// Clean up interval on unmount (if store is destroyed)
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
if (cleanupIntervalId) {
clearInterval(cleanupIntervalId);
}
});
}
}
// Add cleanup method to store
return {
...store,
// Add cleanup method if it doesn't exist
cleanup: () => {
performCleanup(get, set, configs, storeName);
},
} as T & { cleanup: () => void };
};
}
/**
* Manual cleanup function for stores
*/
export function performCleanup<T extends object>(
getState: () => T,
setState: (state: Partial<T>) => void,
configs: Record<string, CleanupConfig>,
storeName: string = 'Unknown',
): void {
try {
const currentState = getState();
const cleanedState = cleanupState(currentState, configs);
const stateChanged = JSON.stringify(currentState) !== JSON.stringify(cleanedState);
if (stateChanged) {
logger.info(`[StateCleanup:${storeName}] Manual cleanup performed`);
setState(cleanedState);
} else {
logger.debug(`[StateCleanup:${storeName}] No cleanup needed`);
}
} catch (error) {
logger.error(`[StateCleanup:${storeName}] Manual cleanup failed:`, error);
}
}