UNPKG

@sailboat-computer/data-storage

Version:

Shared data storage library for sailboat computer v3

775 lines (664 loc) 19.3 kB
/** * Mock storage provider for testing * * This provider can simulate failures and recovery for resilience testing. */ import { StorageProvider, DataMetadata, DataQuery, StoredData, CleanupResult, StorageStatus, StorageErrorCode } from '../../src/types'; import { StorageError } from '../../src/utils/errors'; /** * Mock provider state */ export enum MockProviderState { HEALTHY = 'healthy', UNAVAILABLE = 'unavailable', SLOW = 'slow', INTERMITTENT = 'intermittent', DEGRADED = 'degraded' } /** * Mock provider options */ export interface MockProviderOptions { /** * Initial state of the provider */ initialState?: MockProviderState; /** * Delay in milliseconds for operations when in SLOW state */ slowDelayMs?: number; /** * Failure rate (0-1) for operations when in INTERMITTENT state */ intermittentFailureRate?: number; /** * Whether to log operations */ logging?: boolean; } /** * Mock storage provider for testing */ export class MockProvider implements StorageProvider { private state: MockProviderState; private initialized = false; private data: Map<string, StoredData> = new Map(); private categoryIndex: Map<string, Set<string>> = new Map(); private options: MockProviderOptions; private lastOperation: Date | null = null; private operationCount = 0; private failureCount = 0; /** * Create a new mock provider * * @param options - Provider options */ constructor(options: MockProviderOptions = {}) { this.options = { initialState: MockProviderState.HEALTHY, slowDelayMs: 2000, intermittentFailureRate: 0.5, logging: false, ...options }; this.state = this.options.initialState!; } /** * Initialize the provider */ async initialize(): Promise<void> { this.log('Initializing mock provider'); if (this.state === MockProviderState.UNAVAILABLE) { this.failureCount++; throw new StorageError( StorageErrorCode.CONNECTION_FAILED, 'Mock provider is unavailable', {} ); } if (this.state === MockProviderState.INTERMITTENT && this.shouldFail()) { this.failureCount++; throw new StorageError( StorageErrorCode.CONNECTION_FAILED, 'Mock provider connection failed (intermittent)', {} ); } await this.simulateDelay(); this.initialized = true; this.lastOperation = new Date(); this.operationCount++; } /** * Close the provider */ async close(): Promise<void> { this.log('Closing mock provider'); if (this.state === MockProviderState.UNAVAILABLE) { this.failureCount++; throw new StorageError( StorageErrorCode.SYSTEM_ERROR, 'Mock provider is unavailable', {} ); } if (this.state === MockProviderState.INTERMITTENT && this.shouldFail()) { this.failureCount++; throw new StorageError( StorageErrorCode.SYSTEM_ERROR, 'Mock provider close failed (intermittent)', {} ); } await this.simulateDelay(); this.initialized = false; this.lastOperation = new Date(); this.operationCount++; } /** * Store data * * @param data - Data to store * @param metadata - Data metadata * @returns Data ID */ async store(data: any, metadata: DataMetadata): Promise<string> { this.log('Storing data', { metadata }); this.ensureInitialized(); if (this.state === MockProviderState.UNAVAILABLE) { this.failureCount++; throw new StorageError( StorageErrorCode.STORE_FAILED, 'Mock provider is unavailable', {} ); } if (this.state === MockProviderState.INTERMITTENT && this.shouldFail()) { this.failureCount++; throw new StorageError( StorageErrorCode.STORE_FAILED, 'Mock provider store failed (intermittent)', {} ); } await this.simulateDelay(); const id = metadata.id || `mock-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; const storedData: StoredData = { data, metadata: { ...metadata, id } }; this.data.set(id, storedData); // Update category index if (!this.categoryIndex.has(metadata.category)) { this.categoryIndex.set(metadata.category, new Set()); } this.categoryIndex.get(metadata.category)!.add(id); this.lastOperation = new Date(); this.operationCount++; return id; } /** * Retrieve data * * @param query - Data query * @returns Retrieved data */ async retrieve(query: DataQuery): Promise<StoredData[]> { this.log('Retrieving data', { query }); this.ensureInitialized(); if (this.state === MockProviderState.UNAVAILABLE) { this.failureCount++; throw new StorageError( StorageErrorCode.RETRIEVE_FAILED, 'Mock provider is unavailable', {} ); } if (this.state === MockProviderState.INTERMITTENT && this.shouldFail()) { this.failureCount++; throw new StorageError( StorageErrorCode.RETRIEVE_FAILED, 'Mock provider retrieve failed (intermittent)', {} ); } await this.simulateDelay(); // Handle custom query with ID const customQuery = query as DataQuery & { id?: string }; if (customQuery.id) { const data = this.data.get(customQuery.id); return data ? [data] : []; } // Filter by category let results: StoredData[] = []; if (query.category) { const categoryIds = this.categoryIndex.get(query.category); if (categoryIds) { for (const id of categoryIds) { const data = this.data.get(id); if (data) { results.push(data); } } } } else { // Return all data results = Array.from(this.data.values()); } // Filter by time range if (query.timeRange) { results = results.filter(item => { const timestamp = new Date(item.metadata.timestamp); if (query.timeRange!.start && timestamp < new Date(query.timeRange!.start)) { return false; } if (query.timeRange!.end && timestamp > new Date(query.timeRange!.end)) { return false; } return true; }); } // Sort results if (query.sort) { results.sort((a, b) => { const fieldA = this.getNestedValue(a, query.sort!.field); const fieldB = this.getNestedValue(b, query.sort!.field); if (fieldA < fieldB) { return query.sort!.order === 'asc' ? -1 : 1; } if (fieldA > fieldB) { return query.sort!.order === 'asc' ? 1 : -1; } return 0; }); } // Apply limit and offset const customQueryWithPaging = query as DataQuery & { offset?: number, limit?: number }; if (customQueryWithPaging.offset || customQueryWithPaging.limit) { const offset = customQueryWithPaging.offset || 0; const limit = customQueryWithPaging.limit || results.length; results = results.slice(offset, offset + limit); } this.lastOperation = new Date(); this.operationCount++; return results; } /** * Update data * * @param data - Data to update */ async update(data: StoredData): Promise<void> { this.log('Updating data', { id: data.metadata.id }); this.ensureInitialized(); if (this.state === MockProviderState.UNAVAILABLE) { this.failureCount++; throw new StorageError( StorageErrorCode.UPDATE_FAILED, 'Mock provider is unavailable', {} ); } if (this.state === MockProviderState.INTERMITTENT && this.shouldFail()) { this.failureCount++; throw new StorageError( StorageErrorCode.UPDATE_FAILED, 'Mock provider update failed (intermittent)', {} ); } await this.simulateDelay(); const id = data.metadata.id; if (!id) { throw new StorageError( StorageErrorCode.INVALID_DATA, 'Data ID is required for update', {} ); } if (!this.data.has(id)) { throw new StorageError( StorageErrorCode.UPDATE_FAILED, `Data with ID ${id} not found`, {} ); } // Update data this.data.set(id, data); this.lastOperation = new Date(); this.operationCount++; } /** * Delete data * * @param id - Data ID */ async delete(id: string): Promise<void> { this.log('Deleting data', { id }); this.ensureInitialized(); if (this.state === MockProviderState.UNAVAILABLE) { this.failureCount++; throw new StorageError( StorageErrorCode.DELETE_FAILED, 'Mock provider is unavailable', {} ); } if (this.state === MockProviderState.INTERMITTENT && this.shouldFail()) { this.failureCount++; throw new StorageError( StorageErrorCode.DELETE_FAILED, 'Mock provider delete failed (intermittent)', {} ); } await this.simulateDelay(); if (!this.data.has(id)) { throw new StorageError( StorageErrorCode.DELETE_FAILED, `Data with ID ${id} not found`, {} ); } // Get category for index update const category = this.data.get(id)!.metadata.category; // Delete from data map this.data.delete(id); // Update category index if (this.categoryIndex.has(category)) { this.categoryIndex.get(category)!.delete(id); } this.lastOperation = new Date(); this.operationCount++; } /** * Store batch of data * * @param items - Items to store * @returns Data IDs */ async storeBatch(items: Array<{data: any, metadata: DataMetadata}>): Promise<string[]> { this.log('Storing batch data', { itemCount: items.length }); this.ensureInitialized(); if (this.state === MockProviderState.UNAVAILABLE) { this.failureCount++; throw new StorageError( StorageErrorCode.STORE_FAILED, 'Mock provider is unavailable', {} ); } if (this.state === MockProviderState.INTERMITTENT && this.shouldFail()) { this.failureCount++; throw new StorageError( StorageErrorCode.STORE_FAILED, 'Mock provider store batch failed (intermittent)', {} ); } await this.simulateDelay(); const ids: string[] = []; for (const item of items) { const id = await this.store(item.data, item.metadata); ids.push(id); } this.lastOperation = new Date(); this.operationCount++; return ids; } /** * Clean up old data * * @param category - Data category * @param retentionDays - Retention period in days * @returns Cleanup result */ async cleanup(category: string, retentionDays: number): Promise<CleanupResult> { this.log('Cleaning up data', { category, retentionDays }); this.ensureInitialized(); if (this.state === MockProviderState.UNAVAILABLE) { this.failureCount++; throw new StorageError( StorageErrorCode.CLEANUP_FAILED, 'Mock provider is unavailable', {} ); } if (this.state === MockProviderState.INTERMITTENT && this.shouldFail()) { this.failureCount++; throw new StorageError( StorageErrorCode.CLEANUP_FAILED, 'Mock provider cleanup failed (intermittent)', {} ); } await this.simulateDelay(); const startTime = Date.now(); let itemsRemoved = 0; let bytesFreed = 0; const errors: string[] = []; // Calculate cutoff date const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - retentionDays); // Get category IDs const categoryIds = this.categoryIndex.get(category); if (categoryIds) { const idsToRemove: string[] = []; // Find items to delete for (const id of categoryIds) { const item = this.data.get(id); if (item) { const timestamp = new Date(item.metadata.timestamp); if (timestamp < cutoffDate) { idsToRemove.push(id); // Estimate size (rough approximation) const itemSize = JSON.stringify(item).length; bytesFreed += itemSize; } } } // Delete items for (const id of idsToRemove) { try { await this.delete(id); itemsRemoved++; } catch (error) { errors.push(`Failed to delete item ${id}: ${error}`); } } } this.lastOperation = new Date(); this.operationCount++; return { itemsRemoved, bytesFreed, duration: Date.now() - startTime, errors: errors.length > 0 ? errors : undefined }; } /** * Get provider status * * @returns Provider status */ async getStatus(): Promise<StorageStatus> { this.log('Getting status'); if (this.state === MockProviderState.UNAVAILABLE) { this.failureCount++; return { connected: false, healthy: false, error: 'Mock provider is unavailable', queryPerformance: { hot: { averageQueryTime: 0, queriesPerSecond: 0 }, warm: { averageQueryTime: 0, queriesPerSecond: 0 }, cold: { averageQueryTime: 0, queriesPerSecond: 0 }, overall: 'unhealthy', providers: { hot: 'unhealthy', warm: 'unhealthy', cold: 'unhealthy' }, issues: { severity: 'error', message: 'Mock provider is unavailable', component: 'mock-provider', timestamp: new Date() } } }; } if (this.state === MockProviderState.INTERMITTENT && this.shouldFail()) { this.failureCount++; return { connected: false, healthy: false, error: 'Mock provider is intermittently available', queryPerformance: { hot: { averageQueryTime: 0, queriesPerSecond: 0 }, warm: { averageQueryTime: 0, queriesPerSecond: 0 }, cold: { averageQueryTime: 0, queriesPerSecond: 0 }, overall: 'unhealthy', providers: { hot: 'unhealthy', warm: 'unhealthy', cold: 'unhealthy' }, issues: { severity: 'error', message: 'Mock provider is intermittently available', component: 'mock-provider', timestamp: new Date() } } }; } await this.simulateDelay(); const isHealthy = this.state === MockProviderState.HEALTHY; const isDegraded = this.state === MockProviderState.DEGRADED; this.operationCount++; return { connected: this.initialized, healthy: isHealthy, lastOperation: this.lastOperation || undefined, metrics: { operationsPerSecond: 10, averageLatency: this.state === MockProviderState.SLOW ? 2000 : 50, errorRate: this.failureCount / (this.operationCount || 1), storageUsed: this.data.size * 1024, storageAvailable: 1024 * 1024 * 1024 }, queryPerformance: { hot: { averageQueryTime: this.state === MockProviderState.SLOW ? 2000 : 50, queriesPerSecond: 10 }, warm: { averageQueryTime: this.state === MockProviderState.SLOW ? 3000 : 100, queriesPerSecond: 5 }, cold: { averageQueryTime: this.state === MockProviderState.SLOW ? 5000 : 200, queriesPerSecond: 2 }, overall: isHealthy ? 'healthy' : isDegraded ? 'degraded' : 'unhealthy', providers: { hot: isHealthy ? 'healthy' : isDegraded ? 'degraded' : 'unhealthy', warm: isHealthy ? 'healthy' : isDegraded ? 'degraded' : 'unhealthy', cold: isHealthy ? 'healthy' : isDegraded ? 'degraded' : 'unhealthy' }, issues: { severity: isHealthy ? 'info' : isDegraded ? 'warning' : 'error', message: isHealthy ? 'Mock provider is healthy' : isDegraded ? 'Mock provider is degraded' : 'Mock provider is unhealthy', component: 'mock-provider', timestamp: new Date() } } }; } /** * Set provider state * * @param state - New state */ setState(state: MockProviderState): void { this.log(`Setting state to ${state}`); this.state = state; } /** * Get provider state * * @returns Current state */ getState(): MockProviderState { return this.state; } /** * Clear all data */ clearData(): void { this.log('Clearing all data'); this.data.clear(); this.categoryIndex.clear(); } /** * Get operation count * * @returns Operation count */ getOperationCount(): number { return this.operationCount; } /** * Get failure count * * @returns Failure count */ getFailureCount(): number { return this.failureCount; } /** * Reset metrics */ resetMetrics(): void { this.log('Resetting metrics'); this.operationCount = 0; this.failureCount = 0; } /** * Ensure provider is initialized * * @throws StorageError if not initialized */ private ensureInitialized(): void { if (!this.initialized) { throw new StorageError( StorageErrorCode.SYSTEM_ERROR, 'Mock provider not initialized', {} ); } } /** * Simulate delay based on state */ private async simulateDelay(): Promise<void> { if (this.state === MockProviderState.SLOW) { await new Promise(resolve => setTimeout(resolve, this.options.slowDelayMs)); } } /** * Determine if operation should fail based on intermittent failure rate * * @returns Whether operation should fail */ private shouldFail(): boolean { return Math.random() < (this.options.intermittentFailureRate || 0.5); } /** * Get nested value from object * * @param obj - Object to get value from * @param path - Path to value * @returns Value at path */ private getNestedValue(obj: any, path: string): any { const keys = path.split('.'); let value = obj; for (const key of keys) { if (value === null || value === undefined) { return undefined; } value = value[key]; } return value; } /** * Log message * * @param message - Message to log * @param context - Log context */ private log(message: string, context?: any): void { if (this.options.logging) { if (context) { console.log(`[MockProvider] ${message}`, context); } else { console.log(`[MockProvider] ${message}`); } } } }