@sailboat-computer/data-storage
Version:
Shared data storage library for sailboat computer v3
775 lines (664 loc) • 19.3 kB
text/typescript
/**
* 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}`);
}
}
}
}