From e09c78a76a4c7cdb54e39cb4bb2abe50fe13a8dc Mon Sep 17 00:00:00 2001 From: David Jones Date: Fri, 18 Jul 2025 06:37:58 -0400 Subject: [PATCH] WIP: Commit current plugin-state progress before merge --- backend/app/api/v1/api.py | 3 +- backend/app/api/v1/endpoints/plugin_state.py | 561 +++++++++++++ .../initializers/settings_initializer.py | 4 +- backend/app/models/__init__.py | 1 + backend/app/models/plugin_state.py | 110 +++ backend/app/models/relationships.py | 2 + backend/app/schemas/plugin_state.py | 166 ++++ .../7d0185f79500_add_plugin_states_table.py | 627 ++++++++++++++ ...5bbe8b720_mark_settings_tables_restored.py | 65 ++ frontend/src/App.tsx | 8 + .../src/components/PluginModuleRenderer.tsx | 26 +- .../services/DatabasePersistenceManager.ts | 451 +++++++++++ frontend/src/services/PageContextService.ts | 378 ++++++++- frontend/src/services/PluginStateFactory.ts | 89 ++ .../services/PluginStateLifecycleManager.ts | 430 ++++++++++ frontend/src/services/PluginStateService.ts | 341 ++++++++ .../src/services/SessionStorageManager.ts | 484 +++++++++++ .../src/services/StateConfigurationManager.ts | 470 +++++++++++ .../src/services/StateRestorationManager.ts | 556 +++++++++++++ .../src/services/StateSerializationUtils.ts | 419 ++++++++++ .../test/BrainDriveBasicAIChatStateTest.tsx | 579 +++++++++++++ frontend/src/test/EnhancedPluginStateTest.tsx | 694 ++++++++++++++++ frontend/src/test/Phase4PluginStateTest.tsx | 762 ++++++++++++++++++ frontend/src/test/PluginStateTest.tsx | 318 ++++++++ frontend/src/types/index.ts | 84 ++ frontend/src/utils/serviceBridge.ts | 330 ++++++-- 26 files changed, 7883 insertions(+), 75 deletions(-) create mode 100644 backend/app/api/v1/endpoints/plugin_state.py create mode 100644 backend/app/models/plugin_state.py create mode 100644 backend/app/schemas/plugin_state.py create mode 100644 backend/migrations/versions/7d0185f79500_add_plugin_states_table.py create mode 100644 backend/migrations/versions/cb95bbe8b720_mark_settings_tables_restored.py create mode 100644 frontend/src/services/DatabasePersistenceManager.ts create mode 100644 frontend/src/services/PluginStateFactory.ts create mode 100644 frontend/src/services/PluginStateLifecycleManager.ts create mode 100644 frontend/src/services/PluginStateService.ts create mode 100644 frontend/src/services/SessionStorageManager.ts create mode 100644 frontend/src/services/StateConfigurationManager.ts create mode 100644 frontend/src/services/StateRestorationManager.ts create mode 100644 frontend/src/services/StateSerializationUtils.ts create mode 100644 frontend/src/test/BrainDriveBasicAIChatStateTest.tsx create mode 100644 frontend/src/test/EnhancedPluginStateTest.tsx create mode 100644 frontend/src/test/Phase4PluginStateTest.tsx create mode 100644 frontend/src/test/PluginStateTest.tsx diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 07f72ea..8591d07 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, settings, ollama, ai_providers, ai_provider_settings, navigation_routes, components, conversations, tags, personas +from app.api.v1.endpoints import auth, settings, ollama, ai_providers, ai_provider_settings, navigation_routes, components, conversations, tags, personas, plugin_state from app.routers import plugins from app.routes.pages import router as pages_router @@ -14,6 +14,7 @@ api_router.include_router(conversations.router, tags=["conversations"]) api_router.include_router(tags.router, tags=["tags"]) api_router.include_router(personas.router, tags=["personas"]) +api_router.include_router(plugin_state.router, tags=["plugin-state"]) # Include the plugins router (which already includes the lifecycle router) api_router.include_router(plugins.router, tags=["plugins"]) api_router.include_router(pages_router) diff --git a/backend/app/api/v1/endpoints/plugin_state.py b/backend/app/api/v1/endpoints/plugin_state.py new file mode 100644 index 0000000..c3cc187 --- /dev/null +++ b/backend/app/api/v1/endpoints/plugin_state.py @@ -0,0 +1,561 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, BackgroundTasks +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete, update, func, and_, or_ +from typing import List, Optional, Dict, Any +import json +import gzip +import base64 +from datetime import datetime, timedelta +import logging + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User +from app.models.plugin_state import PluginState, PluginStateHistory, PluginStateConfig +from app.schemas.plugin_state import ( + PluginStateCreate, + PluginStateUpdate, + PluginStateResponse, + PluginStateBulkCreate, + PluginStateBulkResponse, + PluginStateHistoryResponse, + PluginStateConfigCreate, + PluginStateConfigUpdate, + PluginStateConfigResponse, + PluginStateQuery, + PluginStateStats, + PluginStateSyncRequest, + PluginStateSyncResponse, + PluginStateConflictResolution, + PluginStateMigrationRequest, + PluginStateMigrationResponse, + StateStrategy, + SyncStatus, + ChangeType +) + +router = APIRouter(prefix="/plugin-state") +logger = logging.getLogger(__name__) + +# Utility functions +def compress_state_data(data: Dict[Any, Any]) -> tuple[str, str]: + """Compress state data if it's large enough.""" + json_str = json.dumps(data) + if len(json_str) > 1024: # Compress if larger than 1KB + compressed = gzip.compress(json_str.encode('utf-8')) + encoded = base64.b64encode(compressed).decode('utf-8') + return encoded, "gzip" + return json_str, None + +def decompress_state_data(data: str, compression_type: Optional[str]) -> Dict[Any, Any]: + """Decompress state data if compressed.""" + if compression_type == "gzip": + decoded = base64.b64decode(data.encode('utf-8')) + decompressed = gzip.decompress(decoded).decode('utf-8') + return json.loads(decompressed) + return json.loads(data) + +async def create_state_history( + db: AsyncSession, + plugin_state_id: str, + state_data: Dict[Any, Any], + version: int, + change_type: ChangeType, + device_id: Optional[str] = None, + request: Optional[Request] = None +): + """Create a history record for state changes.""" + history = PluginStateHistory( + plugin_state_id=plugin_state_id, + state_data=json.dumps(state_data), + version=version, + change_type=change_type.value, + device_id=device_id, + user_agent=request.headers.get("user-agent") if request else None, + ip_address=request.client.host if request else None + ) + db.add(history) + await db.flush() + return history + +# Plugin State CRUD endpoints +@router.post("/", response_model=PluginStateResponse) +async def create_plugin_state( + state_create: PluginStateCreate, + background_tasks: BackgroundTasks, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Create a new plugin state record.""" + try: + # Check if state already exists + existing_query = select(PluginState).where( + and_( + PluginState.user_id == current_user.id, + PluginState.plugin_id == state_create.plugin_id, + PluginState.page_id == state_create.page_id, + PluginState.state_key == state_create.state_key + ) + ) + result = await db.execute(existing_query) + existing_state = result.scalar_one_or_none() + + if existing_state: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Plugin state already exists. Use PUT to update." + ) + + # Compress state data if needed + compressed_data, compression_type = compress_state_data(state_create.state_data) + + # Create new state + plugin_state = PluginState( + user_id=current_user.id, + plugin_id=state_create.plugin_id, + page_id=state_create.page_id, + state_key=state_create.state_key, + state_data=compressed_data, + state_schema_version=state_create.state_schema_version, + state_strategy=state_create.state_strategy.value, + compression_type=compression_type, + state_size=len(compressed_data), + device_id=state_create.device_id, + ttl_expires_at=state_create.ttl_expires_at, + version=1, + sync_status=SyncStatus.SYNCED.value + ) + + db.add(plugin_state) + await db.flush() + + # Create history record + background_tasks.add_task( + create_state_history, + db, plugin_state.id, state_create.state_data, 1, ChangeType.CREATE, + state_create.device_id, request + ) + + await db.commit() + + # Return response with decompressed data + response_data = plugin_state.__dict__.copy() + response_data['state_data'] = state_create.state_data + + return PluginStateResponse(**response_data) + + except Exception as e: + await db.rollback() + logger.error(f"Error creating plugin state: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create plugin state: {str(e)}" + ) + +@router.get("/", response_model=List[PluginStateResponse]) +async def get_plugin_states( + query: PluginStateQuery = Depends(), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get plugin states for the current user with filtering.""" + try: + # Build query + stmt = select(PluginState).where(PluginState.user_id == current_user.id) + + # Apply filters + if query.plugin_id: + stmt = stmt.where(PluginState.plugin_id == query.plugin_id) + if query.page_id: + stmt = stmt.where(PluginState.page_id == query.page_id) + if query.state_key: + stmt = stmt.where(PluginState.state_key == query.state_key) + if query.state_strategy: + stmt = stmt.where(PluginState.state_strategy == query.state_strategy.value) + if query.sync_status: + stmt = stmt.where(PluginState.sync_status == query.sync_status.value) + if query.is_active is not None: + stmt = stmt.where(PluginState.is_active == query.is_active) + if query.device_id: + stmt = stmt.where(PluginState.device_id == query.device_id) + + # Apply pagination + stmt = stmt.offset(query.offset).limit(query.limit) + stmt = stmt.order_by(PluginState.last_accessed.desc()) + + result = await db.execute(stmt) + states = result.scalars().all() + + # Decompress state data for response + response_states = [] + for state in states: + state_dict = state.__dict__.copy() + state_dict['state_data'] = decompress_state_data( + state.state_data, state.compression_type + ) + response_states.append(PluginStateResponse(**state_dict)) + + return response_states + + except Exception as e: + logger.error(f"Error getting plugin states: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get plugin states: {str(e)}" + ) + +@router.get("/{state_id}", response_model=PluginStateResponse) +async def get_plugin_state( + state_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get a specific plugin state by ID.""" + try: + stmt = select(PluginState).where( + and_( + PluginState.id == state_id, + PluginState.user_id == current_user.id + ) + ) + result = await db.execute(stmt) + state = result.scalar_one_or_none() + + if not state: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Plugin state not found" + ) + + # Update access tracking + state.last_accessed = datetime.utcnow() + state.access_count += 1 + await db.commit() + + # Return response with decompressed data + state_dict = state.__dict__.copy() + state_dict['state_data'] = decompress_state_data( + state.state_data, state.compression_type + ) + + return PluginStateResponse(**state_dict) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting plugin state {state_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get plugin state: {str(e)}" + ) + +@router.put("/{state_id}", response_model=PluginStateResponse) +async def update_plugin_state( + state_id: str, + state_update: PluginStateUpdate, + background_tasks: BackgroundTasks, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Update a plugin state record.""" + try: + stmt = select(PluginState).where( + and_( + PluginState.id == state_id, + PluginState.user_id == current_user.id + ) + ) + result = await db.execute(stmt) + state = result.scalar_one_or_none() + + if not state: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Plugin state not found" + ) + + # Store old data for history + old_data = decompress_state_data(state.state_data, state.compression_type) + + # Update fields + if state_update.state_data is not None: + compressed_data, compression_type = compress_state_data(state_update.state_data) + state.state_data = compressed_data + state.compression_type = compression_type + state.state_size = len(compressed_data) + + if state_update.state_strategy is not None: + state.state_strategy = state_update.state_strategy.value + if state_update.ttl_expires_at is not None: + state.ttl_expires_at = state_update.ttl_expires_at + if state_update.device_id is not None: + state.device_id = state_update.device_id + if state_update.state_schema_version is not None: + state.state_schema_version = state_update.state_schema_version + + # Update version and sync status + state.version += 1 + state.sync_status = SyncStatus.SYNCED.value + state.last_accessed = datetime.utcnow() + state.access_count += 1 + + # Create history record + background_tasks.add_task( + create_state_history, + db, state.id, state_update.state_data or old_data, state.version, + ChangeType.UPDATE, state_update.device_id, request + ) + + await db.commit() + + # Return response with decompressed data + state_dict = state.__dict__.copy() + state_dict['state_data'] = state_update.state_data or old_data + + return PluginStateResponse(**state_dict) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + logger.error(f"Error updating plugin state {state_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update plugin state: {str(e)}" + ) + +@router.delete("/{state_id}") +async def delete_plugin_state( + state_id: str, + background_tasks: BackgroundTasks, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Delete a plugin state record.""" + try: + stmt = select(PluginState).where( + and_( + PluginState.id == state_id, + PluginState.user_id == current_user.id + ) + ) + result = await db.execute(stmt) + state = result.scalar_one_or_none() + + if not state: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Plugin state not found" + ) + + # Store data for history before deletion + state_data = decompress_state_data(state.state_data, state.compression_type) + + # Create history record + background_tasks.add_task( + create_state_history, + db, state.id, state_data, state.version, ChangeType.DELETE, + state.device_id, request + ) + + # Delete the state + await db.delete(state) + await db.commit() + + return JSONResponse( + status_code=status.HTTP_200_OK, + content={"message": "Plugin state deleted successfully"} + ) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + logger.error(f"Error deleting plugin state {state_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete plugin state: {str(e)}" + ) + +# Bulk operations +@router.post("/bulk", response_model=PluginStateBulkResponse) +async def create_plugin_states_bulk( + bulk_create: PluginStateBulkCreate, + background_tasks: BackgroundTasks, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Create multiple plugin states in bulk.""" + created_states = [] + errors = [] + + try: + for i, state_create in enumerate(bulk_create.states): + try: + # Check if state already exists + existing_query = select(PluginState).where( + and_( + PluginState.user_id == current_user.id, + PluginState.plugin_id == state_create.plugin_id, + PluginState.page_id == state_create.page_id, + PluginState.state_key == state_create.state_key + ) + ) + result = await db.execute(existing_query) + existing_state = result.scalar_one_or_none() + + if existing_state: + errors.append({ + "index": i, + "plugin_id": state_create.plugin_id, + "error": "State already exists" + }) + continue + + # Compress state data + compressed_data, compression_type = compress_state_data(state_create.state_data) + + # Create state + plugin_state = PluginState( + user_id=current_user.id, + plugin_id=state_create.plugin_id, + page_id=state_create.page_id, + state_key=state_create.state_key, + state_data=compressed_data, + state_schema_version=state_create.state_schema_version, + state_strategy=state_create.state_strategy.value, + compression_type=compression_type, + state_size=len(compressed_data), + device_id=state_create.device_id, + ttl_expires_at=state_create.ttl_expires_at, + version=1, + sync_status=SyncStatus.SYNCED.value + ) + + db.add(plugin_state) + await db.flush() + + # Create history record + background_tasks.add_task( + create_state_history, + db, plugin_state.id, state_create.state_data, 1, ChangeType.CREATE, + state_create.device_id, request + ) + + # Add to created list + response_data = plugin_state.__dict__.copy() + response_data['state_data'] = state_create.state_data + created_states.append(PluginStateResponse(**response_data)) + + except Exception as e: + errors.append({ + "index": i, + "plugin_id": state_create.plugin_id, + "error": str(e) + }) + + await db.commit() + + return PluginStateBulkResponse(created=created_states, errors=errors) + + except Exception as e: + await db.rollback() + logger.error(f"Error in bulk create: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create plugin states: {str(e)}" + ) + +# Statistics endpoint +@router.get("/stats", response_model=PluginStateStats) +async def get_plugin_state_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Get plugin state statistics for the current user.""" + try: + # Get basic counts + total_query = select(func.count(PluginState.id)).where(PluginState.user_id == current_user.id) + active_query = select(func.count(PluginState.id)).where( + and_(PluginState.user_id == current_user.id, PluginState.is_active == True) + ) + size_query = select(func.sum(PluginState.state_size)).where(PluginState.user_id == current_user.id) + plugins_query = select(func.count(func.distinct(PluginState.plugin_id))).where( + PluginState.user_id == current_user.id + ) + last_activity_query = select(func.max(PluginState.last_accessed)).where( + PluginState.user_id == current_user.id + ) + + total_result = await db.execute(total_query) + active_result = await db.execute(active_query) + size_result = await db.execute(size_query) + plugins_result = await db.execute(plugins_query) + last_activity_result = await db.execute(last_activity_query) + + total_states = total_result.scalar() or 0 + active_states = active_result.scalar() or 0 + total_size = size_result.scalar() or 0 + plugins_with_state = plugins_result.scalar() or 0 + last_activity = last_activity_result.scalar() + + average_state_size = total_size / total_states if total_states > 0 else 0 + + return PluginStateStats( + total_states=total_states, + active_states=active_states, + total_size=total_size, + plugins_with_state=plugins_with_state, + average_state_size=average_state_size, + last_activity=last_activity + ) + + except Exception as e: + logger.error(f"Error getting plugin state stats: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get plugin state stats: {str(e)}" + ) + +# Cleanup endpoint +@router.delete("/cleanup") +async def cleanup_expired_states( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Clean up expired plugin states.""" + try: + # Delete expired states + now = datetime.utcnow() + delete_query = delete(PluginState).where( + and_( + PluginState.user_id == current_user.id, + PluginState.ttl_expires_at < now + ) + ) + + result = await db.execute(delete_query) + deleted_count = result.rowcount + + await db.commit() + + return JSONResponse( + status_code=status.HTTP_200_OK, + content={ + "message": f"Cleaned up {deleted_count} expired plugin states", + "deleted_count": deleted_count + } + ) + + except Exception as e: + await db.rollback() + logger.error(f"Error cleaning up expired states: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to cleanup expired states: {str(e)}" + ) \ No newline at end of file diff --git a/backend/app/core/user_initializer/initializers/settings_initializer.py b/backend/app/core/user_initializer/initializers/settings_initializer.py index 0612401..a5bcbf5 100644 --- a/backend/app/core/user_initializer/initializers/settings_initializer.py +++ b/backend/app/core/user_initializer/initializers/settings_initializer.py @@ -36,7 +36,7 @@ class SettingsInitializer(UserInitializerBase): "description": "Auto-generated definition for Theme Settings", "category": "auto_generated", "type": "object", - "default_value": '{"theme": "light", "useSystemTheme": false}', + "default_value": '{"theme": "dark", "useSystemTheme": false}', "allowed_scopes": '["system", "user", "page", "user_page"]', "validation": None, "is_multiple": False, @@ -73,7 +73,7 @@ class SettingsInitializer(UserInitializerBase): { "definition_id": "theme_settings", "name": "Theme Settings", - "value": '{"theme": "light", "useSystemTheme": false}', + "value": '{"theme": "dark", "useSystemTheme": false}', "scope": "user", "page_id": None }, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e8d8749..5f268fe 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,7 @@ from app.models.tag import Tag from app.models.component import Component from app.models.plugin import Plugin +from app.models.plugin_state import PluginState, PluginStateHistory, PluginStateConfig from app.models.persona import Persona from app.models.settings import SettingDefinition, SettingInstance from app.models.message import Message diff --git a/backend/app/models/plugin_state.py b/backend/app/models/plugin_state.py new file mode 100644 index 0000000..d9eddf5 --- /dev/null +++ b/backend/app/models/plugin_state.py @@ -0,0 +1,110 @@ +from sqlalchemy import Column, String, Text, Boolean, ForeignKey, TIMESTAMP, Integer, Index +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from sqlalchemy.dialects.postgresql import UUID +import uuid + +from app.models.base import Base +from app.models.mixins import TimestampMixin + +class PluginState(Base, TimestampMixin): + """SQLAlchemy model for plugin state persistence across devices.""" + + __tablename__ = "plugin_states" + + # Primary key + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True) + + # Foreign keys + user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + plugin_id = Column(String(255), nullable=False, index=True) + + # State identification + page_id = Column(String(255), nullable=True, index=True) # Optional: state can be page-specific + state_key = Column(String(255), nullable=True, index=True) # Optional: for namespaced state + + # State data + state_data = Column(Text, nullable=False) # JSON serialized state + state_schema_version = Column(String(50), nullable=True) # For migration support + + # Metadata + state_strategy = Column(String(50), default="persistent") # none, session, persistent, custom + compression_type = Column(String(20), nullable=True) # gzip, lz4, etc. + state_size = Column(Integer, default=0) # Size in bytes for monitoring + + # Lifecycle management + last_accessed = Column(TIMESTAMP, default=func.now(), onupdate=func.now()) + access_count = Column(Integer, default=0) + ttl_expires_at = Column(TIMESTAMP, nullable=True) # Time-to-live expiration + is_active = Column(Boolean, default=True) + + # Sync and versioning + version = Column(Integer, default=1) # For conflict resolution + device_id = Column(String(255), nullable=True) # Device that last updated + sync_status = Column(String(50), default="synced") # synced, pending, conflict + + # Relationships + user = relationship("User", back_populates="plugin_states") + + # Indexes for performance + __table_args__ = ( + Index('idx_plugin_state_user_plugin', 'user_id', 'plugin_id'), + Index('idx_plugin_state_user_plugin_page', 'user_id', 'plugin_id', 'page_id'), + Index('idx_plugin_state_last_accessed', 'last_accessed'), + Index('idx_plugin_state_ttl', 'ttl_expires_at'), + Index('idx_plugin_state_sync', 'sync_status'), + ) + +class PluginStateHistory(Base, TimestampMixin): + """SQLAlchemy model for plugin state change history and backup.""" + + __tablename__ = "plugin_state_history" + + # Primary key + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True) + + # Foreign key to plugin state + plugin_state_id = Column(String(36), ForeignKey("plugin_states.id", ondelete="CASCADE"), nullable=False, index=True) + + # Historical data + state_data = Column(Text, nullable=False) # JSON serialized historical state + version = Column(Integer, nullable=False) + change_type = Column(String(50), nullable=False) # create, update, delete, restore + + # Metadata + device_id = Column(String(255), nullable=True) + user_agent = Column(Text, nullable=True) + ip_address = Column(String(45), nullable=True) + + # Relationships + plugin_state = relationship("PluginState") + + # Indexes + __table_args__ = ( + Index('idx_plugin_state_history_state_id', 'plugin_state_id'), + Index('idx_plugin_state_history_version', 'plugin_state_id', 'version'), + ) + +class PluginStateConfig(Base, TimestampMixin): + """SQLAlchemy model for plugin state configuration.""" + + __tablename__ = "plugin_state_configs" + + # Primary key + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True) + + # Configuration identification + plugin_id = Column(String(255), nullable=False, unique=True, index=True) + + # Configuration data + config_data = Column(Text, nullable=False) # JSON serialized configuration + config_version = Column(String(50), default="1.0.0") + + # Metadata + is_active = Column(Boolean, default=True) + created_by = Column(String(36), nullable=True) # Admin who created config + + # Indexes + __table_args__ = ( + Index('idx_plugin_state_config_plugin', 'plugin_id'), + ) \ No newline at end of file diff --git a/backend/app/models/relationships.py b/backend/app/models/relationships.py index 84191b1..4037d05 100644 --- a/backend/app/models/relationships.py +++ b/backend/app/models/relationships.py @@ -14,6 +14,7 @@ from app.models.conversation import Conversation from app.models.tag import Tag from app.models.plugin import Plugin, Module +from app.models.plugin_state import PluginState, PluginStateHistory, PluginStateConfig from app.models.component import Component from app.models.persona import Persona @@ -26,6 +27,7 @@ User.modules = relationship("Module", back_populates="user", lazy="selectin") User.components = relationship("Component", back_populates="user", lazy="selectin") User.personas = relationship("Persona", back_populates="user", lazy="selectin") +User.plugin_states = relationship("PluginState", back_populates="user", lazy="selectin") # Define Page relationships Page.creator = relationship("User", back_populates="pages") diff --git a/backend/app/schemas/plugin_state.py b/backend/app/schemas/plugin_state.py new file mode 100644 index 0000000..5ae0749 --- /dev/null +++ b/backend/app/schemas/plugin_state.py @@ -0,0 +1,166 @@ +from pydantic import BaseModel, Field, validator +from typing import Optional, Dict, Any, List +from datetime import datetime +from enum import Enum + +class StateStrategy(str, Enum): + NONE = "none" + SESSION = "session" + PERSISTENT = "persistent" + CUSTOM = "custom" + +class SyncStatus(str, Enum): + SYNCED = "synced" + PENDING = "pending" + CONFLICT = "conflict" + +class ChangeType(str, Enum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + RESTORE = "restore" + +# Base schemas +class PluginStateBase(BaseModel): + plugin_id: str = Field(..., description="Plugin identifier") + page_id: Optional[str] = Field(None, description="Optional page-specific state") + state_key: Optional[str] = Field(None, description="Optional state namespace key") + state_strategy: StateStrategy = Field(StateStrategy.PERSISTENT, description="State persistence strategy") + ttl_expires_at: Optional[datetime] = Field(None, description="Time-to-live expiration") + +class PluginStateCreate(PluginStateBase): + state_data: Dict[Any, Any] = Field(..., description="State data to persist") + device_id: Optional[str] = Field(None, description="Device identifier") + state_schema_version: Optional[str] = Field(None, description="Schema version for migration") + +class PluginStateUpdate(BaseModel): + state_data: Optional[Dict[Any, Any]] = Field(None, description="Updated state data") + state_strategy: Optional[StateStrategy] = Field(None, description="Updated persistence strategy") + ttl_expires_at: Optional[datetime] = Field(None, description="Updated TTL expiration") + device_id: Optional[str] = Field(None, description="Device identifier") + state_schema_version: Optional[str] = Field(None, description="Updated schema version") + +class PluginStateResponse(PluginStateBase): + id: str = Field(..., description="State record ID") + user_id: str = Field(..., description="User ID") + state_data: Dict[Any, Any] = Field(..., description="State data") + state_schema_version: Optional[str] = Field(None, description="Schema version") + compression_type: Optional[str] = Field(None, description="Compression type used") + state_size: int = Field(0, description="State size in bytes") + last_accessed: datetime = Field(..., description="Last access timestamp") + access_count: int = Field(0, description="Access count") + is_active: bool = Field(True, description="Whether state is active") + version: int = Field(1, description="Version for conflict resolution") + device_id: Optional[str] = Field(None, description="Device that last updated") + sync_status: SyncStatus = Field(SyncStatus.SYNCED, description="Sync status") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + class Config: + from_attributes = True + +# Bulk operations +class PluginStateBulkCreate(BaseModel): + states: List[PluginStateCreate] = Field(..., description="List of states to create") + +class PluginStateBulkResponse(BaseModel): + created: List[PluginStateResponse] = Field(..., description="Successfully created states") + errors: List[Dict[str, Any]] = Field([], description="Errors during creation") + +# State history schemas +class PluginStateHistoryResponse(BaseModel): + id: str = Field(..., description="History record ID") + plugin_state_id: str = Field(..., description="Plugin state ID") + state_data: Dict[Any, Any] = Field(..., description="Historical state data") + version: int = Field(..., description="State version") + change_type: ChangeType = Field(..., description="Type of change") + device_id: Optional[str] = Field(None, description="Device identifier") + user_agent: Optional[str] = Field(None, description="User agent") + ip_address: Optional[str] = Field(None, description="IP address") + created_at: datetime = Field(..., description="Change timestamp") + + class Config: + from_attributes = True + +# State configuration schemas +class PluginStateConfigBase(BaseModel): + plugin_id: str = Field(..., description="Plugin identifier") + config_version: str = Field("1.0.0", description="Configuration version") + +class PluginStateConfigCreate(PluginStateConfigBase): + config_data: Dict[Any, Any] = Field(..., description="Configuration data") + created_by: Optional[str] = Field(None, description="Admin who created config") + +class PluginStateConfigUpdate(BaseModel): + config_data: Optional[Dict[Any, Any]] = Field(None, description="Updated configuration data") + config_version: Optional[str] = Field(None, description="Updated configuration version") + is_active: Optional[bool] = Field(None, description="Whether config is active") + +class PluginStateConfigResponse(PluginStateConfigBase): + id: str = Field(..., description="Config record ID") + config_data: Dict[Any, Any] = Field(..., description="Configuration data") + is_active: bool = Field(True, description="Whether config is active") + created_by: Optional[str] = Field(None, description="Admin who created config") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + class Config: + from_attributes = True + +# Query and filter schemas +class PluginStateQuery(BaseModel): + plugin_id: Optional[str] = Field(None, description="Filter by plugin ID") + page_id: Optional[str] = Field(None, description="Filter by page ID") + state_key: Optional[str] = Field(None, description="Filter by state key") + state_strategy: Optional[StateStrategy] = Field(None, description="Filter by strategy") + sync_status: Optional[SyncStatus] = Field(None, description="Filter by sync status") + is_active: Optional[bool] = Field(None, description="Filter by active status") + device_id: Optional[str] = Field(None, description="Filter by device ID") + limit: int = Field(100, ge=1, le=1000, description="Maximum number of results") + offset: int = Field(0, ge=0, description="Number of results to skip") + +class PluginStateStats(BaseModel): + total_states: int = Field(..., description="Total number of states") + active_states: int = Field(..., description="Number of active states") + total_size: int = Field(..., description="Total size in bytes") + plugins_with_state: int = Field(..., description="Number of plugins with state") + average_state_size: float = Field(..., description="Average state size") + last_activity: Optional[datetime] = Field(None, description="Last state activity") + +# Sync and conflict resolution schemas +class PluginStateSyncRequest(BaseModel): + device_id: str = Field(..., description="Device identifier") + states: List[PluginStateCreate] = Field(..., description="States to sync") + force_overwrite: bool = Field(False, description="Force overwrite conflicts") + +class PluginStateSyncResponse(BaseModel): + synced: List[PluginStateResponse] = Field(..., description="Successfully synced states") + conflicts: List[Dict[str, Any]] = Field([], description="Conflicted states") + errors: List[Dict[str, Any]] = Field([], description="Sync errors") + +class PluginStateConflict(BaseModel): + state_id: str = Field(..., description="Conflicted state ID") + local_version: int = Field(..., description="Local version") + remote_version: int = Field(..., description="Remote version") + local_data: Dict[Any, Any] = Field(..., description="Local state data") + remote_data: Dict[Any, Any] = Field(..., description="Remote state data") + last_modified_local: datetime = Field(..., description="Local last modified") + last_modified_remote: datetime = Field(..., description="Remote last modified") + +class PluginStateConflictResolution(BaseModel): + state_id: str = Field(..., description="State ID to resolve") + resolution: str = Field(..., description="Resolution strategy: 'local', 'remote', 'merge'") + merged_data: Optional[Dict[Any, Any]] = Field(None, description="Merged data if resolution is 'merge'") + +# Migration schemas +class PluginStateMigrationRequest(BaseModel): + from_version: str = Field(..., description="Source schema version") + to_version: str = Field(..., description="Target schema version") + plugin_ids: Optional[List[str]] = Field(None, description="Specific plugins to migrate") + dry_run: bool = Field(True, description="Whether to perform a dry run") + +class PluginStateMigrationResponse(BaseModel): + migrated_count: int = Field(..., description="Number of states migrated") + failed_count: int = Field(..., description="Number of failed migrations") + errors: List[Dict[str, Any]] = Field([], description="Migration errors") + dry_run: bool = Field(..., description="Whether this was a dry run") \ No newline at end of file diff --git a/backend/migrations/versions/7d0185f79500_add_plugin_states_table.py b/backend/migrations/versions/7d0185f79500_add_plugin_states_table.py new file mode 100644 index 0000000..1c9f8f9 --- /dev/null +++ b/backend/migrations/versions/7d0185f79500_add_plugin_states_table.py @@ -0,0 +1,627 @@ +"""add_plugin_states_table + +Revision ID: 7d0185f79500 +Revises: 49d16cc7f8f1 +Create Date: 2025-07-15 15:44:59.615804 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision: str = '7d0185f79500' +down_revision: Union[str, None] = '49d16cc7f8f1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('plugin_state_configs', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('plugin_id', sa.String(length=255), nullable=False), + sa.Column('config_data', sa.Text(), nullable=False), + sa.Column('config_version', sa.String(length=50), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_by', sa.String(length=36), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('plugin_state_configs', schema=None) as batch_op: + batch_op.create_index('idx_plugin_state_config_plugin', ['plugin_id'], unique=False) + batch_op.create_index(batch_op.f('ix_plugin_state_configs_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_plugin_state_configs_plugin_id'), ['plugin_id'], unique=True) + + op.create_table('plugin_states', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('plugin_id', sa.String(length=255), nullable=False), + sa.Column('page_id', sa.String(length=255), nullable=True), + sa.Column('state_key', sa.String(length=255), nullable=True), + sa.Column('state_data', sa.Text(), nullable=False), + sa.Column('state_schema_version', sa.String(length=50), nullable=True), + sa.Column('state_strategy', sa.String(length=50), nullable=True), + sa.Column('compression_type', sa.String(length=20), nullable=True), + sa.Column('state_size', sa.Integer(), nullable=True), + sa.Column('last_accessed', sa.TIMESTAMP(), nullable=True), + sa.Column('access_count', sa.Integer(), nullable=True), + sa.Column('ttl_expires_at', sa.TIMESTAMP(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('version', sa.Integer(), nullable=True), + sa.Column('device_id', sa.String(length=255), nullable=True), + sa.Column('sync_status', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('plugin_states', schema=None) as batch_op: + batch_op.create_index('idx_plugin_state_last_accessed', ['last_accessed'], unique=False) + batch_op.create_index('idx_plugin_state_sync', ['sync_status'], unique=False) + batch_op.create_index('idx_plugin_state_ttl', ['ttl_expires_at'], unique=False) + batch_op.create_index('idx_plugin_state_user_plugin', ['user_id', 'plugin_id'], unique=False) + batch_op.create_index('idx_plugin_state_user_plugin_page', ['user_id', 'plugin_id', 'page_id'], unique=False) + batch_op.create_index(batch_op.f('ix_plugin_states_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_plugin_states_page_id'), ['page_id'], unique=False) + batch_op.create_index(batch_op.f('ix_plugin_states_plugin_id'), ['plugin_id'], unique=False) + batch_op.create_index(batch_op.f('ix_plugin_states_state_key'), ['state_key'], unique=False) + batch_op.create_index(batch_op.f('ix_plugin_states_user_id'), ['user_id'], unique=False) + + op.create_table('plugin_state_history', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('plugin_state_id', sa.String(length=36), nullable=False), + sa.Column('state_data', sa.Text(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('change_type', sa.String(length=50), nullable=False), + sa.Column('device_id', sa.String(length=255), nullable=True), + sa.Column('user_agent', sa.Text(), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.ForeignKeyConstraint(['plugin_state_id'], ['plugin_states.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('plugin_state_history', schema=None) as batch_op: + batch_op.create_index('idx_plugin_state_history_state_id', ['plugin_state_id'], unique=False) + batch_op.create_index('idx_plugin_state_history_version', ['plugin_state_id', 'version'], unique=False) + batch_op.create_index(batch_op.f('ix_plugin_state_history_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_plugin_state_history_plugin_state_id'), ['plugin_state_id'], unique=False) + + op.drop_table('settings_definitions') + with op.batch_alter_table('settings_instances', schema=None) as batch_op: + batch_op.drop_index('ix_settings_instances_definition_id') + batch_op.drop_index('ix_settings_instances_page_id') + batch_op.drop_index('ix_settings_instances_user_id') + + op.drop_table('settings_instances') + op.drop_table('pages_temp') + with op.batch_alter_table('components', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('updated_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + batch_op.drop_index('ix_components_user_id') + batch_op.create_unique_constraint('components_component_id_user_id_key', ['component_id', 'user_id']) + batch_op.create_foreign_key('fk_components_user_id', 'users', ['user_id'], ['id']) + + with op.batch_alter_table('conversation_tags', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(), + nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.drop_index('ix_conversation_tags_conversation_id') + batch_op.drop_index('ix_conversation_tags_tag_id') + batch_op.create_foreign_key('fk_conversation_tags_tag_id', 'tags', ['tag_id'], ['id'], ondelete='CASCADE') + batch_op.create_foreign_key('fk_conversation_tags_conversation_id', 'conversations', ['conversation_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('conversations', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('updated_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + batch_op.drop_index('idx_conversations_page_id') + batch_op.drop_index('idx_conversations_user_page') + batch_op.drop_index('ix_conversations_persona_id') + batch_op.drop_index('ix_conversations_user_id') + batch_op.create_foreign_key('fk_conversations_user_id', 'users', ['user_id'], ['id']) + + with op.batch_alter_table('messages', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('updated_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + batch_op.drop_index('ix_messages_conversation_id') + batch_op.create_foreign_key('fk_messages_conversation_id', 'conversations', ['conversation_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('module', schema=None) as batch_op: + batch_op.drop_index('ix_module_plugin_id') + batch_op.drop_index('ix_module_user_id') + batch_op.create_index(batch_op.f('ix_module_id'), ['id'], unique=False) + batch_op.create_foreign_key('fk_module_user_id', 'users', ['user_id'], ['id']) + batch_op.create_foreign_key('fk_module_plugin_id', 'plugin', ['plugin_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('navigation_routes', schema=None) as batch_op: + batch_op.alter_column('id', + existing_type=sa.VARCHAR(length=36), + type_=sa.String(length=32), + nullable=False) + batch_op.alter_column('route', + existing_type=sa.VARCHAR(length=100), + type_=sa.String(length=255), + existing_nullable=False) + batch_op.alter_column('creator_id', + existing_type=sa.VARCHAR(length=36), + type_=sa.String(length=32), + existing_nullable=False) + batch_op.alter_column('default_page_id', + existing_type=sa.VARCHAR(length=36), + type_=sa.String(length=32), + existing_nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('updated_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.create_unique_constraint('navigation_routes_route_creator_id_key', ['route', 'creator_id']) + batch_op.create_foreign_key('fk_navigation_routes_default_page_id', 'pages', ['default_page_id'], ['id'], ondelete='SET NULL') + + with op.batch_alter_table('oauth_accounts', schema=None) as batch_op: + batch_op.alter_column('user_id', + existing_type=sa.VARCHAR(length=32), + nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('updated_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + batch_op.drop_index('ix_oauth_accounts_user_id') + batch_op.create_foreign_key('fk_oauth_accounts_user_id', 'users', ['user_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('pages', schema=None) as batch_op: + batch_op.create_unique_constraint('pages_route_creator_id_key', ['route', 'creator_id']) + + with op.batch_alter_table('personas', schema=None) as batch_op: + batch_op.drop_index('ix_personas_id') + + with op.batch_alter_table('plugin', schema=None) as batch_op: + batch_op.alter_column('update_available', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text("'0'")) + batch_op.alter_column('installation_type', + existing_type=sa.VARCHAR(length=20), + nullable=True, + existing_server_default=sa.text("'local'")) + batch_op.drop_index('ix_plugin_user_id') + batch_op.create_index(batch_op.f('ix_plugin_id'), ['id'], unique=False) + batch_op.create_unique_constraint('unique_plugin_per_user', ['user_id', 'plugin_slug']) + batch_op.create_foreign_key('fk_plugin_user_id', 'users', ['user_id'], ['id']) + + with op.batch_alter_table('role', schema=None) as batch_op: + batch_op.drop_index('ix_role_role_name') + batch_op.create_unique_constraint(None, ['role_name']) + + with op.batch_alter_table('role_permissions', schema=None) as batch_op: + batch_op.alter_column('role_id', + existing_type=sa.VARCHAR(length=32), + nullable=True) + batch_op.drop_index('ix_role_permissions_role_id') + batch_op.create_unique_constraint('uq_role_permission', ['role_id', 'permission_name']) + batch_op.create_foreign_key('fk_role_permissions_role_id', 'user_roles', ['role_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('sessions', schema=None) as batch_op: + batch_op.alter_column('user_id', + existing_type=sa.VARCHAR(length=32), + nullable=True) + batch_op.alter_column('expires_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('updated_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + batch_op.drop_index('ix_sessions_active_tenant_id') + batch_op.drop_index('ix_sessions_user_id') + batch_op.create_unique_constraint('uq_sessions_session_token', ['session_token']) + batch_op.create_foreign_key('fk_sessions_active_tenant_id', 'tenants', ['active_tenant_id'], ['id'], ondelete='SET NULL') + batch_op.create_foreign_key('fk_sessions_user_id', 'users', ['user_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('tags', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('updated_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + batch_op.drop_index('ix_tags_user_id') + batch_op.create_foreign_key('fk_tags_user_id', 'users', ['user_id'], ['id']) + + with op.batch_alter_table('tenant_users', schema=None) as batch_op: + batch_op.alter_column('tenant_id', + existing_type=sa.VARCHAR(length=32), + nullable=True) + batch_op.alter_column('user_id', + existing_type=sa.VARCHAR(length=32), + nullable=True) + batch_op.alter_column('role_id', + existing_type=sa.VARCHAR(length=32), + nullable=True) + batch_op.drop_index('ix_tenant_users_role_id') + batch_op.drop_index('ix_tenant_users_tenant_id') + batch_op.drop_index('ix_tenant_users_user_id') + batch_op.create_unique_constraint('uq_tenant_user', ['tenant_id', 'user_id']) + batch_op.create_foreign_key('fk_tenant_users_role_id', 'user_roles', ['role_id'], ['id'], ondelete='CASCADE') + batch_op.create_foreign_key('fk_tenant_users_tenant_id', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE') + batch_op.create_foreign_key('fk_tenant_users_user_id', 'users', ['user_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('tenants', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('updated_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + batch_op.create_unique_constraint(None, ['sso_domain']) + batch_op.create_unique_constraint(None, ['name']) + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.create_unique_constraint(None, ['role_name']) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('updated_at', + existing_type=sa.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + + with op.batch_alter_table('user_roles', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + + with op.batch_alter_table('tenants', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + batch_op.drop_constraint(None, type_='unique') + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + + with op.batch_alter_table('tenant_users', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint('uq_tenant_user', type_='unique') + batch_op.create_index('ix_tenant_users_user_id', ['user_id'], unique=False) + batch_op.create_index('ix_tenant_users_tenant_id', ['tenant_id'], unique=False) + batch_op.create_index('ix_tenant_users_role_id', ['role_id'], unique=False) + batch_op.alter_column('role_id', + existing_type=sa.VARCHAR(length=32), + nullable=False) + batch_op.alter_column('user_id', + existing_type=sa.VARCHAR(length=32), + nullable=False) + batch_op.alter_column('tenant_id', + existing_type=sa.VARCHAR(length=32), + nullable=False) + + with op.batch_alter_table('tags', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_index('ix_tags_user_id', ['user_id'], unique=False) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + + with op.batch_alter_table('sessions', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='unique') + batch_op.create_index('ix_sessions_user_id', ['user_id'], unique=False) + batch_op.create_index('ix_sessions_active_tenant_id', ['active_tenant_id'], unique=False) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('expires_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=False) + batch_op.alter_column('user_id', + existing_type=sa.VARCHAR(length=32), + nullable=False) + + with op.batch_alter_table('role_permissions', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint('uq_role_permission', type_='unique') + batch_op.create_index('ix_role_permissions_role_id', ['role_id'], unique=False) + batch_op.alter_column('role_id', + existing_type=sa.VARCHAR(length=32), + nullable=False) + + with op.batch_alter_table('role', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + batch_op.create_index('ix_role_role_name', ['role_name'], unique=1) + + with op.batch_alter_table('plugin', schema=None) as batch_op: + batch_op.drop_constraint('fk_plugin_user_id', type_='foreignkey') + batch_op.drop_constraint('unique_plugin_per_user', type_='unique') + batch_op.drop_index(batch_op.f('ix_plugin_id')) + batch_op.create_index('ix_plugin_user_id', ['user_id'], unique=False) + batch_op.alter_column('installation_type', + existing_type=sa.VARCHAR(length=20), + nullable=False, + existing_server_default=sa.text("'local'")) + batch_op.alter_column('update_available', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text("'0'")) + + with op.batch_alter_table('personas', schema=None) as batch_op: + batch_op.create_index('ix_personas_id', ['id'], unique=False) + + with op.batch_alter_table('pages', schema=None) as batch_op: + batch_op.drop_constraint('pages_route_creator_id_key', type_='unique') + + with op.batch_alter_table('oauth_accounts', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_index('ix_oauth_accounts_user_id', ['user_id'], unique=False) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('user_id', + existing_type=sa.VARCHAR(length=32), + nullable=False) + + with op.batch_alter_table('navigation_routes', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint('navigation_routes_route_creator_id_key', type_='unique') + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + batch_op.alter_column('default_page_id', + existing_type=sa.String(length=32), + type_=sa.VARCHAR(length=36), + existing_nullable=True) + batch_op.alter_column('creator_id', + existing_type=sa.String(length=32), + type_=sa.VARCHAR(length=36), + existing_nullable=False) + batch_op.alter_column('route', + existing_type=sa.String(length=255), + type_=sa.VARCHAR(length=100), + existing_nullable=False) + batch_op.alter_column('id', + existing_type=sa.String(length=32), + type_=sa.VARCHAR(length=36), + nullable=True) + + with op.batch_alter_table('module', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint('fk_module_user_id', type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_module_id')) + batch_op.create_index('ix_module_user_id', ['user_id'], unique=False) + batch_op.create_index('ix_module_plugin_id', ['plugin_id'], unique=False) + + with op.batch_alter_table('messages', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_index('ix_messages_conversation_id', ['conversation_id'], unique=False) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + + with op.batch_alter_table('conversations', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_index('ix_conversations_user_id', ['user_id'], unique=False) + batch_op.create_index('ix_conversations_persona_id', ['persona_id'], unique=False) + batch_op.create_index('idx_conversations_user_page', ['user_id', 'page_id'], unique=False) + batch_op.create_index('idx_conversations_page_id', ['page_id'], unique=False) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + + with op.batch_alter_table('conversation_tags', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_index('ix_conversation_tags_tag_id', ['tag_id'], unique=False) + batch_op.create_index('ix_conversation_tags_conversation_id', ['conversation_id'], unique=False) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(), + type_=sa.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + + with op.batch_alter_table('components', schema=None) as batch_op: + batch_op.drop_constraint('fk_components_user_id', type_='foreignkey') + batch_op.drop_constraint('components_component_id_user_id_key', type_='unique') + batch_op.create_index('ix_components_user_id', ['user_id'], unique=False) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + existing_nullable=True) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=sa.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('(CURRENT_TIMESTAMP)')) + + op.create_table('pages_temp', + sa.Column('id', sa.VARCHAR(), nullable=False), + sa.Column('name', sa.VARCHAR(length=100), nullable=False), + sa.Column('route', sa.VARCHAR(length=255), nullable=False), + sa.Column('parent_route', sa.VARCHAR(length=255), nullable=True), + sa.Column('content', sqlite.JSON(), nullable=False), + sa.Column('content_backup', sqlite.JSON(), nullable=True), + sa.Column('backup_date', sa.DATETIME(), nullable=True), + sa.Column('creator_id', sa.VARCHAR(), nullable=False), + sa.Column('navigation_route_id', sa.VARCHAR(), nullable=True), + sa.Column('is_published', sa.BOOLEAN(), nullable=True), + sa.Column('publish_date', sa.DATETIME(), nullable=True), + sa.Column('description', sa.TEXT(), nullable=True), + sa.Column('icon', sa.VARCHAR(length=50), nullable=True), + sa.Column('created_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('parent_type', sa.VARCHAR(length=50), nullable=True), + sa.Column('is_parent_page', sa.BOOLEAN(), nullable=True), + sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['navigation_route_id'], ['navigation_routes.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('settings_instances', + sa.Column('id', sa.VARCHAR(length=32), nullable=False), + sa.Column('definition_id', sa.VARCHAR(length=32), nullable=False), + sa.Column('name', sa.VARCHAR(), nullable=False), + sa.Column('value', sa.TEXT(), nullable=True), + sa.Column('scope', sa.VARCHAR(), nullable=False), + sa.Column('user_id', sa.VARCHAR(length=32), nullable=True), + sa.Column('page_id', sa.VARCHAR(length=32), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('settings_instances', schema=None) as batch_op: + batch_op.create_index('ix_settings_instances_user_id', ['user_id'], unique=False) + batch_op.create_index('ix_settings_instances_page_id', ['page_id'], unique=False) + batch_op.create_index('ix_settings_instances_definition_id', ['definition_id'], unique=False) + + op.create_table('settings_definitions', + sa.Column('id', sa.VARCHAR(length=32), nullable=False), + sa.Column('name', sa.VARCHAR(), nullable=False), + sa.Column('description', sa.VARCHAR(), nullable=True), + sa.Column('category', sa.VARCHAR(), nullable=False), + sa.Column('type', sa.VARCHAR(), nullable=False), + sa.Column('default_value', sa.TEXT(), nullable=True), + sa.Column('allowed_scopes', sa.TEXT(), nullable=False), + sa.Column('validation', sa.TEXT(), nullable=True), + sa.Column('is_multiple', sa.BOOLEAN(), server_default=sa.text('(FALSE)'), nullable=True), + sa.Column('tags', sa.TEXT(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('plugin_state_history', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_plugin_state_history_plugin_state_id')) + batch_op.drop_index(batch_op.f('ix_plugin_state_history_id')) + batch_op.drop_index('idx_plugin_state_history_version') + batch_op.drop_index('idx_plugin_state_history_state_id') + + op.drop_table('plugin_state_history') + with op.batch_alter_table('plugin_states', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_plugin_states_user_id')) + batch_op.drop_index(batch_op.f('ix_plugin_states_state_key')) + batch_op.drop_index(batch_op.f('ix_plugin_states_plugin_id')) + batch_op.drop_index(batch_op.f('ix_plugin_states_page_id')) + batch_op.drop_index(batch_op.f('ix_plugin_states_id')) + batch_op.drop_index('idx_plugin_state_user_plugin_page') + batch_op.drop_index('idx_plugin_state_user_plugin') + batch_op.drop_index('idx_plugin_state_ttl') + batch_op.drop_index('idx_plugin_state_sync') + batch_op.drop_index('idx_plugin_state_last_accessed') + + op.drop_table('plugin_states') + with op.batch_alter_table('plugin_state_configs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_plugin_state_configs_plugin_id')) + batch_op.drop_index(batch_op.f('ix_plugin_state_configs_id')) + batch_op.drop_index('idx_plugin_state_config_plugin') + + op.drop_table('plugin_state_configs') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/cb95bbe8b720_mark_settings_tables_restored.py b/backend/migrations/versions/cb95bbe8b720_mark_settings_tables_restored.py new file mode 100644 index 0000000..59709c2 --- /dev/null +++ b/backend/migrations/versions/cb95bbe8b720_mark_settings_tables_restored.py @@ -0,0 +1,65 @@ +"""mark_settings_tables_restored + +This migration marks that the settings_definitions and settings_instances tables +have been manually restored after being accidentally dropped in migration 7d0185f79500. + +The tables were recreated with the following structure: +- settings_definitions: Core settings schema definitions +- settings_instances: User/system setting values with proper foreign keys and indexes + +Revision ID: cb95bbe8b720 +Revises: 7d0185f79500 +Create Date: 2025-07-16 14:25:35.324694 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cb95bbe8b720' +down_revision: Union[str, None] = '7d0185f79500' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """ + This migration documents that settings tables have been manually restored. + + The tables were recreated outside of Alembic due to migration branching issues. + This migration serves as a marker in the migration history that the restoration + was completed successfully. + + Tables restored: + - settings_definitions (with all columns and primary key) + - settings_instances (with foreign keys to users/pages and indexes) + """ + + # Get database connection and inspector to verify tables exist + conn = op.get_bind() + inspector = sa.inspect(conn) + existing_tables = inspector.get_table_names() + + # Verify that settings tables exist (they should have been manually created) + if 'settings_definitions' not in existing_tables: + raise Exception("settings_definitions table not found - manual restoration may have failed") + + if 'settings_instances' not in existing_tables: + raise Exception("settings_instances table not found - manual restoration may have failed") + + print("✅ Verified settings_definitions table exists") + print("✅ Verified settings_instances table exists") + print("✅ Settings tables restoration confirmed in migration history") + + +def downgrade() -> None: + """ + Downgrade would drop the settings tables, but this should not be done + as it would recreate the original problem. + """ + print("⚠️ Downgrade not implemented - would recreate the settings table drop issue") + print("⚠️ If you need to remove settings tables, do so manually with proper backup") + pass diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2316827..2954da2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,10 @@ import { UserSettingsInitService } from './services/UserSettingsInitService'; import { userNavigationInitService } from './services/UserNavigationInitService'; import { eventService } from './services/EventService'; import { pageContextService } from './services/PageContextService'; +import { pluginStateFactory } from './services/PluginStateFactory'; +import { databasePersistenceManager } from './services/DatabasePersistenceManager'; +import { stateRestorationManager } from './services/StateRestorationManager'; +import { pluginStateLifecycleManager } from './services/PluginStateLifecycleManager'; import { useAppTheme } from './hooks/useAppTheme'; import { config } from './config'; import { PluginManager } from './components/PluginManager'; @@ -40,6 +44,10 @@ serviceRegistry.registerService(userSettingsInitService); serviceRegistry.registerService(userNavigationInitService); serviceRegistry.registerService(eventService); serviceRegistry.registerService(pageContextService); +serviceRegistry.registerService(pluginStateFactory); +serviceRegistry.registerService(databasePersistenceManager); +serviceRegistry.registerService(stateRestorationManager); +serviceRegistry.registerService(pluginStateLifecycleManager); function AppContent() { const theme = useAppTheme(); diff --git a/frontend/src/components/PluginModuleRenderer.tsx b/frontend/src/components/PluginModuleRenderer.tsx index 1868e01..fbbb400 100644 --- a/frontend/src/components/PluginModuleRenderer.tsx +++ b/frontend/src/components/PluginModuleRenderer.tsx @@ -53,8 +53,32 @@ export const PluginModuleRenderer: React.FC = ({ if (!serviceContext) { throw new Error('Service context not available'); } + + // Special handling for pluginState service - create plugin-specific instance + if (name === 'pluginState' && pluginId) { + try { + const pluginStateFactory = serviceContext.getService('pluginStateFactory') as any; + + if (!pluginStateFactory) { + console.error(`[PluginModuleRenderer] pluginStateFactory service is null/undefined`); + return null; + } + + // Try to get existing service first, create if it doesn't exist + let pluginStateService = pluginStateFactory.getPluginStateService(pluginId); + if (!pluginStateService) { + pluginStateService = pluginStateFactory.createPluginStateService(pluginId); + } + + return pluginStateService; + } catch (error) { + console.error(`[PluginModuleRenderer] Failed to get plugin state service for ${pluginId}:`, error); + return null; + } + } + return serviceContext.getService(name); - }, [serviceContext]); + }, [serviceContext, pluginId]); // Extract state persistence props const { initialState, onStateChange, savedState, moduleUniqueId, stateTimestamp } = moduleProps; diff --git a/frontend/src/services/DatabasePersistenceManager.ts b/frontend/src/services/DatabasePersistenceManager.ts new file mode 100644 index 0000000..76a3bf9 --- /dev/null +++ b/frontend/src/services/DatabasePersistenceManager.ts @@ -0,0 +1,451 @@ +import { AbstractBaseService } from './base/BaseService'; +import { EnhancedPluginStateConfig } from './StateConfigurationManager'; + +// Types for database persistence +export interface DatabaseStateRecord { + id: string; + plugin_id: string; + page_id?: string; + state_key?: string; + state_data: any; + state_strategy: 'none' | 'session' | 'persistent' | 'custom'; + state_schema_version?: string; + compression_type?: string; + state_size: number; + last_accessed: string; + access_count: number; + is_active: boolean; + version: number; + device_id?: string; + sync_status: 'synced' | 'pending' | 'conflict'; + ttl_expires_at?: string; + created_at: string; + updated_at: string; +} + +export interface DatabaseStateQuery { + plugin_id?: string; + page_id?: string; + state_key?: string; + state_strategy?: 'none' | 'session' | 'persistent' | 'custom'; + sync_status?: 'synced' | 'pending' | 'conflict'; + is_active?: boolean; + device_id?: string; + limit?: number; + offset?: number; +} + +export interface DatabaseStateStats { + total_states: number; + active_states: number; + total_size: number; + plugins_with_state: number; + average_state_size: number; + last_activity?: string; +} + +export interface SaveResult { + success: boolean; + record?: DatabaseStateRecord; + error?: string; +} + +export interface LoadResult { + success: boolean; + data?: any; + record?: DatabaseStateRecord; + error?: string; +} + +export interface SyncResult { + success: boolean; + synced: DatabaseStateRecord[]; + conflicts: any[]; + errors: any[]; +} + +export interface DatabasePersistenceManagerInterface { + // Basic CRUD operations + saveState(pluginId: string, state: any, options?: DatabaseSaveOptions): Promise; + loadState(pluginId: string, options?: DatabaseLoadOptions): Promise; + clearState(pluginId: string, options?: DatabaseClearOptions): Promise; + + // Query operations + queryStates(query: DatabaseStateQuery): Promise; + getStateStats(): Promise; + + // Sync operations + syncStates(states: any[], options?: DatabaseSyncOptions): Promise; + + // Migration operations + migrateFromSession(pluginId: string): Promise; + migrateToDatabase(sessionData: Record): Promise; + + // Cleanup operations + cleanupExpiredStates(): Promise; +} + +export interface DatabaseSaveOptions { + pageId?: string; + stateKey?: string; + deviceId?: string; + ttlHours?: number; + schemaVersion?: string; + strategy?: 'none' | 'session' | 'persistent' | 'custom'; +} + +export interface DatabaseLoadOptions { + pageId?: string; + stateKey?: string; + includeInactive?: boolean; +} + +export interface DatabaseClearOptions { + pageId?: string; + stateKey?: string; + clearAll?: boolean; +} + +export interface DatabaseSyncOptions { + deviceId?: string; + forceOverwrite?: boolean; +} + +class DatabasePersistenceManagerImpl extends AbstractBaseService implements DatabasePersistenceManagerInterface { + private baseUrl: string; + private deviceId: string; + + constructor() { + super( + 'database-persistence-manager', + { major: 1, minor: 0, patch: 0 }, + [ + { + name: 'database-state-persistence', + description: 'Database-backed plugin state persistence', + version: '1.0.0' + }, + { + name: 'cross-device-sync', + description: 'Cross-device state synchronization', + version: '1.0.0' + } + ] + ); + + this.baseUrl = '/api/v1/plugin-state'; + this.deviceId = this.generateDeviceId(); + } + + async initialize(): Promise { + // Initialize database persistence manager + console.log('Database persistence manager initialized'); + } + + async destroy(): Promise { + // Cleanup database persistence manager + console.log('Database persistence manager destroyed'); + } + + private generateDeviceId(): string { + // Generate a unique device identifier + let deviceId = localStorage.getItem('braindrive_device_id'); + if (!deviceId) { + deviceId = `device_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + localStorage.setItem('braindrive_device_id', deviceId); + } + return deviceId; + } + + private async makeRequest(endpoint: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${endpoint}`; + const defaultHeaders: Record = { + 'Content-Type': 'application/json', + }; + + // Add authentication token if available + const token = localStorage.getItem('access_token'); + if (token) { + defaultHeaders['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url, { + ...options, + headers: { + ...defaultHeaders, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`); + } + + return response; + } + + async saveState(pluginId: string, state: any, options: DatabaseSaveOptions = {}): Promise { + try { + const payload = { + plugin_id: pluginId, + page_id: options.pageId, + state_key: options.stateKey, + state_data: state, + device_id: options.deviceId || this.deviceId, + state_strategy: options.strategy || 'persistent', + state_schema_version: options.schemaVersion, + ttl_expires_at: options.ttlHours ? + new Date(Date.now() + options.ttlHours * 60 * 60 * 1000).toISOString() : + undefined + }; + + // Try to update existing state first + const existingStates = await this.queryStates({ + plugin_id: pluginId, + page_id: options.pageId, + state_key: options.stateKey, + limit: 1 + }); + + let response: Response; + if (existingStates.length > 0) { + // Update existing state + response = await this.makeRequest(`/${existingStates[0].id}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + } else { + // Create new state + response = await this.makeRequest('/', { + method: 'POST', + body: JSON.stringify(payload) + }); + } + + const record: DatabaseStateRecord = await response.json(); + return { success: true, record }; + + } catch (error) { + console.error('Database save error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + async loadState(pluginId: string, options: DatabaseLoadOptions = {}): Promise { + try { + const query: DatabaseStateQuery = { + plugin_id: pluginId, + page_id: options.pageId, + state_key: options.stateKey, + is_active: options.includeInactive ? undefined : true, + limit: 1 + }; + + const states = await this.queryStates(query); + + if (states.length === 0) { + return { success: true, data: null }; + } + + const record = states[0]; + return { + success: true, + data: record.state_data, + record + }; + + } catch (error) { + console.error('Database load error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + async clearState(pluginId: string, options: DatabaseClearOptions = {}): Promise { + try { + if (options.clearAll) { + // Clear all states for the plugin + const states = await this.queryStates({ plugin_id: pluginId }); + for (const state of states) { + await this.makeRequest(`/${state.id}`, { method: 'DELETE' }); + } + return true; + } else { + // Clear specific state + const query: DatabaseStateQuery = { + plugin_id: pluginId, + page_id: options.pageId, + state_key: options.stateKey, + limit: 1 + }; + + const states = await this.queryStates(query); + if (states.length > 0) { + await this.makeRequest(`/${states[0].id}`, { method: 'DELETE' }); + } + return true; + } + + } catch (error) { + console.error('Database clear error:', error); + return false; + } + } + + async queryStates(query: DatabaseStateQuery): Promise { + try { + const params = new URLSearchParams(); + + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + params.append(key, value.toString()); + } + }); + + const response = await this.makeRequest(`/?${params.toString()}`); + return await response.json(); + + } catch (error) { + console.error('Database query error:', error); + return []; + } + } + + async getStateStats(): Promise { + try { + const response = await this.makeRequest('/stats'); + return await response.json(); + + } catch (error) { + console.error('Database stats error:', error); + return { + total_states: 0, + active_states: 0, + total_size: 0, + plugins_with_state: 0, + average_state_size: 0 + }; + } + } + + async syncStates(states: any[], options: DatabaseSyncOptions = {}): Promise { + try { + const payload = { + device_id: options.deviceId || this.deviceId, + states: states.map(state => ({ + plugin_id: state.pluginId, + page_id: state.pageId, + state_key: state.stateKey, + state_data: state.data, + state_strategy: state.strategy || 'persistent', + device_id: options.deviceId || this.deviceId + })), + force_overwrite: options.forceOverwrite || false + }; + + const response = await this.makeRequest('/sync', { + method: 'POST', + body: JSON.stringify(payload) + }); + + return await response.json(); + + } catch (error) { + console.error('Database sync error:', error); + return { + success: false, + synced: [], + conflicts: [], + errors: [{ error: error instanceof Error ? error.message : 'Unknown error' }] + }; + } + } + + async migrateFromSession(pluginId: string): Promise { + try { + // Get session storage data + const sessionKey = `braindrive_plugin_state_${pluginId}`; + const sessionData = sessionStorage.getItem(sessionKey); + + if (!sessionData) { + return true; // Nothing to migrate + } + + const parsedData = JSON.parse(sessionData); + + // Save to database + const result = await this.saveState(pluginId, parsedData, { + strategy: 'persistent', + deviceId: this.deviceId + }); + + if (result.success) { + // Remove from session storage after successful migration + sessionStorage.removeItem(sessionKey); + return true; + } + + return false; + + } catch (error) { + console.error('Session migration error:', error); + return false; + } + } + + async migrateToDatabase(sessionData: Record): Promise { + try { + const states = Object.entries(sessionData).map(([pluginId, data]) => ({ + pluginId, + data, + strategy: 'persistent' as const + })); + + const result = await this.syncStates(states, { + deviceId: this.deviceId, + forceOverwrite: false + }); + + // Clear session storage for successfully migrated states + if (result.success) { + result.synced.forEach(record => { + const sessionKey = `braindrive_plugin_state_${record.plugin_id}`; + sessionStorage.removeItem(sessionKey); + }); + } + + return result; + + } catch (error) { + console.error('Database migration error:', error); + return { + success: false, + synced: [], + conflicts: [], + errors: [{ error: error instanceof Error ? error.message : 'Unknown error' }] + }; + } + } + + async cleanupExpiredStates(): Promise { + try { + const response = await this.makeRequest('/cleanup', { method: 'DELETE' }); + const result = await response.json(); + return result.deleted_count || 0; + + } catch (error) { + console.error('Database cleanup error:', error); + return 0; + } + } +} + +// Export singleton instance +export const databasePersistenceManager = new DatabasePersistenceManagerImpl(); +export default databasePersistenceManager; \ No newline at end of file diff --git a/frontend/src/services/PageContextService.ts b/frontend/src/services/PageContextService.ts index 2e9add6..1657d05 100644 --- a/frontend/src/services/PageContextService.ts +++ b/frontend/src/services/PageContextService.ts @@ -1,4 +1,10 @@ import { AbstractBaseService } from './base/BaseService'; +import { StateSerializationUtils } from './StateSerializationUtils'; +import { stateConfigurationManager, EnhancedPluginStateConfig } from './StateConfigurationManager'; +import { sessionStorageManager } from './SessionStorageManager'; +import { databasePersistenceManager } from './DatabasePersistenceManager'; +import { stateRestorationManager } from './StateRestorationManager'; +import { pluginStateLifecycleManager } from './PluginStateLifecycleManager'; export interface PageContextData { pageId: string; @@ -7,20 +13,71 @@ export interface PageContextData { isStudioPage: boolean; } +// Plugin state configuration interface +export interface PluginStateConfig { + pluginId: string; + stateStrategy: 'none' | 'session' | 'persistent' | 'custom'; + preserveKeys?: string[]; + stateSchema?: { + [key: string]: { + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + required?: boolean; + default?: any; + }; + }; + serialize?: (state: any) => string; + deserialize?: (serialized: string) => any; + maxStateSize?: number; + ttl?: number; +} + +// Enhanced page context data with plugin states +export interface EnhancedPageContextData extends PageContextData { + pluginStates?: Record; +} + export interface PageContextServiceInterface { getCurrentPageContext(): PageContextData | null; onPageContextChange(callback: (context: PageContextData) => void): () => void; } -class PageContextServiceImpl extends AbstractBaseService implements PageContextServiceInterface { +// Enhanced interface with plugin state management +export interface EnhancedPageContextServiceInterface extends PageContextServiceInterface { + // Plugin state management + savePluginState(pluginId: string, state: any): Promise; + getPluginState(pluginId: string): Promise; + clearPluginState(pluginId: string): Promise; + + // Plugin state configuration + registerPluginStateConfig(config: PluginStateConfig | EnhancedPluginStateConfig): void; + getPluginStateConfig(pluginId: string): PluginStateConfig | EnhancedPluginStateConfig | null; + + // State lifecycle events + onPluginStateChange(pluginId: string, callback: (state: any) => void): () => void; + + // Enhanced Phase 3 methods + getStorageStats(): any; + cleanupOldStates(maxAge: number): Promise; + + // Phase 4 methods - Database persistence and advanced features + migrateToDatabase(pluginId: string): Promise; + syncStateAcrossDevices(pluginId: string): Promise; + restoreStateWithFallback(pluginId: string, fallbackData?: any): Promise; + registerLifecycleHook(pluginId: string, eventType: string, callback: Function): string; + unregisterLifecycleHook(hookId: string): boolean; +} + +class PageContextServiceImpl extends AbstractBaseService implements EnhancedPageContextServiceInterface { private currentContext: PageContextData | null = null; private listeners: ((context: PageContextData) => void)[] = []; + private pluginStateConfigs: Map = new Map(); + private pluginStateListeners: Map void)[]> = new Map(); private static instance: PageContextServiceImpl; private constructor() { super( 'pageContext', - { major: 1, minor: 0, patch: 0 }, + { major: 1, minor: 1, patch: 0 }, [ { name: 'page-context-management', @@ -31,6 +88,16 @@ class PageContextServiceImpl extends AbstractBaseService implements PageContextS name: 'page-context-events', description: 'Page context change event subscription system', version: '1.0.0' + }, + { + name: 'plugin-state-management', + description: 'Plugin state persistence and lifecycle management', + version: '1.1.0' + }, + { + name: 'plugin-state-configuration', + description: 'Plugin state configuration and validation', + version: '1.1.0' } ] ); @@ -75,6 +142,313 @@ class PageContextServiceImpl extends AbstractBaseService implements PageContextS } }; } + + // Plugin state management methods + async savePluginState(pluginId: string, state: any): Promise { + try { + const config = this.pluginStateConfigs.get(pluginId); + if (!config || config.stateStrategy === 'none') { + return; + } + + // Execute before save hook + await stateConfigurationManager.executeHook(pluginId, 'beforeSave', state); + + // Apply transformations + let processedState = await stateConfigurationManager.applyTransformations( + pluginId, + state, + 'beforeSave' + ); + + // Filter state using enhanced configuration manager + processedState = stateConfigurationManager.filterState(pluginId, processedState); + + // Validate and sanitize state + const validationResult = stateConfigurationManager.validateAndSanitizeState(pluginId, processedState); + if (!validationResult.valid) { + console.error(`[PageContextService] State validation failed for plugin ${pluginId}:`, validationResult.errors); + return; + } + + if (validationResult.warnings.length > 0) { + console.warn(`[PageContextService] State validation warnings for plugin ${pluginId}:`, validationResult.warnings); + } + + processedState = validationResult.sanitizedState || processedState; + + // Use enhanced session storage manager + const storageOptions = { + compression: stateConfigurationManager.shouldCompress(pluginId, StateSerializationUtils.calculateStateSize(processedState)), + compressionThreshold: 1024, + maxSize: config.maxStateSize, + enableMetrics: true + }; + + const saveResult = await sessionStorageManager.saveState(pluginId, processedState, storageOptions); + + if (!saveResult.success) { + console.error(`[PageContextService] Failed to save state for plugin ${pluginId}:`, saveResult.error); + return; + } + + // Execute after save hook + await stateConfigurationManager.executeHook(pluginId, 'afterSave', processedState); + + // Notify listeners + const listeners = this.pluginStateListeners.get(pluginId) || []; + listeners.forEach(listener => listener(processedState)); + + console.log(`[PageContextService] Saved state for plugin ${pluginId} (${saveResult.size} bytes)`); + } catch (error) { + console.error(`[PageContextService] Error saving state for plugin ${pluginId}:`, error); + + // Execute error hook + await stateConfigurationManager.executeHook(pluginId, 'onError', error, 'save'); + } + } + + async getPluginState(pluginId: string): Promise { + try { + const config = this.pluginStateConfigs.get(pluginId); + if (!config || config.stateStrategy === 'none') { + return null; + } + + // Execute before load hook + await stateConfigurationManager.executeHook(pluginId, 'beforeLoad'); + + // Use enhanced session storage manager + const storageOptions = { + maxAge: config.ttl, + enableMetrics: true + }; + + const loadResult = await sessionStorageManager.loadState(pluginId, storageOptions); + + if (!loadResult.success) { + console.error(`[PageContextService] Failed to load state for plugin ${pluginId}:`, loadResult.error); + return null; + } + + if (!loadResult.data) { + return null; + } + + // Apply transformations + let processedState = await stateConfigurationManager.applyTransformations( + pluginId, + loadResult.data, + 'afterLoad' + ); + + // Execute after load hook + processedState = await stateConfigurationManager.executeHook(pluginId, 'afterLoad', processedState) || processedState; + + console.log(`[PageContextService] Retrieved state for plugin ${pluginId}`); + return processedState; + } catch (error) { + console.error(`[PageContextService] Error retrieving state for plugin ${pluginId}:`, error); + + // Execute error hook + await stateConfigurationManager.executeHook(pluginId, 'onError', error, 'load'); + return null; + } + } + + async clearPluginState(pluginId: string): Promise { + try { + const config = this.pluginStateConfigs.get(pluginId); + if (!config || config.stateStrategy === 'none') { + return; + } + + // Use enhanced session storage manager + const clearResult = await sessionStorageManager.clearState(pluginId); + + if (!clearResult.success) { + console.error(`[PageContextService] Failed to clear state for plugin ${pluginId}:`, clearResult.error); + return; + } + + // Notify listeners + const listeners = this.pluginStateListeners.get(pluginId) || []; + listeners.forEach(listener => listener(null)); + + console.log(`[PageContextService] Cleared state for plugin ${pluginId}`); + } catch (error) { + console.error(`[PageContextService] Error clearing state for plugin ${pluginId}:`, error); + + // Execute error hook + await stateConfigurationManager.executeHook(pluginId, 'onError', error, 'clear'); + } + } + + registerPluginStateConfig(config: PluginStateConfig | EnhancedPluginStateConfig): void { + this.pluginStateConfigs.set(config.pluginId, config); + + // Also register with the enhanced configuration manager + if ('validation' in config || 'transformers' in config || 'hooks' in config) { + stateConfigurationManager.registerConfig(config as EnhancedPluginStateConfig); + } + + console.log(`[PageContextService] Registered state config for plugin ${config.pluginId}`); + } + + getPluginStateConfig(pluginId: string): PluginStateConfig | EnhancedPluginStateConfig | null { + return this.pluginStateConfigs.get(pluginId) || null; + } + + // Enhanced Phase 3 methods + getStorageStats(): any { + return sessionStorageManager.getStorageStats(); + } + + async cleanupOldStates(maxAge: number): Promise { + return await sessionStorageManager.cleanupOldEntries(maxAge); + } + + onPluginStateChange(pluginId: string, callback: (state: any) => void): () => void { + if (!this.pluginStateListeners.has(pluginId)) { + this.pluginStateListeners.set(pluginId, []); + } + + const listeners = this.pluginStateListeners.get(pluginId)!; + listeners.push(callback); + + // Return unsubscribe function + return () => { + const index = listeners.indexOf(callback); + if (index > -1) { + listeners.splice(index, 1); + } + }; + } + + // Phase 4 methods - Database persistence and advanced features + async migrateToDatabase(pluginId: string): Promise { + try { + const config = this.pluginStateConfigs.get(pluginId); + if (!config || config.stateStrategy !== 'persistent') { + return true; // No migration needed + } + + return await stateRestorationManager.migrateSessionToDatabase(pluginId, config as EnhancedPluginStateConfig); + } catch (error) { + console.error(`[PageContextService] Error migrating plugin ${pluginId} to database:`, error); + return false; + } + } + + async syncStateAcrossDevices(pluginId: string): Promise { + try { + const config = this.pluginStateConfigs.get(pluginId); + if (!config || config.stateStrategy !== 'persistent') { + return false; + } + + const result = await stateRestorationManager.syncStateAcrossDevices(pluginId, config as EnhancedPluginStateConfig); + return result.success; + } catch (error) { + console.error(`[PageContextService] Error syncing plugin ${pluginId} across devices:`, error); + return false; + } + } + + async restoreStateWithFallback(pluginId: string, fallbackData?: any): Promise { + try { + const config = this.pluginStateConfigs.get(pluginId); + if (!config) { + return fallbackData || null; + } + + const result = await stateRestorationManager.restoreWithFallback( + pluginId, + config as EnhancedPluginStateConfig, + fallbackData + ); + + return result.success ? result.data : fallbackData; + } catch (error) { + console.error(`[PageContextService] Error restoring plugin ${pluginId} with fallback:`, error); + return fallbackData || null; + } + } + + registerLifecycleHook(pluginId: string, eventType: string, callback: Function): string { + try { + return pluginStateLifecycleManager.registerHook( + pluginId, + eventType as any, + callback as any + ); + } catch (error) { + console.error(`[PageContextService] Error registering lifecycle hook for plugin ${pluginId}:`, error); + return ''; + } + } + + unregisterLifecycleHook(hookId: string): boolean { + try { + return pluginStateLifecycleManager.unregisterHook(hookId); + } catch (error) { + console.error(`[PageContextService] Error unregistering lifecycle hook ${hookId}:`, error); + return false; + } + } + + // Helper methods + private validateAndSanitizeState(state: any, schema: PluginStateConfig['stateSchema']): any { + if (!schema) return state; + + const sanitized: any = {}; + + for (const [key, fieldSchema] of Object.entries(schema)) { + const value = state[key]; + + // Check if required field is missing + if (fieldSchema.required && (value === undefined || value === null)) { + if (fieldSchema.default !== undefined) { + sanitized[key] = fieldSchema.default; + } else { + throw new Error(`Required field '${key}' is missing`); + } + continue; + } + + // Skip undefined/null optional fields + if (value === undefined || value === null) { + if (fieldSchema.default !== undefined) { + sanitized[key] = fieldSchema.default; + } + continue; + } + + // Type validation + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== fieldSchema.type) { + console.warn(`[PageContextService] Type mismatch for field '${key}': expected ${fieldSchema.type}, got ${actualType}`); + if (fieldSchema.default !== undefined) { + sanitized[key] = fieldSchema.default; + } + continue; + } + + sanitized[key] = value; + } + + return sanitized; + } + + private filterStateKeys(state: any, preserveKeys: string[]): any { + const filtered: any = {}; + for (const key of preserveKeys) { + if (state.hasOwnProperty(key)) { + filtered[key] = state[key]; + } + } + return filtered; + } } export const pageContextService = PageContextServiceImpl.getInstance(); \ No newline at end of file diff --git a/frontend/src/services/PluginStateFactory.ts b/frontend/src/services/PluginStateFactory.ts new file mode 100644 index 0000000..0735f29 --- /dev/null +++ b/frontend/src/services/PluginStateFactory.ts @@ -0,0 +1,89 @@ +import { AbstractBaseService } from './base/BaseService'; +import { createPluginStateService, PluginStateServiceImpl } from './PluginStateService'; + +export interface PluginStateFactoryInterface { + createPluginStateService(pluginId: string): PluginStateServiceImpl; + getPluginStateService(pluginId: string): PluginStateServiceImpl | null; + destroyPluginStateService(pluginId: string): Promise; + listActivePlugins(): string[]; +} + +class PluginStateFactoryImpl extends AbstractBaseService implements PluginStateFactoryInterface { + private activeServices: Map = new Map(); + private static instance: PluginStateFactoryImpl; + + private constructor() { + super( + 'pluginStateFactory', + { major: 1, minor: 0, patch: 0 }, + [ + { + name: 'plugin-state-factory', + description: 'Factory for creating plugin-specific state services', + version: '1.0.0' + }, + { + name: 'plugin-state-lifecycle', + description: 'Lifecycle management for plugin state services', + version: '1.0.0' + } + ] + ); + } + + public static getInstance(): PluginStateFactoryImpl { + if (!PluginStateFactoryImpl.instance) { + PluginStateFactoryImpl.instance = new PluginStateFactoryImpl(); + } + return PluginStateFactoryImpl.instance; + } + + async initialize(): Promise { + console.log('[PluginStateFactory] Initialized'); + } + + async destroy(): Promise { + // Destroy all active plugin state services + const destroyPromises = Array.from(this.activeServices.values()).map(service => service.destroy()); + await Promise.all(destroyPromises); + this.activeServices.clear(); + console.log('[PluginStateFactory] Destroyed'); + } + + createPluginStateService(pluginId: string): PluginStateServiceImpl { + if (this.activeServices.has(pluginId)) { + console.warn(`[PluginStateFactory] Plugin state service for ${pluginId} already exists, returning existing instance`); + return this.activeServices.get(pluginId)!; + } + + const service = createPluginStateService(pluginId); + this.activeServices.set(pluginId, service); + + // Initialize the service + service.initialize().catch(error => { + console.error(`[PluginStateFactory] Error initializing plugin state service for ${pluginId}:`, error); + }); + + console.log(`[PluginStateFactory] Created plugin state service for ${pluginId}`); + return service; + } + + getPluginStateService(pluginId: string): PluginStateServiceImpl | null { + return this.activeServices.get(pluginId) || null; + } + + async destroyPluginStateService(pluginId: string): Promise { + const service = this.activeServices.get(pluginId); + if (service) { + await service.destroy(); + this.activeServices.delete(pluginId); + console.log(`[PluginStateFactory] Destroyed plugin state service for ${pluginId}`); + } + } + + listActivePlugins(): string[] { + return Array.from(this.activeServices.keys()); + } +} + +export const pluginStateFactory = PluginStateFactoryImpl.getInstance(); \ No newline at end of file diff --git a/frontend/src/services/PluginStateLifecycleManager.ts b/frontend/src/services/PluginStateLifecycleManager.ts new file mode 100644 index 0000000..d8f52ca --- /dev/null +++ b/frontend/src/services/PluginStateLifecycleManager.ts @@ -0,0 +1,430 @@ +import { AbstractBaseService } from './base/BaseService'; +import { EnhancedPluginStateConfig } from './StateConfigurationManager'; + +// Lifecycle event types +export type LifecycleEventType = 'beforeSave' | 'afterSave' | 'beforeLoad' | 'afterLoad' | 'beforeClear' | 'afterClear' | 'onError' | 'onStateChange'; + +// Lifecycle event data +export interface LifecycleEventData { + pluginId: string; + eventType: LifecycleEventType; + state?: any; + previousState?: any; + error?: Error; + operation?: 'save' | 'load' | 'clear'; + metadata?: { + timestamp: number; + source: 'session' | 'database' | 'default'; + deviceId?: string; + version?: number; + }; +} + +// Lifecycle hook callback +export type LifecycleHookCallback = (data: LifecycleEventData) => Promise | any; + +// Hook registration options +export interface HookRegistrationOptions { + priority?: number; // Higher priority hooks run first + once?: boolean; // Run only once + condition?: (data: LifecycleEventData) => boolean; // Conditional execution +} + +// Hook registration result +export interface HookRegistration { + id: string; + pluginId: string; + eventType: LifecycleEventType; + callback: LifecycleHookCallback; + options: HookRegistrationOptions; + registeredAt: number; +} + +// State change event +export interface StateChangeEvent { + pluginId: string; + oldState: any; + newState: any; + changeType: 'create' | 'update' | 'delete' | 'restore'; + source: 'session' | 'database' | 'default'; + timestamp: number; +} + +// Validation callback +export type StateValidationCallback = (state: any, config: EnhancedPluginStateConfig) => Promise | boolean; + +export interface PluginStateLifecycleManagerInterface { + // Hook registration + registerHook(pluginId: string, eventType: LifecycleEventType, callback: LifecycleHookCallback, options?: HookRegistrationOptions): string; + unregisterHook(hookId: string): boolean; + unregisterAllHooks(pluginId: string): number; + + // Hook execution + executeHooks(eventType: LifecycleEventType, data: LifecycleEventData): Promise; + executeHooksForPlugin(pluginId: string, eventType: LifecycleEventType, data: LifecycleEventData): Promise; + + // State change notifications + notifyStateChange(event: StateChangeEvent): Promise; + + // Validation hooks + registerValidationCallback(pluginId: string, callback: StateValidationCallback): string; + unregisterValidationCallback(callbackId: string): boolean; + executeValidation(pluginId: string, state: any, config: EnhancedPluginStateConfig): Promise; + + // Hook management + getRegisteredHooks(pluginId?: string): HookRegistration[]; + clearExpiredHooks(): number; +} + +class PluginStateLifecycleManagerImpl extends AbstractBaseService implements PluginStateLifecycleManagerInterface { + private hooks: Map = new Map(); + private validationCallbacks: Map = new Map(); + private hookIdCounter = 0; + private callbackIdCounter = 0; + + constructor() { + super( + 'plugin-state-lifecycle-manager', + { major: 1, minor: 0, patch: 0 }, + [ + { + name: 'lifecycle-hooks', + description: 'Plugin state lifecycle hook management', + version: '1.0.0' + }, + { + name: 'state-validation', + description: 'Plugin state validation callbacks', + version: '1.0.0' + }, + { + name: 'change-notifications', + description: 'State change notification system', + version: '1.0.0' + } + ] + ); + } + + async initialize(): Promise { + console.log('Plugin state lifecycle manager initialized'); + + // Start periodic cleanup of expired hooks + setInterval(() => { + this.clearExpiredHooks(); + }, 5 * 60 * 1000); // Every 5 minutes + } + + async destroy(): Promise { + this.hooks.clear(); + this.validationCallbacks.clear(); + console.log('Plugin state lifecycle manager destroyed'); + } + + private generateHookId(): string { + return `hook_${++this.hookIdCounter}_${Date.now()}`; + } + + private generateCallbackId(): string { + return `callback_${++this.callbackIdCounter}_${Date.now()}`; + } + + registerHook( + pluginId: string, + eventType: LifecycleEventType, + callback: LifecycleHookCallback, + options: HookRegistrationOptions = {} + ): string { + const hookId = this.generateHookId(); + + const registration: HookRegistration = { + id: hookId, + pluginId, + eventType, + callback, + options: { + priority: options.priority || 0, + once: options.once || false, + condition: options.condition + }, + registeredAt: Date.now() + }; + + this.hooks.set(hookId, registration); + + console.log(`[LifecycleManager] Registered ${eventType} hook for plugin ${pluginId} (ID: ${hookId})`); + + return hookId; + } + + unregisterHook(hookId: string): boolean { + const removed = this.hooks.delete(hookId); + if (removed) { + console.log(`[LifecycleManager] Unregistered hook ${hookId}`); + } + return removed; + } + + unregisterAllHooks(pluginId: string): number { + let removedCount = 0; + + for (const [hookId, registration] of this.hooks.entries()) { + if (registration.pluginId === pluginId) { + this.hooks.delete(hookId); + removedCount++; + } + } + + console.log(`[LifecycleManager] Unregistered ${removedCount} hooks for plugin ${pluginId}`); + + return removedCount; + } + + async executeHooks(eventType: LifecycleEventType, data: LifecycleEventData): Promise { + // Get all hooks for this event type + const relevantHooks = Array.from(this.hooks.values()) + .filter(hook => hook.eventType === eventType) + .sort((a, b) => (b.options.priority || 0) - (a.options.priority || 0)); // Higher priority first + + const results: any[] = []; + const hooksToRemove: string[] = []; + + for (const hook of relevantHooks) { + try { + // Check condition if provided + if (hook.options.condition && !hook.options.condition(data)) { + continue; + } + + // Execute hook + const result = await Promise.resolve(hook.callback(data)); + results.push(result); + + // Mark for removal if it's a one-time hook + if (hook.options.once) { + hooksToRemove.push(hook.id); + } + + } catch (error) { + console.error(`[LifecycleManager] Error executing hook ${hook.id}:`, error); + + // Notify error hooks + if (eventType !== 'onError') { + await this.executeHooks('onError', { + ...data, + eventType: 'onError', + error: error instanceof Error ? error : new Error(String(error)), + operation: this.getOperationFromEventType(eventType) + }); + } + } + } + + // Remove one-time hooks + hooksToRemove.forEach(hookId => this.hooks.delete(hookId)); + + return results; + } + + async executeHooksForPlugin( + pluginId: string, + eventType: LifecycleEventType, + data: LifecycleEventData + ): Promise { + // Get hooks for specific plugin and event type + const relevantHooks = Array.from(this.hooks.values()) + .filter(hook => hook.pluginId === pluginId && hook.eventType === eventType) + .sort((a, b) => (b.options.priority || 0) - (a.options.priority || 0)); + + const results: any[] = []; + const hooksToRemove: string[] = []; + + for (const hook of relevantHooks) { + try { + // Check condition if provided + if (hook.options.condition && !hook.options.condition(data)) { + continue; + } + + // Execute hook + const result = await Promise.resolve(hook.callback(data)); + results.push(result); + + // Mark for removal if it's a one-time hook + if (hook.options.once) { + hooksToRemove.push(hook.id); + } + + } catch (error) { + console.error(`[LifecycleManager] Error executing plugin hook ${hook.id}:`, error); + + // Notify error hooks for this plugin + if (eventType !== 'onError') { + await this.executeHooksForPlugin(pluginId, 'onError', { + ...data, + eventType: 'onError', + error: error instanceof Error ? error : new Error(String(error)), + operation: this.getOperationFromEventType(eventType) + }); + } + } + } + + // Remove one-time hooks + hooksToRemove.forEach(hookId => this.hooks.delete(hookId)); + + return results; + } + + async notifyStateChange(event: StateChangeEvent): Promise { + const eventData: LifecycleEventData = { + pluginId: event.pluginId, + eventType: 'onStateChange', + state: event.newState, + previousState: event.oldState, + metadata: { + timestamp: event.timestamp, + source: event.source + } + }; + + // Execute global state change hooks + await this.executeHooks('onStateChange', eventData); + + // Execute plugin-specific state change hooks + await this.executeHooksForPlugin(event.pluginId, 'onStateChange', eventData); + } + + registerValidationCallback(pluginId: string, callback: StateValidationCallback): string { + const callbackId = this.generateCallbackId(); + + this.validationCallbacks.set(callbackId, { + pluginId, + callback, + registeredAt: Date.now() + }); + + console.log(`[LifecycleManager] Registered validation callback for plugin ${pluginId} (ID: ${callbackId})`); + + return callbackId; + } + + unregisterValidationCallback(callbackId: string): boolean { + const removed = this.validationCallbacks.delete(callbackId); + if (removed) { + console.log(`[LifecycleManager] Unregistered validation callback ${callbackId}`); + } + return removed; + } + + async executeValidation(pluginId: string, state: any, config: EnhancedPluginStateConfig): Promise { + // Get validation callbacks for this plugin + const relevantCallbacks = Array.from(this.validationCallbacks.values()) + .filter(cb => cb.pluginId === pluginId); + + // If no custom validation callbacks, return true + if (relevantCallbacks.length === 0) { + return true; + } + + try { + // Execute all validation callbacks + const validationResults = await Promise.all( + relevantCallbacks.map(cb => Promise.resolve(cb.callback(state, config))) + ); + + // All validations must pass + return validationResults.every(result => result === true); + + } catch (error) { + console.error(`[LifecycleManager] Error executing validation for plugin ${pluginId}:`, error); + return false; + } + } + + getRegisteredHooks(pluginId?: string): HookRegistration[] { + const allHooks = Array.from(this.hooks.values()); + + if (pluginId) { + return allHooks.filter(hook => hook.pluginId === pluginId); + } + + return allHooks; + } + + clearExpiredHooks(): number { + // This could be extended to support TTL for hooks + // For now, just remove hooks that are marked as 'once' and have been executed + // (they would have been removed during execution) + return 0; + } + + private getOperationFromEventType(eventType: LifecycleEventType): 'save' | 'load' | 'clear' { + if (eventType.includes('Save')) return 'save'; + if (eventType.includes('Load')) return 'load'; + if (eventType.includes('Clear')) return 'clear'; + return 'save'; // Default + } + + // Convenience methods for common lifecycle events + async executeSaveHooks(pluginId: string, state: any, previousState?: any): Promise { + const beforeResults = await this.executeHooksForPlugin(pluginId, 'beforeSave', { + pluginId, + eventType: 'beforeSave', + state, + previousState, + metadata: { timestamp: Date.now(), source: 'session' } + }); + + // If any beforeSave hook returns a modified state, use it + const modifiedState = beforeResults.find(result => result !== undefined) || state; + + return modifiedState; + } + + async executeAfterSaveHooks(pluginId: string, state: any): Promise { + await this.executeHooksForPlugin(pluginId, 'afterSave', { + pluginId, + eventType: 'afterSave', + state, + metadata: { timestamp: Date.now(), source: 'session' } + }); + } + + async executeLoadHooks(pluginId: string, state: any): Promise { + await this.executeHooksForPlugin(pluginId, 'beforeLoad', { + pluginId, + eventType: 'beforeLoad', + metadata: { timestamp: Date.now(), source: 'session' } + }); + + const afterResults = await this.executeHooksForPlugin(pluginId, 'afterLoad', { + pluginId, + eventType: 'afterLoad', + state, + metadata: { timestamp: Date.now(), source: 'session' } + }); + + // If any afterLoad hook returns a modified state, use it + return afterResults.find(result => result !== undefined) || state; + } + + async executeClearHooks(pluginId: string, state?: any): Promise { + await this.executeHooksForPlugin(pluginId, 'beforeClear', { + pluginId, + eventType: 'beforeClear', + state, + metadata: { timestamp: Date.now(), source: 'session' } + }); + + await this.executeHooksForPlugin(pluginId, 'afterClear', { + pluginId, + eventType: 'afterClear', + metadata: { timestamp: Date.now(), source: 'session' } + }); + } +} + +// Export singleton instance +export const pluginStateLifecycleManager = new PluginStateLifecycleManagerImpl(); +export default pluginStateLifecycleManager; \ No newline at end of file diff --git a/frontend/src/services/PluginStateService.ts b/frontend/src/services/PluginStateService.ts new file mode 100644 index 0000000..ca8ef8b --- /dev/null +++ b/frontend/src/services/PluginStateService.ts @@ -0,0 +1,341 @@ +import { AbstractBaseService } from './base/BaseService'; +import { PluginStateConfig } from './PageContextService'; +import { EnhancedPluginStateConfig, stateConfigurationManager } from './StateConfigurationManager'; + +export interface PluginStateServiceInterface { + // Configuration management + configure(config: PluginStateConfig | EnhancedPluginStateConfig): void; + getConfiguration(): PluginStateConfig | EnhancedPluginStateConfig | null; + + // State operations + saveState(state: any): Promise; + getState(): Promise; + clearState(): Promise; + + // State validation + validateState(state: any): boolean; + sanitizeState(state: any): any; + + // Lifecycle hooks + onSave(callback: (state: any) => void): () => void; + onRestore(callback: (state: any) => void): () => void; + onClear(callback: () => void): () => void; +} + +class PluginStateServiceImpl extends AbstractBaseService implements PluginStateServiceInterface { + private config: PluginStateConfig | EnhancedPluginStateConfig | null = null; + private pageContextService: any = null; // Will be injected + private saveCallbacks: ((state: any) => void)[] = []; + private restoreCallbacks: ((state: any) => void)[] = []; + private clearCallbacks: (() => void)[] = []; + private static instances: Map = new Map(); + + private constructor(pluginId: string) { + super( + `pluginState-${pluginId}`, + { major: 1, minor: 0, patch: 0 }, + [ + { + name: 'plugin-state-configuration', + description: 'Plugin state configuration management', + version: '1.0.0' + }, + { + name: 'plugin-state-operations', + description: 'Plugin state save, restore, and clear operations', + version: '1.0.0' + }, + { + name: 'plugin-state-validation', + description: 'Plugin state validation and sanitization', + version: '1.0.0' + }, + { + name: 'plugin-state-lifecycle', + description: 'Plugin state lifecycle event hooks', + version: '1.0.0' + } + ] + ); + } + + public static getInstance(pluginId: string): PluginStateServiceImpl { + if (!PluginStateServiceImpl.instances.has(pluginId)) { + PluginStateServiceImpl.instances.set(pluginId, new PluginStateServiceImpl(pluginId)); + } + return PluginStateServiceImpl.instances.get(pluginId)!; + } + + async initialize(): Promise { + // Import PageContextService to avoid circular dependency + const { pageContextService } = await import('./PageContextService'); + this.pageContextService = pageContextService; + console.log(`[PluginStateService] Initialized for plugin ${this.getPluginId()}`); + } + + async destroy(): Promise { + // Clean up enhanced configuration manager + if (this.config) { + stateConfigurationManager.cleanup(this.config.pluginId); + } + + // Clean up callbacks + this.saveCallbacks = []; + this.restoreCallbacks = []; + this.clearCallbacks = []; + this.config = null; + this.pageContextService = null; + console.log(`[PluginStateService] Destroyed for plugin ${this.getPluginId()}`); + } + + private getPluginId(): string { + return this.getName().replace('pluginState-', ''); + } + + configure(config: PluginStateConfig | EnhancedPluginStateConfig): void { + this.config = config; + + // Register configuration with PageContextService + if (this.pageContextService) { + this.pageContextService.registerPluginStateConfig(config); + } + + console.log(`[PluginStateService] Configured for plugin ${config.pluginId}`); + } + + getConfiguration(): PluginStateConfig | EnhancedPluginStateConfig | null { + return this.config; + } + + async saveState(state: any): Promise { + if (!this.config) { + throw new Error('Plugin state service not configured. Call configure() first.'); + } + + if (!this.pageContextService) { + throw new Error('PageContextService not available'); + } + + try { + // Validate state before saving + if (!this.validateState(state)) { + throw new Error('State validation failed'); + } + + // Sanitize state + const sanitizedState = this.sanitizeState(state); + + // Use debounced save if configured + const saveOperation = async () => { + try { + // Save through PageContextService (which now uses enhanced features) + await this.pageContextService.savePluginState(this.config!.pluginId, sanitizedState); + + // Notify save callbacks + this.saveCallbacks.forEach(callback => { + try { + callback(sanitizedState); + } catch (error) { + console.error(`[PluginStateService] Error in save callback:`, error); + } + }); + + console.log(`[PluginStateService] State saved for plugin ${this.config!.pluginId}`); + } catch (error) { + console.error(`[PluginStateService] Error saving state for plugin ${this.config!.pluginId}:`, error); + throw error; + } + }; + + // Check if enhanced config has debouncing + if ('performance' in this.config && this.config.performance?.debounceMs) { + stateConfigurationManager.executeDebouncedSave(this.config.pluginId, saveOperation); + } else { + await saveOperation(); + } + + } catch (error) { + console.error(`[PluginStateService] Error saving state for plugin ${this.config.pluginId}:`, error); + throw error; + } + } + + async getState(): Promise { + if (!this.config) { + throw new Error('Plugin state service not configured. Call configure() first.'); + } + + if (!this.pageContextService) { + throw new Error('PageContextService not available'); + } + + try { + const state = await this.pageContextService.getPluginState(this.config.pluginId); + + // Notify restore callbacks if state was found + if (state !== null) { + this.restoreCallbacks.forEach(callback => { + try { + callback(state); + } catch (error) { + console.error(`[PluginStateService] Error in restore callback:`, error); + } + }); + } + + console.log(`[PluginStateService] State retrieved for plugin ${this.config.pluginId}`); + return state; + } catch (error) { + console.error(`[PluginStateService] Error retrieving state for plugin ${this.config.pluginId}:`, error); + throw error; + } + } + + async clearState(): Promise { + if (!this.config) { + throw new Error('Plugin state service not configured. Call configure() first.'); + } + + if (!this.pageContextService) { + throw new Error('PageContextService not available'); + } + + try { + await this.pageContextService.clearPluginState(this.config.pluginId); + + // Notify clear callbacks + this.clearCallbacks.forEach(callback => { + try { + callback(); + } catch (error) { + console.error(`[PluginStateService] Error in clear callback:`, error); + } + }); + + console.log(`[PluginStateService] State cleared for plugin ${this.config.pluginId}`); + } catch (error) { + console.error(`[PluginStateService] Error clearing state for plugin ${this.config.pluginId}:`, error); + throw error; + } + } + + validateState(state: any): boolean { + if (!this.config || !this.config.stateSchema) { + return true; // No schema means no validation required + } + + try { + for (const [key, fieldSchema] of Object.entries(this.config.stateSchema)) { + const value = state[key]; + + // Check required fields + if (fieldSchema.required && (value === undefined || value === null)) { + console.error(`[PluginStateService] Required field '${key}' is missing`); + return false; + } + + // Skip validation for undefined/null optional fields + if (value === undefined || value === null) { + continue; + } + + // Type validation + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== fieldSchema.type) { + console.error(`[PluginStateService] Type mismatch for field '${key}': expected ${fieldSchema.type}, got ${actualType}`); + return false; + } + } + + return true; + } catch (error) { + console.error(`[PluginStateService] Error validating state:`, error); + return false; + } + } + + sanitizeState(state: any): any { + if (!this.config || !this.config.stateSchema) { + return state; // No schema means no sanitization needed + } + + const sanitized: any = {}; + + for (const [key, fieldSchema] of Object.entries(this.config.stateSchema)) { + const value = state[key]; + + // Handle missing required fields with defaults + if (fieldSchema.required && (value === undefined || value === null)) { + if (fieldSchema.default !== undefined) { + sanitized[key] = fieldSchema.default; + } + continue; + } + + // Handle optional fields with defaults + if (value === undefined || value === null) { + if (fieldSchema.default !== undefined) { + sanitized[key] = fieldSchema.default; + } + continue; + } + + // Type coercion for mismatched types + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== fieldSchema.type) { + if (fieldSchema.default !== undefined) { + sanitized[key] = fieldSchema.default; + } + continue; + } + + sanitized[key] = value; + } + + return sanitized; + } + + onSave(callback: (state: any) => void): () => void { + this.saveCallbacks.push(callback); + + // Return unsubscribe function + return () => { + const index = this.saveCallbacks.indexOf(callback); + if (index > -1) { + this.saveCallbacks.splice(index, 1); + } + }; + } + + onRestore(callback: (state: any) => void): () => void { + this.restoreCallbacks.push(callback); + + // Return unsubscribe function + return () => { + const index = this.restoreCallbacks.indexOf(callback); + if (index > -1) { + this.restoreCallbacks.splice(index, 1); + } + }; + } + + onClear(callback: () => void): () => void { + this.clearCallbacks.push(callback); + + // Return unsubscribe function + return () => { + const index = this.clearCallbacks.indexOf(callback); + if (index > -1) { + this.clearCallbacks.splice(index, 1); + } + }; + } +} + +// Factory function to create plugin-specific instances +export function createPluginStateService(pluginId: string): PluginStateServiceImpl { + return PluginStateServiceImpl.getInstance(pluginId); +} + +// Export the service interface for type checking +export { PluginStateServiceImpl }; \ No newline at end of file diff --git a/frontend/src/services/SessionStorageManager.ts b/frontend/src/services/SessionStorageManager.ts new file mode 100644 index 0000000..3f90b87 --- /dev/null +++ b/frontend/src/services/SessionStorageManager.ts @@ -0,0 +1,484 @@ +/** + * Enhanced Session Storage Manager for Plugin State Persistence + * Provides optimized session storage with cleanup, monitoring, and error handling + */ + +import { StateSerializationUtils, SerializationResult, DeserializationResult } from './StateSerializationUtils'; + +export interface StorageMetadata { + pluginId: string; + timestamp: number; + size: number; + version: string; + compressed: boolean; +} + +export interface StorageStats { + totalPlugins: number; + totalSize: number; + availableSpace: number; + oldestEntry: number; + newestEntry: number; + pluginStats: Map; +} + +export interface StorageOptions { + compression?: boolean; + compressionThreshold?: number; + maxAge?: number; // Maximum age in milliseconds + maxSize?: number; // Maximum size per plugin + enableMetrics?: boolean; +} + +export class SessionStorageManager { + private static readonly STORAGE_PREFIX = 'braindrive_plugin_state_'; + private static readonly METADATA_PREFIX = 'braindrive_plugin_meta_'; + private static readonly GLOBAL_STATS_KEY = 'braindrive_storage_stats'; + + private static instance: SessionStorageManager; + private metrics: Map = new Map(); + private cleanupInterval?: NodeJS.Timeout; + + private constructor() { + this.initializeCleanup(); + this.loadMetrics(); + } + + public static getInstance(): SessionStorageManager { + if (!SessionStorageManager.instance) { + SessionStorageManager.instance = new SessionStorageManager(); + } + return SessionStorageManager.instance; + } + + /** + * Save plugin state with enhanced options + */ + async saveState( + pluginId: string, + state: any, + options: StorageOptions = {} + ): Promise<{ success: boolean; error?: string; size?: number }> { + try { + const { + compression = false, + compressionThreshold = 1024, + maxSize, + enableMetrics = true + } = options; + + // Check if plugin state exceeds size limit + const stateSize = StateSerializationUtils.calculateStateSize(state); + if (maxSize && stateSize > maxSize) { + return { + success: false, + error: `State size (${stateSize}) exceeds maximum allowed size (${maxSize})` + }; + } + + // Determine if compression should be used + const shouldCompress = compression && stateSize > compressionThreshold; + + // Serialize state + const serializationResult: SerializationResult = shouldCompress + ? StateSerializationUtils.serializeCompressed(state) + : StateSerializationUtils.serialize(state); + + if (!serializationResult.success) { + return { + success: false, + error: serializationResult.error + }; + } + + // Check available storage space + const availableSpace = this.getAvailableStorageSpace(); + const requiredSpace = serializationResult.size || 0; + + if (requiredSpace > availableSpace) { + // Try to free up space + const freedSpace = await this.freeUpSpace(requiredSpace - availableSpace); + if (freedSpace < requiredSpace - availableSpace) { + return { + success: false, + error: `Insufficient storage space. Required: ${requiredSpace}, Available: ${availableSpace + freedSpace}` + }; + } + } + + // Create metadata + const metadata: StorageMetadata = { + pluginId, + timestamp: Date.now(), + size: serializationResult.size || 0, + version: '1.0.0', + compressed: shouldCompress + }; + + // Save to session storage + const storageKey = this.getStorageKey(pluginId); + const metadataKey = this.getMetadataKey(pluginId); + + sessionStorage.setItem(storageKey, serializationResult.data!); + sessionStorage.setItem(metadataKey, JSON.stringify(metadata)); + + // Update metrics + if (enableMetrics) { + this.updateMetrics(pluginId, 'save'); + } + + // Update global stats + this.updateGlobalStats(); + + console.log(`[SessionStorageManager] Saved state for plugin ${pluginId} (${metadata.size} bytes, compressed: ${shouldCompress})`); + + return { + success: true, + size: metadata.size + }; + + } catch (error) { + console.error(`[SessionStorageManager] Error saving state for plugin ${pluginId}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Load plugin state with enhanced options + */ + async loadState( + pluginId: string, + options: StorageOptions = {} + ): Promise<{ success: boolean; data?: any; error?: string; metadata?: StorageMetadata }> { + try { + const { maxAge, enableMetrics = true } = options; + + const storageKey = this.getStorageKey(pluginId); + const metadataKey = this.getMetadataKey(pluginId); + + // Get stored data and metadata + const storedData = sessionStorage.getItem(storageKey); + const storedMetadata = sessionStorage.getItem(metadataKey); + + if (!storedData) { + return { + success: true, + data: null + }; + } + + // Parse metadata + let metadata: StorageMetadata | null = null; + if (storedMetadata) { + try { + metadata = JSON.parse(storedMetadata); + } catch (error) { + console.warn(`[SessionStorageManager] Invalid metadata for plugin ${pluginId}`); + } + } + + // Check age limit + if (maxAge && metadata && (Date.now() - metadata.timestamp) > maxAge) { + // Data is too old, remove it + await this.clearState(pluginId); + return { + success: true, + data: null + }; + } + + // Deserialize data + const deserializationResult: DeserializationResult = metadata?.compressed + ? StateSerializationUtils.deserializeCompressed(storedData) + : StateSerializationUtils.deserialize(storedData); + + if (!deserializationResult.success) { + return { + success: false, + error: deserializationResult.error + }; + } + + // Update metrics + if (enableMetrics) { + this.updateMetrics(pluginId, 'load'); + } + + console.log(`[SessionStorageManager] Loaded state for plugin ${pluginId}`); + + return { + success: true, + data: deserializationResult.data, + metadata: metadata || undefined + }; + + } catch (error) { + console.error(`[SessionStorageManager] Error loading state for plugin ${pluginId}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Clear plugin state + */ + async clearState(pluginId: string): Promise<{ success: boolean; error?: string }> { + try { + const storageKey = this.getStorageKey(pluginId); + const metadataKey = this.getMetadataKey(pluginId); + + sessionStorage.removeItem(storageKey); + sessionStorage.removeItem(metadataKey); + + // Remove from metrics + this.metrics.delete(pluginId); + + // Update global stats + this.updateGlobalStats(); + + console.log(`[SessionStorageManager] Cleared state for plugin ${pluginId}`); + + return { success: true }; + + } catch (error) { + console.error(`[SessionStorageManager] Error clearing state for plugin ${pluginId}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Get storage statistics + */ + getStorageStats(): StorageStats { + const pluginStats = new Map(); + let totalSize = 0; + let oldestEntry = Date.now(); + let newestEntry = 0; + let totalPlugins = 0; + + // Iterate through all storage items + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (!key || !key.startsWith(SessionStorageManager.STORAGE_PREFIX)) { + continue; + } + + const pluginId = key.replace(SessionStorageManager.STORAGE_PREFIX, ''); + const metadataKey = this.getMetadataKey(pluginId); + const metadataStr = sessionStorage.getItem(metadataKey); + + if (metadataStr) { + try { + const metadata: StorageMetadata = JSON.parse(metadataStr); + const metrics = this.metrics.get(pluginId) || { accessCount: 0, lastAccessed: metadata.timestamp }; + + pluginStats.set(pluginId, { + size: metadata.size, + lastAccessed: metrics.lastAccessed, + accessCount: metrics.accessCount + }); + + totalSize += metadata.size; + totalPlugins++; + + if (metadata.timestamp < oldestEntry) { + oldestEntry = metadata.timestamp; + } + if (metadata.timestamp > newestEntry) { + newestEntry = metadata.timestamp; + } + } catch (error) { + console.warn(`[SessionStorageManager] Invalid metadata for plugin ${pluginId}`); + } + } + } + + return { + totalPlugins, + totalSize, + availableSpace: this.getAvailableStorageSpace(), + oldestEntry: totalPlugins > 0 ? oldestEntry : 0, + newestEntry: totalPlugins > 0 ? newestEntry : 0, + pluginStats + }; + } + + /** + * Clean up old or unused state entries + */ + async cleanupOldEntries(maxAge: number): Promise { + const now = Date.now(); + let cleanedCount = 0; + + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (!key || !key.startsWith(SessionStorageManager.METADATA_PREFIX)) { + continue; + } + + const metadataStr = sessionStorage.getItem(key); + if (!metadataStr) continue; + + try { + const metadata: StorageMetadata = JSON.parse(metadataStr); + if ((now - metadata.timestamp) > maxAge) { + await this.clearState(metadata.pluginId); + cleanedCount++; + } + } catch (error) { + // Remove invalid metadata + sessionStorage.removeItem(key); + cleanedCount++; + } + } + + console.log(`[SessionStorageManager] Cleaned up ${cleanedCount} old entries`); + return cleanedCount; + } + + /** + * Get list of all stored plugin IDs + */ + getStoredPluginIds(): string[] { + const pluginIds: string[] = []; + + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key && key.startsWith(SessionStorageManager.STORAGE_PREFIX)) { + const pluginId = key.replace(SessionStorageManager.STORAGE_PREFIX, ''); + pluginIds.push(pluginId); + } + } + + return pluginIds; + } + + /** + * Destroy the manager and clean up resources + */ + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + this.saveMetrics(); + } + + // Private helper methods + + private getStorageKey(pluginId: string): string { + return `${SessionStorageManager.STORAGE_PREFIX}${pluginId}`; + } + + private getMetadataKey(pluginId: string): string { + return `${SessionStorageManager.METADATA_PREFIX}${pluginId}`; + } + + private getAvailableStorageSpace(): number { + try { + // Estimate available space (sessionStorage typically has ~5-10MB limit) + const testKey = 'braindrive_space_test'; + const testData = 'x'.repeat(1024); // 1KB test + let availableSpace = 0; + + // Try to determine available space + for (let i = 0; i < 10240; i++) { // Test up to ~10MB + try { + sessionStorage.setItem(`${testKey}_${i}`, testData); + availableSpace += 1024; + } catch (error) { + break; + } + } + + // Clean up test data + for (let i = 0; i < 10240; i++) { + sessionStorage.removeItem(`${testKey}_${i}`); + } + + return availableSpace; + } catch (error) { + return 0; + } + } + + private async freeUpSpace(requiredSpace: number): Promise { + const stats = this.getStorageStats(); + let freedSpace = 0; + + // Sort plugins by last accessed time (oldest first) + const sortedPlugins = Array.from(stats.pluginStats.entries()) + .sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed); + + for (const [pluginId, pluginStat] of sortedPlugins) { + if (freedSpace >= requiredSpace) { + break; + } + + await this.clearState(pluginId); + freedSpace += pluginStat.size; + console.log(`[SessionStorageManager] Freed ${pluginStat.size} bytes by removing state for plugin ${pluginId}`); + } + + return freedSpace; + } + + private updateMetrics(pluginId: string, operation: 'save' | 'load'): void { + const existing = this.metrics.get(pluginId) || { accessCount: 0, lastAccessed: 0 }; + this.metrics.set(pluginId, { + accessCount: existing.accessCount + 1, + lastAccessed: Date.now() + }); + } + + private updateGlobalStats(): void { + const stats = this.getStorageStats(); + sessionStorage.setItem(SessionStorageManager.GLOBAL_STATS_KEY, JSON.stringify({ + lastUpdated: Date.now(), + totalPlugins: stats.totalPlugins, + totalSize: stats.totalSize + })); + } + + private initializeCleanup(): void { + // Run cleanup every 5 minutes + this.cleanupInterval = setInterval(() => { + this.cleanupOldEntries(24 * 60 * 60 * 1000); // Clean entries older than 24 hours + }, 5 * 60 * 1000); + } + + private loadMetrics(): void { + try { + const metricsData = sessionStorage.getItem('braindrive_storage_metrics'); + if (metricsData) { + const parsed = JSON.parse(metricsData); + this.metrics = new Map(Object.entries(parsed)); + } + } catch (error) { + console.warn('[SessionStorageManager] Failed to load metrics:', error); + } + } + + private saveMetrics(): void { + try { + const metricsObj = Object.fromEntries(this.metrics); + sessionStorage.setItem('braindrive_storage_metrics', JSON.stringify(metricsObj)); + } catch (error) { + console.warn('[SessionStorageManager] Failed to save metrics:', error); + } + } +} + +// Export singleton instance +export const sessionStorageManager = SessionStorageManager.getInstance(); \ No newline at end of file diff --git a/frontend/src/services/StateConfigurationManager.ts b/frontend/src/services/StateConfigurationManager.ts new file mode 100644 index 0000000..105c9e1 --- /dev/null +++ b/frontend/src/services/StateConfigurationManager.ts @@ -0,0 +1,470 @@ +/** + * State Configuration Manager for Advanced Plugin State Management + * Provides enhanced configuration options and state filtering capabilities + */ + +import { PluginStateConfig } from './PageContextService'; +import { StateSerializationUtils, SerializationOptions } from './StateSerializationUtils'; + +export interface EnhancedPluginStateConfig extends PluginStateConfig { + // Advanced filtering options + excludeKeys?: string[]; + includePatterns?: RegExp[]; + excludePatterns?: RegExp[]; + + // State transformation options + transformers?: { + beforeSave?: (state: any) => any; + afterLoad?: (state: any) => any; + }; + + // Validation options + validation?: { + strict?: boolean; + allowUnknownKeys?: boolean; + customValidators?: Map boolean>; + }; + + // Storage optimization + compression?: { + enabled?: boolean; + threshold?: number; // Compress if state size exceeds this + }; + + // Lifecycle hooks + hooks?: { + beforeSave?: (state: any) => Promise | any; + afterSave?: (state: any) => Promise | void; + beforeLoad?: () => Promise | void; + afterLoad?: (state: any) => Promise | any; + onError?: (error: Error, operation: 'save' | 'load' | 'clear') => void; + }; + + // Performance options + performance?: { + debounceMs?: number; // Debounce save operations + maxRetries?: number; // Retry failed operations + timeout?: number; // Operation timeout + }; +} + +export interface StateFilterOptions { + preserveKeys?: string[]; + excludeKeys?: string[]; + includePatterns?: RegExp[]; + excludePatterns?: RegExp[]; + maxDepth?: number; +} + +export interface StateValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + sanitizedState?: any; +} + +export class StateConfigurationManager { + private configs: Map = new Map(); + private debounceTimers: Map = new Map(); + + /** + * Register an enhanced plugin state configuration + */ + registerConfig(config: EnhancedPluginStateConfig): void { + // Validate configuration + const validationResult = this.validateConfig(config); + if (!validationResult.valid) { + throw new Error(`Invalid configuration: ${validationResult.errors.join(', ')}`); + } + + // Set defaults + const enhancedConfig = this.applyDefaults(config); + + this.configs.set(config.pluginId, enhancedConfig); + console.log(`[StateConfigurationManager] Registered enhanced config for plugin ${config.pluginId}`); + } + + /** + * Get configuration for a plugin + */ + getConfig(pluginId: string): EnhancedPluginStateConfig | null { + return this.configs.get(pluginId) || null; + } + + /** + * Filter state based on configuration + */ + filterState(pluginId: string, state: any): any { + const config = this.configs.get(pluginId); + if (!config) { + return state; + } + + const filterOptions: StateFilterOptions = { + preserveKeys: config.preserveKeys, + excludeKeys: config.excludeKeys, + includePatterns: config.includePatterns, + excludePatterns: config.excludePatterns, + maxDepth: 10 // Default max depth + }; + + return this.applyStateFilter(state, filterOptions); + } + + /** + * Validate and sanitize state according to configuration + */ + validateAndSanitizeState(pluginId: string, state: any): StateValidationResult { + const config = this.configs.get(pluginId); + if (!config) { + return { valid: true, errors: [], warnings: [] }; + } + + const errors: string[] = []; + const warnings: string[] = []; + let sanitizedState = { ...state }; + + // Apply schema validation if present + if (config.stateSchema) { + const schemaResult = this.validateAgainstSchema(sanitizedState, config.stateSchema); + errors.push(...schemaResult.errors); + warnings.push(...schemaResult.warnings); + sanitizedState = schemaResult.sanitizedState || sanitizedState; + } + + // Apply custom validators + if (config.validation?.customValidators) { + const customResult = this.applyCustomValidators( + sanitizedState, + config.validation.customValidators + ); + errors.push(...customResult.errors); + warnings.push(...customResult.warnings); + } + + // Check for unknown keys if strict validation is enabled + if (config.validation?.strict && !config.validation?.allowUnknownKeys && config.stateSchema) { + const unknownKeys = Object.keys(sanitizedState).filter( + key => !config.stateSchema!.hasOwnProperty(key) + ); + if (unknownKeys.length > 0) { + if (config.validation.strict) { + errors.push(`Unknown keys not allowed: ${unknownKeys.join(', ')}`); + } else { + warnings.push(`Unknown keys found: ${unknownKeys.join(', ')}`); + } + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + sanitizedState + }; + } + + /** + * Apply state transformations + */ + async applyTransformations( + pluginId: string, + state: any, + phase: 'beforeSave' | 'afterLoad' + ): Promise { + const config = this.configs.get(pluginId); + if (!config?.transformers) { + return state; + } + + try { + const transformer = config.transformers[phase]; + if (transformer) { + return await transformer(state); + } + return state; + } catch (error) { + console.error(`[StateConfigurationManager] Transformation error for ${pluginId}:`, error); + return state; + } + } + + /** + * Execute lifecycle hooks + */ + async executeHook( + pluginId: string, + hookName: keyof NonNullable, + ...args: any[] + ): Promise { + const config = this.configs.get(pluginId); + const hook = config?.hooks?.[hookName] as any; + + if (!hook) { + return; + } + + try { + return await hook.apply(null, args); + } catch (error) { + console.error(`[StateConfigurationManager] Hook ${hookName} error for ${pluginId}:`, error); + + // Call error hook if available + if (config?.hooks?.onError) { + try { + await config.hooks.onError(error as Error, hookName as any); + } catch (hookError) { + console.error(`[StateConfigurationManager] Error hook failed:`, hookError); + } + } + } + } + + /** + * Get serialization options based on configuration + */ + getSerializationOptions(pluginId: string): SerializationOptions { + const config = this.configs.get(pluginId); + if (!config) { + return {}; + } + + const options: SerializationOptions = { + maxSize: config.maxStateSize, + maxDepth: 10 // Default + }; + + // Add custom serializers if provided + if (config.serialize) { + options.customSerializers = new Map([ + ['object', config.serialize] + ]); + } + + if (config.deserialize) { + options.customDeserializers = new Map([ + ['object', config.deserialize] + ]); + } + + return options; + } + + /** + * Check if compression should be used + */ + shouldCompress(pluginId: string, stateSize: number): boolean { + const config = this.configs.get(pluginId); + if (!config?.compression?.enabled) { + return false; + } + + const threshold = config.compression.threshold || 1024; // 1KB default + return stateSize > threshold; + } + + /** + * Get debounce delay for save operations + */ + getDebounceDelay(pluginId: string): number { + const config = this.configs.get(pluginId); + return config?.performance?.debounceMs || 0; + } + + /** + * Execute debounced operation + */ + executeDebouncedSave(pluginId: string, saveOperation: () => Promise): void { + const delay = this.getDebounceDelay(pluginId); + + if (delay <= 0) { + saveOperation(); + return; + } + + // Clear existing timer + const existingTimer = this.debounceTimers.get(pluginId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + // Set new timer + const timer = setTimeout(() => { + saveOperation(); + this.debounceTimers.delete(pluginId); + }, delay); + + this.debounceTimers.set(pluginId, timer); + } + + /** + * Clean up resources for a plugin + */ + cleanup(pluginId: string): void { + const timer = this.debounceTimers.get(pluginId); + if (timer) { + clearTimeout(timer); + this.debounceTimers.delete(pluginId); + } + + this.configs.delete(pluginId); + } + + // Private helper methods + + private validateConfig(config: EnhancedPluginStateConfig): StateValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!config.pluginId) { + errors.push('pluginId is required'); + } + + if (!['none', 'session', 'persistent', 'custom'].includes(config.stateStrategy)) { + errors.push('Invalid stateStrategy'); + } + + if (config.maxStateSize && config.maxStateSize < 0) { + errors.push('maxStateSize must be positive'); + } + + if (config.performance?.debounceMs && config.performance.debounceMs < 0) { + errors.push('debounceMs must be positive'); + } + + return { valid: errors.length === 0, errors, warnings }; + } + + private applyDefaults(config: EnhancedPluginStateConfig): EnhancedPluginStateConfig { + return { + ...config, + validation: { + strict: false, + allowUnknownKeys: true, + ...config.validation + }, + compression: { + enabled: false, + threshold: 1024, + ...config.compression + }, + performance: { + debounceMs: 0, + maxRetries: 3, + timeout: 5000, + ...config.performance + } + }; + } + + private applyStateFilter(state: any, options: StateFilterOptions): any { + if (!state || typeof state !== 'object') { + return state; + } + + const filtered: any = {}; + + for (const [key, value] of Object.entries(state)) { + // Check exclude keys + if (options.excludeKeys?.includes(key)) { + continue; + } + + // Check exclude patterns + if (options.excludePatterns?.some(pattern => pattern.test(key))) { + continue; + } + + // Check preserve keys (if specified, only include these) + if (options.preserveKeys && options.preserveKeys.length > 0) { + if (!options.preserveKeys.includes(key)) { + continue; + } + } + + // Check include patterns (if specified, key must match at least one) + if (options.includePatterns && options.includePatterns.length > 0) { + if (!options.includePatterns.some(pattern => pattern.test(key))) { + continue; + } + } + + filtered[key] = value; + } + + return filtered; + } + + private validateAgainstSchema( + state: any, + schema: NonNullable + ): { errors: string[]; warnings: string[]; sanitizedState: any } { + const errors: string[] = []; + const warnings: string[] = []; + const sanitizedState: any = {}; + + for (const [key, fieldSchema] of Object.entries(schema)) { + const value = state[key]; + + // Check required fields + if (fieldSchema.required && (value === undefined || value === null)) { + if (fieldSchema.default !== undefined) { + sanitizedState[key] = fieldSchema.default; + warnings.push(`Using default value for required field '${key}'`); + } else { + errors.push(`Required field '${key}' is missing`); + } + continue; + } + + // Skip undefined/null optional fields + if (value === undefined || value === null) { + if (fieldSchema.default !== undefined) { + sanitizedState[key] = fieldSchema.default; + } + continue; + } + + // Type validation + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== fieldSchema.type) { + if (fieldSchema.default !== undefined) { + sanitizedState[key] = fieldSchema.default; + warnings.push(`Type mismatch for field '${key}', using default value`); + } else { + errors.push(`Type mismatch for field '${key}': expected ${fieldSchema.type}, got ${actualType}`); + } + continue; + } + + sanitizedState[key] = value; + } + + return { errors, warnings, sanitizedState }; + } + + private applyCustomValidators( + state: any, + validators: Map boolean> + ): { errors: string[]; warnings: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + + for (const [key, validator] of validators.entries()) { + if (state.hasOwnProperty(key)) { + try { + if (!validator(state[key])) { + errors.push(`Custom validation failed for field '${key}'`); + } + } catch (error) { + warnings.push(`Custom validator error for field '${key}': ${error}`); + } + } + } + + return { errors, warnings }; + } +} + +// Export singleton instance +export const stateConfigurationManager = new StateConfigurationManager(); \ No newline at end of file diff --git a/frontend/src/services/StateRestorationManager.ts b/frontend/src/services/StateRestorationManager.ts new file mode 100644 index 0000000..dd9c327 --- /dev/null +++ b/frontend/src/services/StateRestorationManager.ts @@ -0,0 +1,556 @@ +import { AbstractBaseService } from './base/BaseService'; +import { EnhancedPluginStateConfig } from './StateConfigurationManager'; +import { databasePersistenceManager, DatabaseStateRecord } from './DatabasePersistenceManager'; +import { sessionStorageManager } from './SessionStorageManager'; + +export interface RestorationOptions { + preferDatabase?: boolean; + fallbackToSession?: boolean; + pageSpecific?: boolean; + pageId?: string; // Page ID for page-specific state + stateKey?: string; // State key for namespaced state + includeInactive?: boolean; + maxAge?: number; // Maximum age in milliseconds +} + +export interface RestorationResult { + success: boolean; + data?: any; + source: 'database' | 'session' | 'default' | 'none'; + partial?: boolean; + errors?: string[]; + metadata?: { + lastAccessed?: string; + version?: number; + deviceId?: string; + stateSize?: number; + }; +} + +export interface StateRestorationManagerInterface { + // Core restoration methods + restorePluginState(pluginId: string, config: EnhancedPluginStateConfig, options?: RestorationOptions): Promise; + restorePartialState(pluginId: string, keys: string[], config: EnhancedPluginStateConfig, options?: RestorationOptions): Promise; + + // Fallback and recovery methods + restoreWithFallback(pluginId: string, config: EnhancedPluginStateConfig, fallbackData?: any): Promise; + recoverFromCorruption(pluginId: string, config: EnhancedPluginStateConfig): Promise; + + // Migration and sync methods + migrateSessionToDatabase(pluginId: string, config: EnhancedPluginStateConfig): Promise; + syncStateAcrossDevices(pluginId: string, config: EnhancedPluginStateConfig): Promise; + + // Validation and cleanup + validateRestoredState(state: any, config: EnhancedPluginStateConfig): boolean; + cleanupStaleStates(maxAge: number): Promise; +} + +class StateRestorationManagerImpl extends AbstractBaseService implements StateRestorationManagerInterface { + private restorationCache: Map = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + constructor() { + super( + 'state-restoration-manager', + { major: 1, minor: 0, patch: 0 }, + [ + { + name: 'state-restoration', + description: 'Plugin state restoration with fallback mechanisms', + version: '1.0.0' + }, + { + name: 'cross-device-sync', + description: 'Cross-device state synchronization', + version: '1.0.0' + }, + { + name: 'migration-support', + description: 'Session to database migration support', + version: '1.0.0' + } + ] + ); + } + + async initialize(): Promise { + console.log('State restoration manager initialized'); + + // Start periodic cleanup of cache + setInterval(() => { + this.cleanupCache(); + }, this.CACHE_TTL); + } + + async destroy(): Promise { + this.restorationCache.clear(); + console.log('State restoration manager destroyed'); + } + + private cleanupCache(): void { + const now = Date.now(); + for (const [key, value] of this.restorationCache.entries()) { + if (now - value.timestamp > this.CACHE_TTL) { + this.restorationCache.delete(key); + } + } + } + + private getCacheKey(pluginId: string, pageId?: string, stateKey?: string): string { + return `${pluginId}:${pageId || 'global'}:${stateKey || 'default'}`; + } + + private getCachedState(pluginId: string, pageId?: string, stateKey?: string): any { + const cacheKey = this.getCacheKey(pluginId, pageId, stateKey); + const cached = this.restorationCache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + return cached.data; + } + + return null; + } + + private setCachedState(pluginId: string, data: any, source: string, pageId?: string, stateKey?: string): void { + const cacheKey = this.getCacheKey(pluginId, pageId, stateKey); + this.restorationCache.set(cacheKey, { + data, + timestamp: Date.now(), + source + }); + } + + async restorePluginState( + pluginId: string, + config: EnhancedPluginStateConfig, + options: RestorationOptions = {} + ): Promise { + try { + // Check cache first + const cachedState = this.getCachedState(pluginId, options.pageSpecific ? options.pageId : undefined, options.stateKey); + if (cachedState) { + return { + success: true, + data: cachedState, + source: 'session', // Cache is considered session-level + metadata: { stateSize: JSON.stringify(cachedState).length } + }; + } + + // Determine restoration strategy based on config and options + const preferDatabase = options.preferDatabase ?? (config.stateStrategy === 'persistent'); + const fallbackToSession = options.fallbackToSession ?? true; + + let result: RestorationResult; + + if (preferDatabase) { + result = await this.restoreFromDatabase(pluginId, config, options); + + if (!result.success && fallbackToSession) { + result = await this.restoreFromSession(pluginId, config, options); + } + } else { + result = await this.restoreFromSession(pluginId, config, options); + + if (!result.success && config.stateStrategy === 'persistent') { + result = await this.restoreFromDatabase(pluginId, config, options); + } + } + + // Apply fallback to default values if restoration failed + if (!result.success) { + result = this.restoreFromDefaults(config); + } + + // Validate restored state + if (result.success && result.data) { + const isValid = this.validateRestoredState(result.data, config); + if (!isValid) { + result = this.restoreFromDefaults(config); + result.partial = true; + result.errors = ['State validation failed, using defaults']; + } + } + + // Cache successful restoration + if (result.success && result.data) { + this.setCachedState( + pluginId, + result.data, + result.source, + options.pageSpecific ? config.pageId : undefined + ); + } + + return result; + + } catch (error) { + console.error('State restoration error:', error); + return { + success: false, + source: 'none', + errors: [error instanceof Error ? error.message : 'Unknown error'] + }; + } + } + + private async restoreFromDatabase( + pluginId: string, + config: EnhancedPluginStateConfig, + options: RestorationOptions + ): Promise { + try { + const loadResult = await databasePersistenceManager.loadState(pluginId, { + pageId: options.pageSpecific ? options.pageId : undefined, + includeInactive: options.includeInactive + }); + + if (!loadResult.success || !loadResult.data) { + return { + success: false, + source: 'database', + errors: [loadResult.error || 'No database state found'] + }; + } + + // Check age if specified + if (options.maxAge && loadResult.record) { + const stateAge = Date.now() - new Date(loadResult.record.last_accessed).getTime(); + if (stateAge > options.maxAge) { + return { + success: false, + source: 'database', + errors: ['State too old'] + }; + } + } + + return { + success: true, + data: loadResult.data, + source: 'database', + metadata: loadResult.record ? { + lastAccessed: loadResult.record.last_accessed, + version: loadResult.record.version, + deviceId: loadResult.record.device_id, + stateSize: loadResult.record.state_size + } : undefined + }; + + } catch (error) { + return { + success: false, + source: 'database', + errors: [error instanceof Error ? error.message : 'Database error'] + }; + } + } + + private async restoreFromSession( + pluginId: string, + config: EnhancedPluginStateConfig, + options: RestorationOptions + ): Promise { + try { + const loadResult = await sessionStorageManager.loadState(pluginId, { + maxAge: options.maxAge + }); + + if (!loadResult.success || !loadResult.data) { + return { + success: false, + source: 'session', + errors: [loadResult.error || 'No session state found'] + }; + } + + return { + success: true, + data: loadResult.data, + source: 'session', + metadata: { + stateSize: JSON.stringify(loadResult.data).length + } + }; + + } catch (error) { + return { + success: false, + source: 'session', + errors: [error instanceof Error ? error.message : 'Session error'] + }; + } + } + + private restoreFromDefaults(config: EnhancedPluginStateConfig): RestorationResult { + try { + const defaultState: any = {}; + + // Apply schema defaults + if (config.stateSchema) { + Object.entries(config.stateSchema).forEach(([key, schema]) => { + if (schema.default !== undefined) { + defaultState[key] = schema.default; + } + }); + } + + return { + success: true, + data: defaultState, + source: 'default' + }; + + } catch (error) { + return { + success: false, + source: 'default', + errors: [error instanceof Error ? error.message : 'Default restoration error'] + }; + } + } + + async restorePartialState( + pluginId: string, + keys: string[], + config: EnhancedPluginStateConfig, + options: RestorationOptions = {} + ): Promise { + try { + // First restore full state + const fullResult = await this.restorePluginState(pluginId, config, options); + + if (!fullResult.success || !fullResult.data) { + return fullResult; + } + + // Extract only requested keys + const partialData: any = {}; + const errors: string[] = []; + + keys.forEach(key => { + if (fullResult.data.hasOwnProperty(key)) { + partialData[key] = fullResult.data[key]; + } else { + // Try to get default value from schema + if (config.stateSchema?.[key]?.default !== undefined) { + partialData[key] = config.stateSchema[key].default; + } else { + errors.push(`Key '${key}' not found in state`); + } + } + }); + + return { + success: true, + data: partialData, + source: fullResult.source, + partial: true, + errors: errors.length > 0 ? errors : undefined, + metadata: fullResult.metadata + }; + + } catch (error) { + return { + success: false, + source: 'none', + partial: true, + errors: [error instanceof Error ? error.message : 'Partial restoration error'] + }; + } + } + + async restoreWithFallback( + pluginId: string, + config: EnhancedPluginStateConfig, + fallbackData?: any + ): Promise { + try { + // Try normal restoration first + const result = await this.restorePluginState(pluginId, config); + + if (result.success) { + return result; + } + + // Use provided fallback data + if (fallbackData) { + const isValid = this.validateRestoredState(fallbackData, config); + return { + success: true, + data: isValid ? fallbackData : this.restoreFromDefaults(config).data, + source: 'default', + partial: !isValid, + errors: isValid ? undefined : ['Fallback data validation failed, using defaults'] + }; + } + + // Use schema defaults as final fallback + return this.restoreFromDefaults(config); + + } catch (error) { + return { + success: false, + source: 'none', + errors: [error instanceof Error ? error.message : 'Fallback restoration error'] + }; + } + } + + async recoverFromCorruption( + pluginId: string, + config: EnhancedPluginStateConfig + ): Promise { + try { + console.warn(`Attempting corruption recovery for plugin: ${pluginId}`); + + // Try to restore from database history if available + // This would require additional API endpoints for history access + + // For now, clear corrupted state and restore defaults + await sessionStorageManager.clearState(pluginId); + + if (config.stateStrategy === 'persistent') { + await databasePersistenceManager.clearState(pluginId); + } + + // Clear cache + this.restorationCache.delete(this.getCacheKey(pluginId)); + + // Restore from defaults + const result = this.restoreFromDefaults(config); + result.errors = ['State corruption detected, restored from defaults']; + + return result; + + } catch (error) { + return { + success: false, + source: 'none', + errors: [error instanceof Error ? error.message : 'Corruption recovery error'] + }; + } + } + + async migrateSessionToDatabase( + pluginId: string, + config: EnhancedPluginStateConfig + ): Promise { + try { + if (config.stateStrategy !== 'persistent') { + return true; // No migration needed + } + + return await databasePersistenceManager.migrateFromSession(pluginId); + + } catch (error) { + console.error('Migration error:', error); + return false; + } + } + + async syncStateAcrossDevices( + pluginId: string, + config: EnhancedPluginStateConfig + ): Promise { + try { + if (config.stateStrategy !== 'persistent') { + return { + success: false, + source: 'none', + errors: ['Cross-device sync requires persistent strategy'] + }; + } + + // Get latest state from database + const result = await this.restoreFromDatabase(pluginId, config, { + includeInactive: false + }); + + if (result.success) { + // Update cache with synced state + this.setCachedState(pluginId, result.data, 'database'); + } + + return result; + + } catch (error) { + return { + success: false, + source: 'none', + errors: [error instanceof Error ? error.message : 'Sync error'] + }; + } + } + + validateRestoredState(state: any, config: EnhancedPluginStateConfig): boolean { + try { + if (!state || typeof state !== 'object') { + return false; + } + + // Validate against schema if provided + if (config.stateSchema) { + for (const [key, schema] of Object.entries(config.stateSchema)) { + if (schema.required && !(key in state)) { + return false; + } + + if (key in state) { + const value = state[key]; + const expectedType = schema.type; + + // Type validation + if (expectedType === 'array' && !Array.isArray(value)) { + return false; + } else if (expectedType === 'object' && (typeof value !== 'object' || Array.isArray(value))) { + return false; + } else if (expectedType !== 'array' && expectedType !== 'object' && typeof value !== expectedType) { + return false; + } + } + } + } + + // Custom validation if provided + if (config.validation?.customValidators) { + for (const [key, validator] of config.validation.customValidators.entries()) { + if (key in state && !validator(state[key])) { + return false; + } + } + } + + return true; + + } catch (error) { + console.error('State validation error:', error); + return false; + } + } + + async cleanupStaleStates(maxAge: number): Promise { + try { + let cleanedCount = 0; + + // Cleanup database states + cleanedCount += await databasePersistenceManager.cleanupExpiredStates(); + + // Cleanup session states (this would need to be implemented in SessionStorageManager) + // cleanedCount += await sessionStorageManager.cleanupStaleStates(maxAge); + + // Cleanup cache + this.cleanupCache(); + + return cleanedCount; + + } catch (error) { + console.error('Cleanup error:', error); + return 0; + } + } +} + +// Export singleton instance +export const stateRestorationManager = new StateRestorationManagerImpl(); +export default stateRestorationManager; \ No newline at end of file diff --git a/frontend/src/services/StateSerializationUtils.ts b/frontend/src/services/StateSerializationUtils.ts new file mode 100644 index 0000000..1bf3c5c --- /dev/null +++ b/frontend/src/services/StateSerializationUtils.ts @@ -0,0 +1,419 @@ +/** + * State Serialization Utilities for Plugin State Management + * Provides safe serialization/deserialization with error handling and validation + */ + +export interface SerializationOptions { + maxDepth?: number; + maxSize?: number; + allowedTypes?: string[]; + customSerializers?: Map any>; + customDeserializers?: Map any>; +} + +export interface SerializationResult { + success: boolean; + data?: string; + error?: string; + size?: number; + warnings?: string[]; +} + +export interface DeserializationResult { + success: boolean; + data?: any; + error?: string; + warnings?: string[]; +} + +export class StateSerializationUtils { + private static readonly DEFAULT_MAX_DEPTH = 10; + private static readonly DEFAULT_MAX_SIZE = 1024 * 1024; // 1MB + private static readonly ALLOWED_TYPES = ['string', 'number', 'boolean', 'object', 'array']; + + /** + * Safely serialize state with comprehensive error handling + */ + static serialize(state: any, options: SerializationOptions = {}): SerializationResult { + const { + maxDepth = StateSerializationUtils.DEFAULT_MAX_DEPTH, + maxSize = StateSerializationUtils.DEFAULT_MAX_SIZE, + allowedTypes = StateSerializationUtils.ALLOWED_TYPES, + customSerializers = new Map() + } = options; + + const warnings: string[] = []; + + try { + // Pre-validation + const validationResult = StateSerializationUtils.validateForSerialization( + state, + { maxDepth, allowedTypes } + ); + + if (!validationResult.valid) { + return { + success: false, + error: `Validation failed: ${validationResult.errors.join(', ')}` + }; + } + + warnings.push(...validationResult.warnings); + + // Clean and prepare state for serialization + const cleanedState = StateSerializationUtils.cleanStateForSerialization( + state, + { maxDepth, customSerializers } + ); + + // Serialize + const serialized = JSON.stringify(cleanedState, (key, value) => { + // Handle custom serializers + const valueType = StateSerializationUtils.getValueType(value); + if (customSerializers.has(valueType)) { + try { + return customSerializers.get(valueType)!(value); + } catch (error) { + warnings.push(`Custom serializer failed for type ${valueType}: ${error}`); + return null; + } + } + return value; + }); + + // Check size limits + if (serialized.length > maxSize) { + return { + success: false, + error: `Serialized state size (${serialized.length}) exceeds maximum allowed size (${maxSize})` + }; + } + + return { + success: true, + data: serialized, + size: serialized.length, + warnings: warnings.length > 0 ? warnings : undefined + }; + + } catch (error) { + return { + success: false, + error: `Serialization failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + } + + /** + * Safely deserialize state with comprehensive error handling + */ + static deserialize(serializedState: string, options: SerializationOptions = {}): DeserializationResult { + const { + customDeserializers = new Map() + } = options; + + const warnings: string[] = []; + + try { + if (!serializedState || typeof serializedState !== 'string') { + return { + success: false, + error: 'Invalid serialized state: must be a non-empty string' + }; + } + + // Parse JSON + const parsed = JSON.parse(serializedState); + + // Apply custom deserializers + const deserialized = StateSerializationUtils.applyCustomDeserializers( + parsed, + customDeserializers, + warnings + ); + + // Post-deserialization validation + const validationResult = StateSerializationUtils.validateDeserialized(deserialized); + if (!validationResult.valid) { + warnings.push(...validationResult.warnings); + } + + return { + success: true, + data: deserialized, + warnings: warnings.length > 0 ? warnings : undefined + }; + + } catch (error) { + return { + success: false, + error: `Deserialization failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + } + + /** + * Create a compressed serialization for large states + */ + static serializeCompressed(state: any, options: SerializationOptions = {}): SerializationResult { + const result = StateSerializationUtils.serialize(state, options); + + if (!result.success || !result.data) { + return result; + } + + try { + // Simple compression using repeated pattern replacement + const compressed = StateSerializationUtils.compressString(result.data); + + return { + ...result, + data: compressed, + size: compressed.length + }; + } catch (error) { + // Fall back to uncompressed if compression fails + return result; + } + } + + /** + * Deserialize compressed state + */ + static deserializeCompressed(compressedState: string, options: SerializationOptions = {}): DeserializationResult { + try { + const decompressed = StateSerializationUtils.decompressString(compressedState); + return StateSerializationUtils.deserialize(decompressed, options); + } catch (error) { + // Try direct deserialization in case it's not compressed + return StateSerializationUtils.deserialize(compressedState, options); + } + } + + /** + * Validate state before serialization + */ + private static validateForSerialization( + state: any, + options: { maxDepth: number; allowedTypes: string[] } + ): { valid: boolean; errors: string[]; warnings: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + + const validateRecursive = (value: any, depth: number, path: string = 'root'): void => { + if (depth > options.maxDepth) { + errors.push(`Maximum depth (${options.maxDepth}) exceeded at path: ${path}`); + return; + } + + const valueType = StateSerializationUtils.getValueType(value); + + if (!options.allowedTypes.includes(valueType)) { + errors.push(`Unsupported type '${valueType}' at path: ${path}`); + return; + } + + // Check for circular references + if (typeof value === 'object' && value !== null) { + try { + JSON.stringify(value); + } catch (error) { + if (error instanceof Error && error.message.includes('circular')) { + errors.push(`Circular reference detected at path: ${path}`); + return; + } + } + + // Recursively validate object properties + if (Array.isArray(value)) { + value.forEach((item, index) => { + validateRecursive(item, depth + 1, `${path}[${index}]`); + }); + } else { + Object.keys(value).forEach(key => { + validateRecursive(value[key], depth + 1, `${path}.${key}`); + }); + } + } + + // Check for potentially problematic values + if (value === undefined) { + warnings.push(`Undefined value at path: ${path} (will be omitted in JSON)`); + } + + if (typeof value === 'function') { + warnings.push(`Function at path: ${path} (will be omitted in JSON)`); + } + }; + + validateRecursive(state, 0); + + return { + valid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Clean state for safe serialization + */ + private static cleanStateForSerialization( + state: any, + options: { maxDepth: number; customSerializers: Map any> } + ): any { + const cleanRecursive = (value: any, depth: number): any => { + if (depth > options.maxDepth) { + return null; + } + + if (value === null || value === undefined) { + return value; + } + + const valueType = StateSerializationUtils.getValueType(value); + + // Apply custom serializers + if (options.customSerializers.has(valueType)) { + try { + return options.customSerializers.get(valueType)!(value); + } catch (error) { + return null; + } + } + + if (typeof value === 'object') { + if (Array.isArray(value)) { + return value.map(item => cleanRecursive(item, depth + 1)); + } else { + const cleaned: any = {}; + Object.keys(value).forEach(key => { + const cleanedValue = cleanRecursive(value[key], depth + 1); + if (cleanedValue !== undefined) { + cleaned[key] = cleanedValue; + } + }); + return cleaned; + } + } + + return value; + }; + + return cleanRecursive(state, 0); + } + + /** + * Apply custom deserializers to parsed data + */ + private static applyCustomDeserializers( + data: any, + customDeserializers: Map any>, + warnings: string[] + ): any { + const applyRecursive = (value: any): any => { + if (value === null || value === undefined) { + return value; + } + + const valueType = StateSerializationUtils.getValueType(value); + + // Apply custom deserializers + if (customDeserializers.has(valueType)) { + try { + return customDeserializers.get(valueType)!(value); + } catch (error) { + warnings.push(`Custom deserializer failed for type ${valueType}: ${error}`); + return value; + } + } + + if (typeof value === 'object') { + if (Array.isArray(value)) { + return value.map(item => applyRecursive(item)); + } else { + const result: any = {}; + Object.keys(value).forEach(key => { + result[key] = applyRecursive(value[key]); + }); + return result; + } + } + + return value; + }; + + return applyRecursive(data); + } + + /** + * Validate deserialized data + */ + private static validateDeserialized(data: any): { valid: boolean; warnings: string[] } { + const warnings: string[] = []; + + // Basic validation - can be extended + if (data === null || data === undefined) { + warnings.push('Deserialized data is null or undefined'); + } + + return { + valid: true, // Currently always valid, but can be enhanced + warnings + }; + } + + /** + * Get the type of a value for serialization purposes + */ + private static getValueType(value: any): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; + } + + /** + * Simple string compression using pattern replacement + */ + private static compressString(str: string): string { + // Simple compression - replace common JSON patterns + return str + .replace(/{"([^"]+)":/g, '{$1:') + .replace(/,"([^"]+)":/g, ',$1:') + .replace(/\s+/g, ' ') + .trim(); + } + + /** + * Simple string decompression + */ + private static decompressString(str: string): string { + // Reverse the compression - add quotes back to property names + return str + .replace(/{([^":]+):/g, '{"$1":') + .replace(/,([^":]+):/g, ',"$1":'); + } + + /** + * Calculate the memory footprint of a state object + */ + static calculateStateSize(state: any): number { + try { + return JSON.stringify(state).length; + } catch (error) { + return 0; + } + } + + /** + * Check if state can be safely serialized + */ + static canSerialize(state: any): boolean { + try { + JSON.stringify(state); + return true; + } catch (error) { + return false; + } + } +} \ No newline at end of file diff --git a/frontend/src/test/BrainDriveBasicAIChatStateTest.tsx b/frontend/src/test/BrainDriveBasicAIChatStateTest.tsx new file mode 100644 index 0000000..b514b87 --- /dev/null +++ b/frontend/src/test/BrainDriveBasicAIChatStateTest.tsx @@ -0,0 +1,579 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Button, Typography, Paper, Alert, Chip, Divider } from '@mui/material'; +import { useService } from '../contexts/ServiceContext'; +import { PluginStateServiceInterface, PluginStateServiceImpl } from '../services/PluginStateService'; +import { EnhancedPageContextServiceInterface } from '../services/PageContextService'; +import { PluginStateFactoryInterface } from '../services/PluginStateFactory'; +import { ServiceBridgeManager, getServiceAvailability, getAllServiceAvailability } from '../utils/serviceBridge'; +import { BaseService } from '../services/base/BaseService'; + +interface TestState { + messages: Array<{ + id: string; + sender: 'user' | 'ai'; + content: string; + timestamp: string; + }>; + selectedModel: { + name: string; + provider: string; + } | null; + conversationId: string | null; + useStreaming: boolean; + currentTheme: string; + inputText: string; +} + +interface TestResults { + serviceBridgeIntegration: boolean; + stateConfigurationSetup: boolean; + stateSaveOperation: boolean; + stateRestoreOperation: boolean; + stateClearOperation: boolean; + pageNavigationPersistence: boolean; + errorHandling: boolean; + serviceAvailability: boolean; +} + +/** + * Comprehensive test component for BrainDriveBasicAIChat plugin state persistence + */ +export const BrainDriveBasicAIChatStateTest: React.FC = () => { + const [testState, setTestState] = useState({ + messages: [], + selectedModel: null, + conversationId: null, + useStreaming: true, + currentTheme: 'light', + inputText: '' + }); + + const [testResults, setTestResults] = useState({ + serviceBridgeIntegration: false, + stateConfigurationSetup: false, + stateSaveOperation: false, + stateRestoreOperation: false, + stateClearOperation: false, + pageNavigationPersistence: false, + errorHandling: false, + serviceAvailability: false + }); + + const [isRunning, setIsRunning] = useState(false); + const [currentTest, setCurrentTest] = useState(''); + const [logs, setLogs] = useState([]); + const [pluginStateService, setPluginStateService] = useState(null); + + // Get services + const pageContextService = useService('pageContext') as any; + + const addLog = (message: string) => { + const timestamp = new Date().toISOString(); + setLogs(prev => [...prev, `[${timestamp}] ${message}`]); + console.log(`[BrainDriveBasicAIChatStateTest] ${message}`); + }; + + const updateTestResult = (test: keyof TestResults, result: boolean) => { + setTestResults(prev => ({ ...prev, [test]: result })); + }; + + /** + * Test 1: Service Bridge Integration + */ + const testServiceBridgeIntegration = async (): Promise => { + setCurrentTest('Testing Service Bridge Integration'); + addLog('Starting service bridge integration test...'); + + try { + // Test service availability checks + const bridgeManager = ServiceBridgeManager.getInstance(); + const availabilityChecks = getAllServiceAvailability(); + + addLog(`Found ${availabilityChecks.length} service availability checks`); + + // Check for required services + const requiredServices = ['pageContext', 'pluginStateFactory']; + let allServicesAvailable = true; + + for (const serviceName of requiredServices) { + const availability = getServiceAvailability(serviceName); + if (!availability || !availability.isAvailable) { + addLog(`ERROR: Required service '${serviceName}' is not available`); + allServicesAvailable = false; + } else { + addLog(`✓ Service '${serviceName}' is available (version: ${availability.version || 'unknown'})`); + } + } + + // Test error handling + try { + const mockGetService = (name: string) => { + if (name === 'nonexistent') { + return null; + } + return { testMethod: () => 'test' }; + }; + + const { serviceBridges, errors } = bridgeManager.createServiceBridges( + { nonexistent: { methods: ['testMethod'] } }, + mockGetService + ); + + if (errors.length > 0) { + addLog(`✓ Error handling working: ${errors[0].error}`); + } + } catch (error) { + addLog(`ERROR: Service bridge error handling failed: ${error}`); + return false; + } + + addLog('Service bridge integration test completed successfully'); + return allServicesAvailable; + } catch (error) { + addLog(`ERROR: Service bridge integration test failed: ${error}`); + return false; + } + }; + + /** + * Test 2: State Configuration Setup + */ + const testStateConfigurationSetup = async (): Promise => { + setCurrentTest('Testing State Configuration Setup'); + addLog('Starting state configuration setup test...'); + + try { + // Get plugin state service for BrainDriveBasicAIChat + const pluginStateFactory = useService('pluginStateFactory') as any; + const stateService = pluginStateFactory.getPluginStateService('BrainDriveBasicAIChat'); + setPluginStateService(stateService); + + // Configure plugin state + const config = { + pluginId: 'BrainDriveBasicAIChat', + stateStrategy: 'session' as const, + preserveKeys: ['messages', 'selectedModel', 'conversationId', 'useStreaming', 'currentTheme', 'inputText'], + stateSchema: { + messages: { type: 'array' as const, required: false, default: [] }, + selectedModel: { type: 'object' as const, required: false, default: null }, + conversationId: { type: 'string' as const, required: false, default: null }, + useStreaming: { type: 'boolean' as const, required: false, default: true }, + currentTheme: { type: 'string' as const, required: false, default: 'light' }, + inputText: { type: 'string' as const, required: false, default: '' } + }, + maxStateSize: 1024 * 1024 // 1MB + }; + + stateService.configure(config); + addLog('✓ Plugin state configuration set successfully'); + + // Verify configuration + const retrievedConfig = stateService.getConfiguration(); + if (retrievedConfig && retrievedConfig.pluginId === 'BrainDriveBasicAIChat') { + addLog('✓ Configuration retrieval successful'); + return true; + } else { + addLog('ERROR: Configuration retrieval failed'); + return false; + } + } catch (error) { + addLog(`ERROR: State configuration setup failed: ${error}`); + return false; + } + }; + + /** + * Test 3: State Save Operation + */ + const testStateSaveOperation = async (): Promise => { + setCurrentTest('Testing State Save Operation'); + addLog('Starting state save operation test...'); + + if (!pluginStateService) { + addLog('ERROR: Plugin state service not available'); + return false; + } + + try { + // Create test state + const testStateData = { + messages: [ + { + id: 'test-1', + sender: 'user' as const, + content: 'Hello, this is a test message', + timestamp: new Date().toISOString() + }, + { + id: 'test-2', + sender: 'ai' as const, + content: 'Hello! I am an AI assistant. How can I help you today?', + timestamp: new Date().toISOString() + } + ], + selectedModel: { + name: 'gpt-4', + provider: 'openai' + }, + conversationId: 'test-conversation-123', + useStreaming: false, + currentTheme: 'dark', + inputText: 'Test input text' + }; + + // Save state + await pluginStateService.saveState(testStateData); + addLog('✓ State saved successfully'); + + // Update local test state + setTestState(testStateData); + + return true; + } catch (error) { + addLog(`ERROR: State save operation failed: ${error}`); + return false; + } + }; + + /** + * Test 4: State Restore Operation + */ + const testStateRestoreOperation = async (): Promise => { + setCurrentTest('Testing State Restore Operation'); + addLog('Starting state restore operation test...'); + + if (!pluginStateService) { + addLog('ERROR: Plugin state service not available'); + return false; + } + + try { + // Restore state + const restoredState = await pluginStateService.getState(); + + if (restoredState) { + addLog('✓ State restored successfully'); + addLog(`Restored ${restoredState.messages?.length || 0} messages`); + addLog(`Restored model: ${restoredState.selectedModel?.name || 'none'}`); + addLog(`Restored conversation ID: ${restoredState.conversationId || 'none'}`); + + // Verify restored data matches saved data + const isValid = + restoredState.messages?.length === 2 && + restoredState.selectedModel?.name === 'gpt-4' && + restoredState.conversationId === 'test-conversation-123' && + restoredState.useStreaming === false && + restoredState.currentTheme === 'dark' && + restoredState.inputText === 'Test input text'; + + if (isValid) { + addLog('✓ Restored state data is valid'); + return true; + } else { + addLog('ERROR: Restored state data is invalid'); + return false; + } + } else { + addLog('ERROR: No state was restored'); + return false; + } + } catch (error) { + addLog(`ERROR: State restore operation failed: ${error}`); + return false; + } + }; + + /** + * Test 5: State Clear Operation + */ + const testStateClearOperation = async (): Promise => { + setCurrentTest('Testing State Clear Operation'); + addLog('Starting state clear operation test...'); + + if (!pluginStateService) { + addLog('ERROR: Plugin state service not available'); + return false; + } + + try { + // Clear state + await pluginStateService.clearState(); + addLog('✓ State cleared successfully'); + + // Verify state is cleared + const clearedState = await pluginStateService.getState(); + + if (!clearedState || Object.keys(clearedState).length === 0) { + addLog('✓ State clear verification successful'); + return true; + } else { + addLog('ERROR: State was not properly cleared'); + return false; + } + } catch (error) { + addLog(`ERROR: State clear operation failed: ${error}`); + return false; + } + }; + + /** + * Test 6: Page Navigation Persistence + */ + const testPageNavigationPersistence = async (): Promise => { + setCurrentTest('Testing Page Navigation Persistence'); + addLog('Starting page navigation persistence test...'); + + try { + // This test simulates what happens during page navigation + // First, save some state + if (!pluginStateService) { + addLog('ERROR: Plugin state service not available'); + return false; + } + + const navigationTestState = { + messages: [ + { + id: 'nav-test-1', + sender: 'user' as const, + content: 'Testing navigation persistence', + timestamp: new Date().toISOString() + } + ], + selectedModel: { + name: 'claude-3', + provider: 'anthropic' + }, + conversationId: 'nav-test-conversation', + useStreaming: true, + currentTheme: 'light', + inputText: 'Navigation test input' + }; + + await pluginStateService.saveState(navigationTestState); + addLog('✓ State saved before navigation simulation'); + + // Simulate page context change (like navigation) + const currentContext = pageContextService.getCurrentPageContext(); + if (currentContext) { + // Trigger a page context change event + addLog('✓ Page context change simulated'); + } + + // Wait a bit to simulate navigation delay + await new Promise(resolve => setTimeout(resolve, 100)); + + // Try to restore state after "navigation" + const restoredAfterNav = await pluginStateService.getState(); + + if (restoredAfterNav && restoredAfterNav.conversationId === 'nav-test-conversation') { + addLog('✓ State persisted through navigation simulation'); + return true; + } else { + addLog('ERROR: State was not persisted through navigation'); + return false; + } + } catch (error) { + addLog(`ERROR: Page navigation persistence test failed: ${error}`); + return false; + } + }; + + /** + * Test 7: Error Handling + */ + const testErrorHandling = async (): Promise => { + setCurrentTest('Testing Error Handling'); + addLog('Starting error handling test...'); + + if (!pluginStateService) { + addLog('ERROR: Plugin state service not available'); + return false; + } + + try { + // Test invalid state validation + const invalidState = { + messages: 'invalid-not-array', + selectedModel: 'invalid-not-object', + useStreaming: 'invalid-not-boolean' + }; + + // This should either validate and sanitize, or throw an error + const isValid = pluginStateService.validateState(invalidState); + addLog(`State validation result for invalid data: ${isValid}`); + + // Test state sanitization + const sanitizedState = pluginStateService.sanitizeState(invalidState); + addLog('✓ State sanitization completed'); + + // Test error recovery + try { + await pluginStateService.saveState(null); + addLog('WARNING: Saving null state should have been handled'); + } catch (error) { + addLog('✓ Error handling for null state working'); + } + + return true; + } catch (error) { + addLog(`ERROR: Error handling test failed: ${error}`); + return false; + } + }; + + /** + * Test 8: Service Availability + */ + const testServiceAvailability = async (): Promise => { + setCurrentTest('Testing Service Availability'); + addLog('Starting service availability test...'); + + try { + const allAvailability = getAllServiceAvailability(); + addLog(`Total services checked: ${allAvailability.length}`); + + let availableCount = 0; + let unavailableCount = 0; + + allAvailability.forEach(check => { + if (check.isAvailable) { + availableCount++; + addLog(`✓ ${check.serviceName}: Available (${check.version || 'unknown version'})`); + } else { + unavailableCount++; + addLog(`✗ ${check.serviceName}: Unavailable`); + } + }); + + addLog(`Service availability summary: ${availableCount} available, ${unavailableCount} unavailable`); + + // Check specifically for plugin state related services + const criticalServices = ['pageContext', 'pluginStateFactory']; + let criticalServicesAvailable = true; + + for (const serviceName of criticalServices) { + const availability = getServiceAvailability(serviceName); + if (!availability || !availability.isAvailable) { + addLog(`ERROR: Critical service '${serviceName}' is not available`); + criticalServicesAvailable = false; + } + } + + return criticalServicesAvailable; + } catch (error) { + addLog(`ERROR: Service availability test failed: ${error}`); + return false; + } + }; + + /** + * Run all tests + */ + const runAllTests = async () => { + setIsRunning(true); + setLogs([]); + addLog('Starting comprehensive BrainDriveBasicAIChat state persistence tests...'); + + const tests = [ + { name: 'serviceBridgeIntegration', fn: testServiceBridgeIntegration }, + { name: 'stateConfigurationSetup', fn: testStateConfigurationSetup }, + { name: 'stateSaveOperation', fn: testStateSaveOperation }, + { name: 'stateRestoreOperation', fn: testStateRestoreOperation }, + { name: 'stateClearOperation', fn: testStateClearOperation }, + { name: 'pageNavigationPersistence', fn: testPageNavigationPersistence }, + { name: 'errorHandling', fn: testErrorHandling }, + { name: 'serviceAvailability', fn: testServiceAvailability } + ]; + + for (const test of tests) { + try { + const result = await test.fn(); + updateTestResult(test.name as keyof TestResults, result); + addLog(`Test ${test.name}: ${result ? 'PASSED' : 'FAILED'}`); + } catch (error) { + updateTestResult(test.name as keyof TestResults, false); + addLog(`Test ${test.name}: FAILED with error: ${error}`); + } + } + + setCurrentTest(''); + setIsRunning(false); + addLog('All tests completed!'); + }; + + const getTestResultColor = (result: boolean) => result ? 'success' : 'error'; + const getTestResultText = (result: boolean) => result ? 'PASS' : 'FAIL'; + + return ( + + + BrainDriveBasicAIChat State Persistence Test + + + + This test validates the plugin state persistence functionality with the BrainDriveBasicAIChat plugin. + + + + + + {currentTest && ( + + Current: {currentTest} + + )} + + + + Test Results + + {Object.entries(testResults).map(([test, result]) => ( + + ))} + + + + + Current Test State + + {JSON.stringify(testState, null, 2)} + + + + + Test Logs + + {logs.map((log, index) => ( + + {log} + + ))} + + + + ); +}; + +export default BrainDriveBasicAIChatStateTest; \ No newline at end of file diff --git a/frontend/src/test/EnhancedPluginStateTest.tsx b/frontend/src/test/EnhancedPluginStateTest.tsx new file mode 100644 index 0000000..112c7ce --- /dev/null +++ b/frontend/src/test/EnhancedPluginStateTest.tsx @@ -0,0 +1,694 @@ +import React, { Component } from 'react'; +import { EnhancedPluginStateConfig } from '../services/StateConfigurationManager'; +import { PluginStateServiceInterface } from '../services/PluginStateService'; +import { PluginStateFactoryInterface } from '../services/PluginStateFactory'; + +interface EnhancedPluginStateTestProps { + services?: { + pluginStateFactory?: PluginStateFactoryInterface; + pageContext?: any; + }; +} + +interface EnhancedPluginStateTestState { + pluginState: any; + isConfigured: boolean; + testData: { + counter: number; + message: string; + email: string; + age: number; + preferences: { + theme: string; + autoSave: boolean; + notifications: boolean; + }; + sensitiveData: string; + tempData: string; + }; + configType: 'basic' | 'enhanced'; + compressionEnabled: boolean; + debounceMs: number; + storageStats: any; + logs: string[]; +} + +/** + * Enhanced test component to demonstrate Phase 3 plugin state persistence features + * Shows advanced configuration, lifecycle hooks, transformations, and optimization + */ +export class EnhancedPluginStateTest extends Component { + private pluginStateService: PluginStateServiceInterface | null = null; + private saveCallback?: () => void; + private restoreCallback?: () => void; + private clearCallback?: () => void; + + constructor(props: EnhancedPluginStateTestProps) { + super(props); + + this.state = { + pluginState: null, + isConfigured: false, + testData: { + counter: 0, + message: 'Hello Enhanced Plugin State!', + email: 'user@example.com', + age: 25, + preferences: { + theme: 'light', + autoSave: true, + notifications: true + }, + sensitiveData: 'secret-token-123', + tempData: 'temporary-cache-data' + }, + configType: 'basic', + compressionEnabled: false, + debounceMs: 0, + storageStats: null, + logs: [] + }; + } + + async componentDidMount() { + await this.initializePluginState(); + await this.restoreState(); + this.updateStorageStats(); + } + + componentWillUnmount() { + this.cleanup(); + } + + private addLog = (message: string) => { + const timestamp = new Date().toLocaleTimeString(); + this.setState(prevState => ({ + logs: [`[${timestamp}] ${message}`, ...prevState.logs.slice(0, 19)] // Keep last 20 logs + })); + }; + + private async initializePluginState() { + if (!this.props.services?.pluginStateFactory) { + this.addLog('ERROR: PluginStateFactory service not available'); + return; + } + + try { + // Create plugin-specific state service + this.pluginStateService = this.props.services.pluginStateFactory.createPluginStateService('enhanced-test-plugin'); + + // Configure with basic settings initially + await this.configurePlugin(); + + this.setState({ isConfigured: true }); + this.addLog('Plugin state service initialized successfully'); + } catch (error) { + this.addLog(`ERROR: Failed to initialize plugin state: ${error}`); + } + } + + private async configurePlugin() { + if (!this.pluginStateService) return; + + const { configType, compressionEnabled, debounceMs } = this.state; + + if (configType === 'basic') { + // Basic configuration (backward compatible) + const basicConfig = { + pluginId: 'enhanced-test-plugin', + stateStrategy: 'session' as const, + preserveKeys: ['counter', 'message', 'email', 'age', 'preferences'], + stateSchema: { + counter: { type: 'number' as const, required: false, default: 0 }, + message: { type: 'string' as const, required: false, default: 'Hello Enhanced Plugin State!' }, + email: { type: 'string' as const, required: false, default: 'user@example.com' }, + age: { type: 'number' as const, required: false, default: 0 }, + preferences: { type: 'object' as const, required: false, default: { theme: 'light', autoSave: true, notifications: true } } + }, + maxStateSize: 2048 + }; + + this.pluginStateService.configure(basicConfig); + this.addLog('Configured with basic settings'); + } else { + // Enhanced configuration with Phase 3 features + const enhancedConfig: EnhancedPluginStateConfig = { + pluginId: 'enhanced-test-plugin', + stateStrategy: 'session', + + // Advanced filtering + preserveKeys: ['counter', 'message', 'email', 'age', 'preferences'], + excludeKeys: ['tempData'], // Exclude temporary data + excludePatterns: [/^temp_/, /_cache$/], // Exclude temp and cache keys + + // State schema with validation + stateSchema: { + counter: { type: 'number', required: false, default: 0 }, + message: { type: 'string', required: false, default: 'Hello Enhanced Plugin State!' }, + email: { type: 'string', required: true, default: 'user@example.com' }, + age: { type: 'number', required: false, default: 0 }, + preferences: { type: 'object', required: false, default: { theme: 'light', autoSave: true, notifications: true } } + }, + + // Enhanced validation + validation: { + strict: true, + allowUnknownKeys: false, + customValidators: new Map([ + ['email', (value: any) => typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)], + ['age', (value: any) => typeof value === 'number' && value >= 0 && value <= 150] + ]) + }, + + // State transformations + transformers: { + beforeSave: (state: any) => { + // Simulate encryption of sensitive data + return { + ...state, + sensitiveData: state.sensitiveData ? `encrypted:${btoa(state.sensitiveData)}` : undefined + }; + }, + afterLoad: (state: any) => { + // Simulate decryption of sensitive data + if (state.sensitiveData && state.sensitiveData.startsWith('encrypted:')) { + return { + ...state, + sensitiveData: atob(state.sensitiveData.replace('encrypted:', '')) + }; + } + return state; + } + }, + + // Lifecycle hooks + hooks: { + beforeSave: async (state: any) => { + this.addLog(`HOOK: Before save - State size: ${JSON.stringify(state).length} bytes`); + return state; + }, + afterSave: async (state: any) => { + this.addLog('HOOK: After save - State saved successfully'); + this.updateStorageStats(); + }, + beforeLoad: async () => { + this.addLog('HOOK: Before load - Preparing to load state'); + }, + afterLoad: async (state: any) => { + this.addLog(`HOOK: After load - State loaded: ${state ? 'success' : 'no data'}`); + return state; + }, + onError: (error: Error, operation: string) => { + this.addLog(`HOOK: Error during ${operation}: ${error.message}`); + } + }, + + // Performance optimization + performance: { + debounceMs: debounceMs, + maxRetries: 3, + timeout: 5000 + }, + + // Compression settings + compression: { + enabled: compressionEnabled, + threshold: 512 // Compress if state > 512 bytes + }, + + maxStateSize: 4096 // 4KB limit + }; + + this.pluginStateService.configure(enhancedConfig); + this.addLog(`Configured with enhanced settings (compression: ${compressionEnabled}, debounce: ${debounceMs}ms)`); + } + + // Set up lifecycle callbacks + this.saveCallback = this.pluginStateService.onSave((state: any) => { + this.addLog('CALLBACK: State save callback triggered'); + }); + + this.restoreCallback = this.pluginStateService.onRestore((state: any) => { + this.addLog('CALLBACK: State restore callback triggered'); + }); + + this.clearCallback = this.pluginStateService.onClear(() => { + this.addLog('CALLBACK: State clear callback triggered'); + }); + } + + private async restoreState() { + if (!this.pluginStateService) return; + + try { + const savedState = await this.pluginStateService.getState(); + if (savedState) { + this.setState({ + testData: { ...this.state.testData, ...savedState } + }); + this.addLog('State restored from storage'); + } else { + this.addLog('No saved state found'); + } + } catch (error) { + this.addLog(`ERROR: Failed to restore state: ${error}`); + } + } + + private async saveState() { + if (!this.pluginStateService) return; + + try { + await this.pluginStateService.saveState(this.state.testData); + this.addLog('State saved to storage'); + } catch (error) { + this.addLog(`ERROR: Failed to save state: ${error}`); + } + } + + private async clearState() { + if (!this.pluginStateService) return; + + try { + await this.pluginStateService.clearState(); + this.setState({ + testData: { + counter: 0, + message: 'Hello Enhanced Plugin State!', + email: 'user@example.com', + age: 25, + preferences: { + theme: 'light', + autoSave: true, + notifications: true + }, + sensitiveData: 'secret-token-123', + tempData: 'temporary-cache-data' + } + }); + this.addLog('State cleared from storage'); + this.updateStorageStats(); + } catch (error) { + this.addLog(`ERROR: Failed to clear state: ${error}`); + } + } + + private updateStorageStats = () => { + if (this.props.services?.pageContext?.getStorageStats) { + try { + const stats = this.props.services.pageContext.getStorageStats(); + this.setState({ storageStats: stats }); + } catch (error) { + this.addLog(`ERROR: Failed to get storage stats: ${error}`); + } + } + }; + + private async cleanupOldStates() { + if (this.props.services?.pageContext?.cleanupOldStates) { + try { + const maxAge = 60 * 60 * 1000; // 1 hour + const cleanedCount = await this.props.services.pageContext.cleanupOldStates(maxAge); + this.addLog(`Cleaned up ${cleanedCount} old state entries`); + this.updateStorageStats(); + } catch (error) { + this.addLog(`ERROR: Failed to cleanup old states: ${error}`); + } + } + } + + private cleanup() { + if (this.saveCallback) this.saveCallback(); + if (this.restoreCallback) this.restoreCallback(); + if (this.clearCallback) this.clearCallback(); + } + + private async switchConfigType(newType: 'basic' | 'enhanced') { + this.setState({ configType: newType }); + await this.configurePlugin(); + this.addLog(`Switched to ${newType} configuration`); + } + + private async updateCompressionSetting(enabled: boolean) { + this.setState({ compressionEnabled: enabled }); + await this.configurePlugin(); + this.addLog(`Compression ${enabled ? 'enabled' : 'disabled'}`); + } + + private async updateDebounceSetting(ms: number) { + this.setState({ debounceMs: ms }); + await this.configurePlugin(); + this.addLog(`Debounce set to ${ms}ms`); + } + + // Test data modification methods + private incrementCounter = async () => { + const newTestData = { + ...this.state.testData, + counter: this.state.testData.counter + 1 + }; + + this.setState({ testData: newTestData }); + + if (newTestData.preferences.autoSave) { + await this.saveState(); + } + }; + + private updateMessage = async (message: string) => { + const newTestData = { + ...this.state.testData, + message + }; + + this.setState({ testData: newTestData }); + + if (newTestData.preferences.autoSave) { + await this.saveState(); + } + }; + + private updateEmail = async (email: string) => { + const newTestData = { + ...this.state.testData, + email + }; + + this.setState({ testData: newTestData }); + + if (newTestData.preferences.autoSave) { + await this.saveState(); + } + }; + + private updateAge = async (age: number) => { + const newTestData = { + ...this.state.testData, + age + }; + + this.setState({ testData: newTestData }); + + if (newTestData.preferences.autoSave) { + await this.saveState(); + } + }; + + private toggleTheme = async () => { + const newTestData = { + ...this.state.testData, + preferences: { + ...this.state.testData.preferences, + theme: this.state.testData.preferences.theme === 'light' ? 'dark' : 'light' + } + }; + + this.setState({ testData: newTestData }); + + if (newTestData.preferences.autoSave) { + await this.saveState(); + } + }; + + private toggleAutoSave = async () => { + const newTestData = { + ...this.state.testData, + preferences: { + ...this.state.testData.preferences, + autoSave: !this.state.testData.preferences.autoSave + } + }; + + this.setState({ testData: newTestData }); + await this.saveState(); // Always save this preference change + }; + + render() { + const { isConfigured, testData, configType, compressionEnabled, debounceMs, storageStats, logs } = this.state; + + if (!isConfigured) { + return ( +
+

Enhanced Plugin State Test

+

Initializing enhanced plugin state service...

+
+ ); + } + + const isDarkTheme = testData.preferences.theme === 'dark'; + const containerStyle = { + padding: '20px', + border: '1px solid #ccc', + margin: '10px', + backgroundColor: isDarkTheme ? '#333' : '#fff', + color: isDarkTheme ? '#fff' : '#000', + fontFamily: 'Arial, sans-serif' + }; + + return ( +
+

Enhanced Plugin State Test (Phase 3)

+ + {/* Configuration Controls */} +
+

Configuration Settings

+ +
+ +
+ + {configType === 'enhanced' && ( + <> +
+ +
+ +
+ +
+ + )} +
+ + {/* Current State Display */} +
+

Current State

+
+
+

Counter: {testData.counter}

+

Message: {testData.message}

+

Email: {testData.email}

+

Age: {testData.age}

+
+
+

Theme: {testData.preferences.theme}

+

Auto-save: {testData.preferences.autoSave ? 'Enabled' : 'Disabled'}

+

Notifications: {testData.preferences.notifications ? 'Enabled' : 'Disabled'}

+

Sensitive Data: {testData.sensitiveData}

+
+
+
+ + {/* Data Modification Controls */} +
+

Data Modification

+ +
+ + + + + + + +
+ +
+ this.updateEmail(e.target.value)} + placeholder="Email address" + style={{ margin: '5px', padding: '8px', width: '200px' }} + /> + + this.updateAge(parseInt(e.target.value) || 0)} + placeholder="Age" + style={{ margin: '5px', padding: '8px', width: '80px' }} + min="0" + max="150" + /> +
+
+ + {/* State Management Controls */} +
+

State Management

+ + + + + + + + + + +
+ + {/* Storage Statistics */} + {storageStats && ( +
+

Storage Statistics

+
+
+

Total Plugins: {storageStats.totalPlugins}

+

Total Size: {storageStats.totalSize} bytes

+

Available Space: {storageStats.availableSpace} bytes

+
+
+

Oldest Entry: {storageStats.oldestEntry ? new Date(storageStats.oldestEntry).toLocaleString() : 'N/A'}

+

Newest Entry: {storageStats.newestEntry ? new Date(storageStats.newestEntry).toLocaleString() : 'N/A'}

+
+
+
+ )} + + {/* Activity Logs */} +
+

Activity Logs

+
+ {logs.map((log, index) => ( +
+ {log} +
+ ))} + {logs.length === 0 && ( +
+ No logs yet. Perform some actions to see activity. +
+ )} +
+ +
+ + {/* Instructions */} +
+

Phase 3 Features Demonstrated:

+
    +
  • Enhanced Configuration: Switch between basic and enhanced config modes
  • +
  • State Filtering: Sensitive and temporary data handling with exclude patterns
  • +
  • Lifecycle Hooks: Before/after save/load hooks with logging
  • +
  • State Transformations: Automatic encryption/decryption of sensitive data
  • +
  • Compression: Optional compression for large states
  • +
  • Debounced Saves: Configurable debouncing to prevent excessive writes
  • +
  • Enhanced Validation: Email and age validation with custom validators
  • +
  • Storage Monitoring: Real-time storage statistics and cleanup
  • +
  • Error Handling: Comprehensive error handling with recovery
  • +
+

Instructions:

+
    +
  • Try switching between basic and enhanced configuration modes
  • +
  • Enable compression and observe the behavior with large states
  • +
  • Set debounce delay and rapidly modify data to see debouncing in action
  • +
  • Enter invalid email addresses to test validation
  • +
  • Navigate to another page and return to test persistence
  • +
  • Check the activity logs to see lifecycle hooks and callbacks
  • +
  • Monitor storage statistics to see space usage
  • +
+
+
+ ); + } +} + +export default EnhancedPluginStateTest; \ No newline at end of file diff --git a/frontend/src/test/Phase4PluginStateTest.tsx b/frontend/src/test/Phase4PluginStateTest.tsx new file mode 100644 index 0000000..320bd9e --- /dev/null +++ b/frontend/src/test/Phase4PluginStateTest.tsx @@ -0,0 +1,762 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Card, + CardContent, + Typography, + Button, + TextField, + Grid, + Alert, + Chip, + Accordion, + AccordionSummary, + AccordionDetails, + List, + ListItem, + ListItemText, + Switch, + FormControlLabel, + Divider, + Paper +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { databasePersistenceManager } from '../services/DatabasePersistenceManager'; +import { stateRestorationManager } from '../services/StateRestorationManager'; +import { pluginStateLifecycleManager } from '../services/PluginStateLifecycleManager'; +import { EnhancedPluginStateConfig } from '../services/StateConfigurationManager'; + +interface TestResult { + success: boolean; + message: string; + data?: any; + error?: string; +} + +interface LifecycleEvent { + timestamp: number; + pluginId: string; + eventType: string; + data?: any; +} + +const Phase4PluginStateTest: React.FC = () => { + const [testPluginId, setTestPluginId] = useState('test-plugin-phase4'); + const [testState, setTestState] = useState({ counter: 0, message: 'Hello Phase 4!' }); + const [results, setResults] = useState([]); + const [lifecycleEvents, setLifecycleEvents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [enableLifecycleHooks, setEnableLifecycleHooks] = useState(true); + const [databaseStats, setDatabaseStats] = useState(null); + + // Test configuration + const testConfig: EnhancedPluginStateConfig = { + pluginId: testPluginId, + stateStrategy: 'persistent', + preserveKeys: ['counter', 'message'], + stateSchema: { + counter: { type: 'number', required: true, default: 0 }, + message: { type: 'string', required: false, default: 'Default message' } + }, + validation: { + strict: true, + allowUnknownKeys: false, + customValidators: new Map([ + ['counter', (value) => typeof value === 'number' && value >= 0] + ]) + }, + compression: { + enabled: true, + threshold: 100 + }, + performance: { + debounceMs: 300, + maxRetries: 3, + timeout: 5000 + }, + hooks: { + beforeSave: async (state) => { + addLifecycleEvent('beforeSave', state); + return state; + }, + afterSave: async (state) => { + addLifecycleEvent('afterSave', state); + }, + beforeLoad: async () => { + addLifecycleEvent('beforeLoad', null); + }, + afterLoad: async (state) => { + addLifecycleEvent('afterLoad', state); + return state; + }, + onError: (error, operation) => { + addLifecycleEvent('onError', { error: error.message, operation }); + } + } + }; + + const addResult = (result: TestResult) => { + setResults(prev => [...prev, { ...result, timestamp: Date.now() }]); + }; + + const addLifecycleEvent = (eventType: string, data: any) => { + if (enableLifecycleHooks) { + setLifecycleEvents(prev => [...prev, { + timestamp: Date.now(), + pluginId: testPluginId, + eventType, + data + }]); + } + }; + + const clearResults = () => { + setResults([]); + setLifecycleEvents([]); + }; + + // Database Persistence Tests + const testDatabaseSave = async () => { + setIsLoading(true); + try { + const result = await databasePersistenceManager.saveState(testPluginId, testState, { + strategy: 'persistent', + pageId: 'test-page', + ttlHours: 24 + }); + + addResult({ + success: result.success, + message: result.success ? 'Database save successful' : 'Database save failed', + data: result.record, + error: result.error + }); + } catch (error) { + addResult({ + success: false, + message: 'Database save error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + const testDatabaseLoad = async () => { + setIsLoading(true); + try { + const result = await databasePersistenceManager.loadState(testPluginId, { + pageId: 'test-page' + }); + + addResult({ + success: result.success, + message: result.success ? 'Database load successful' : 'Database load failed', + data: result.data, + error: result.error + }); + } catch (error) { + addResult({ + success: false, + message: 'Database load error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + const testDatabaseQuery = async () => { + setIsLoading(true); + try { + const states = await databasePersistenceManager.queryStates({ + plugin_id: testPluginId, + limit: 10 + }); + + addResult({ + success: true, + message: `Found ${states.length} database states`, + data: states + }); + } catch (error) { + addResult({ + success: false, + message: 'Database query error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + const testDatabaseStats = async () => { + setIsLoading(true); + try { + const stats = await databasePersistenceManager.getStateStats(); + setDatabaseStats(stats); + + addResult({ + success: true, + message: 'Database stats retrieved', + data: stats + }); + } catch (error) { + addResult({ + success: false, + message: 'Database stats error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + // State Restoration Tests + const testStateRestoration = async () => { + setIsLoading(true); + try { + const result = await stateRestorationManager.restorePluginState(testPluginId, testConfig, { + preferDatabase: true, + fallbackToSession: true + }); + + addResult({ + success: result.success, + message: `State restoration ${result.success ? 'successful' : 'failed'} (source: ${result.source})`, + data: result.data, + error: result.errors?.join(', ') + }); + } catch (error) { + addResult({ + success: false, + message: 'State restoration error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + const testPartialRestoration = async () => { + setIsLoading(true); + try { + const result = await stateRestorationManager.restorePartialState( + testPluginId, + ['counter'], + testConfig + ); + + addResult({ + success: result.success, + message: `Partial restoration ${result.success ? 'successful' : 'failed'}`, + data: result.data, + error: result.errors?.join(', ') + }); + } catch (error) { + addResult({ + success: false, + message: 'Partial restoration error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + const testFallbackRestoration = async () => { + setIsLoading(true); + try { + const fallbackData = { counter: 999, message: 'Fallback data' }; + const result = await stateRestorationManager.restoreWithFallback( + testPluginId, + testConfig, + fallbackData + ); + + addResult({ + success: result.success, + message: `Fallback restoration ${result.success ? 'successful' : 'failed'}`, + data: result.data, + error: result.errors?.join(', ') + }); + } catch (error) { + addResult({ + success: false, + message: 'Fallback restoration error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + const testMigration = async () => { + setIsLoading(true); + try { + // First save to session storage (simulate existing session data) + sessionStorage.setItem(`braindrive_plugin_state_${testPluginId}`, JSON.stringify(testState)); + + const success = await stateRestorationManager.migrateSessionToDatabase(testPluginId, testConfig); + + addResult({ + success, + message: `Migration ${success ? 'successful' : 'failed'}`, + data: { migrated: success } + }); + } catch (error) { + addResult({ + success: false, + message: 'Migration error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + // Lifecycle Hook Tests + const testLifecycleHooks = async () => { + setIsLoading(true); + try { + // Register lifecycle hooks + const hookIds: string[] = []; + + hookIds.push(pluginStateLifecycleManager.registerHook( + testPluginId, + 'onStateChange', + (data) => { + addLifecycleEvent('onStateChange', data); + } + )); + + hookIds.push(pluginStateLifecycleManager.registerHook( + testPluginId, + 'beforeSave', + (data) => { + addLifecycleEvent('hookBeforeSave', data); + return { ...data.state, hookModified: true }; + }, + { priority: 10 } + )); + + // Simulate state change + await pluginStateLifecycleManager.notifyStateChange({ + pluginId: testPluginId, + oldState: { counter: 0 }, + newState: testState, + changeType: 'update', + source: 'session', + timestamp: Date.now() + }); + + addResult({ + success: true, + message: `Registered ${hookIds.length} lifecycle hooks`, + data: { hookIds } + }); + + // Clean up hooks after test + setTimeout(() => { + hookIds.forEach(id => pluginStateLifecycleManager.unregisterHook(id)); + }, 1000); + + } catch (error) { + addResult({ + success: false, + message: 'Lifecycle hooks error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + const testValidationCallbacks = async () => { + setIsLoading(true); + try { + // Register validation callback + const callbackId = pluginStateLifecycleManager.registerValidationCallback( + testPluginId, + (state, config) => { + // Custom validation: counter must be positive + return state.counter >= 0; + } + ); + + // Test validation with valid state + const validResult = await pluginStateLifecycleManager.executeValidation( + testPluginId, + { counter: 5, message: 'Valid' }, + testConfig + ); + + // Test validation with invalid state + const invalidResult = await pluginStateLifecycleManager.executeValidation( + testPluginId, + { counter: -1, message: 'Invalid' }, + testConfig + ); + + addResult({ + success: true, + message: `Validation tests completed`, + data: { + callbackId, + validResult, + invalidResult + } + }); + + // Clean up + pluginStateLifecycleManager.unregisterValidationCallback(callbackId); + + } catch (error) { + addResult({ + success: false, + message: 'Validation callbacks error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + // Sync and Cross-Device Tests + const testCrossDeviceSync = async () => { + setIsLoading(true); + try { + const result = await stateRestorationManager.syncStateAcrossDevices(testPluginId, testConfig); + + addResult({ + success: result.success, + message: `Cross-device sync ${result.success ? 'successful' : 'failed'}`, + data: result.data, + error: result.errors?.join(', ') + }); + } catch (error) { + addResult({ + success: false, + message: 'Cross-device sync error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + const testBulkOperations = async () => { + setIsLoading(true); + try { + const states = [ + { pluginId: `${testPluginId}-1`, data: { counter: 1 } }, + { pluginId: `${testPluginId}-2`, data: { counter: 2 } }, + { pluginId: `${testPluginId}-3`, data: { counter: 3 } } + ]; + + const result = await databasePersistenceManager.syncStates(states); + + addResult({ + success: result.success, + message: `Bulk operations ${result.success ? 'successful' : 'failed'}`, + data: { + synced: result.synced.length, + conflicts: result.conflicts.length, + errors: result.errors.length + } + }); + } catch (error) { + addResult({ + success: false, + message: 'Bulk operations error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + setIsLoading(false); + }; + + const runAllTests = async () => { + clearResults(); + setIsLoading(true); + + const tests = [ + testDatabaseSave, + testDatabaseLoad, + testDatabaseQuery, + testDatabaseStats, + testStateRestoration, + testPartialRestoration, + testFallbackRestoration, + testMigration, + testLifecycleHooks, + testValidationCallbacks, + testCrossDeviceSync, + testBulkOperations + ]; + + for (const test of tests) { + await test(); + await new Promise(resolve => setTimeout(resolve, 500)); // Small delay between tests + } + + setIsLoading(false); + }; + + return ( + + + Phase 4: Advanced Plugin State Management Test Suite + + + + {/* Configuration Panel */} + + + + Test Configuration + + setTestPluginId(e.target.value)} + margin="normal" + /> + + setTestState(prev => ({ ...prev, counter: parseInt(e.target.value) || 0 }))} + margin="normal" + /> + + setTestState(prev => ({ ...prev, message: e.target.value }))} + margin="normal" + /> + + setEnableLifecycleHooks(e.target.checked)} + /> + } + label="Enable Lifecycle Hooks" + /> + + + + + + + + + {/* Database Stats */} + {databaseStats && ( + + + Database Statistics + + + + + + + + + + + + + + + + + )} + + + {/* Test Controls */} + + + }> + Database Persistence Tests + + + + + + + + + + + + + + + + + + + + + }> + State Restoration Tests + + + + + + + + + + + + + + + + + + + + + }> + Lifecycle & Validation Tests + + + + + + + + + + + + + + + + + + }> + Advanced Tests + + + + + + + + + + + + {/* Results Panel */} + + + + Test Results + {results.length === 0 ? ( + No test results yet. Run some tests to see results here. + ) : ( + + {results.map((result, index) => ( + + + {result.message} + {result.data && ( + + {JSON.stringify(result.data, null, 2)} + + )} + {result.error && ( + + Error: {result.error} + + )} + + + ))} + + )} + + + + + {/* Lifecycle Events Panel */} + {enableLifecycleHooks && lifecycleEvents.length > 0 && ( + + + + Lifecycle Events + + {lifecycleEvents.map((event, index) => ( + + + + + {new Date(event.timestamp).toLocaleTimeString()} + + + {event.data && ( + + {JSON.stringify(event.data, null, 2)} + + )} + + ))} + + + + + )} + + + ); +}; + +export default Phase4PluginStateTest; \ No newline at end of file diff --git a/frontend/src/test/PluginStateTest.tsx b/frontend/src/test/PluginStateTest.tsx new file mode 100644 index 0000000..f568897 --- /dev/null +++ b/frontend/src/test/PluginStateTest.tsx @@ -0,0 +1,318 @@ +import React, { Component } from 'react'; +import { PluginStateConfig } from '../services/PageContextService'; +import { PluginStateServiceInterface } from '../services/PluginStateService'; +import { PluginStateFactoryInterface } from '../services/PluginStateFactory'; + +interface PluginStateTestProps { + services?: { + pluginStateFactory?: PluginStateFactoryInterface; + pageContext?: any; + }; +} + +interface PluginStateTestState { + pluginState: any; + isConfigured: boolean; + testData: { + counter: number; + message: string; + preferences: { + theme: string; + autoSave: boolean; + }; + }; +} + +/** + * Test component to demonstrate plugin state persistence functionality + * This would typically be used within a plugin to test the state management + */ +export class PluginStateTest extends Component { + private pluginStateService: PluginStateServiceInterface | null = null; + private saveCallback?: () => void; + private restoreCallback?: () => void; + private clearCallback?: () => void; + + constructor(props: PluginStateTestProps) { + super(props); + + this.state = { + pluginState: null, + isConfigured: false, + testData: { + counter: 0, + message: 'Hello Plugin State!', + preferences: { + theme: 'light', + autoSave: true + } + } + }; + } + + async componentDidMount() { + await this.initializePluginState(); + await this.restoreState(); + } + + componentWillUnmount() { + this.cleanup(); + } + + private async initializePluginState() { + if (!this.props.services?.pluginStateFactory) { + console.error('[PluginStateTest] PluginStateFactory service not available'); + return; + } + + try { + // Create plugin-specific state service + this.pluginStateService = this.props.services.pluginStateFactory.createPluginStateService('test-plugin'); + + // Configure the plugin state + const config: PluginStateConfig = { + pluginId: 'test-plugin', + stateStrategy: 'session', + preserveKeys: ['counter', 'message', 'preferences'], + stateSchema: { + counter: { type: 'number', required: false, default: 0 }, + message: { type: 'string', required: false, default: 'Hello Plugin State!' }, + preferences: { type: 'object', required: false, default: { theme: 'light', autoSave: true } } + }, + maxStateSize: 1024 // 1KB limit + }; + + this.pluginStateService.configure(config); + + // Set up lifecycle callbacks + this.saveCallback = this.pluginStateService.onSave((state: any) => { + console.log('[PluginStateTest] State saved:', state); + }); + + this.restoreCallback = this.pluginStateService.onRestore((state: any) => { + console.log('[PluginStateTest] State restored:', state); + }); + + this.clearCallback = this.pluginStateService.onClear(() => { + console.log('[PluginStateTest] State cleared'); + }); + + this.setState({ isConfigured: true }); + console.log('[PluginStateTest] Plugin state service initialized'); + } catch (error) { + console.error('[PluginStateTest] Error initializing plugin state:', error); + } + } + + private async restoreState() { + if (!this.pluginStateService) return; + + try { + const savedState = await this.pluginStateService.getState(); + if (savedState) { + this.setState({ + testData: { ...this.state.testData, ...savedState } + }); + console.log('[PluginStateTest] State restored from storage'); + } + } catch (error) { + console.error('[PluginStateTest] Error restoring state:', error); + } + } + + private async saveState() { + if (!this.pluginStateService) return; + + try { + await this.pluginStateService.saveState(this.state.testData); + console.log('[PluginStateTest] State saved to storage'); + } catch (error) { + console.error('[PluginStateTest] Error saving state:', error); + } + } + + private async clearState() { + if (!this.pluginStateService) return; + + try { + await this.pluginStateService.clearState(); + this.setState({ + testData: { + counter: 0, + message: 'Hello Plugin State!', + preferences: { + theme: 'light', + autoSave: true + } + } + }); + console.log('[PluginStateTest] State cleared from storage'); + } catch (error) { + console.error('[PluginStateTest] Error clearing state:', error); + } + } + + private cleanup() { + if (this.saveCallback) this.saveCallback(); + if (this.restoreCallback) this.restoreCallback(); + if (this.clearCallback) this.clearCallback(); + } + + private incrementCounter = async () => { + const newTestData = { + ...this.state.testData, + counter: this.state.testData.counter + 1 + }; + + this.setState({ testData: newTestData }); + + // Auto-save if enabled + if (newTestData.preferences.autoSave) { + await this.saveState(); + } + }; + + private updateMessage = async (message: string) => { + const newTestData = { + ...this.state.testData, + message + }; + + this.setState({ testData: newTestData }); + + // Auto-save if enabled + if (newTestData.preferences.autoSave) { + await this.saveState(); + } + }; + + private toggleTheme = async () => { + const newTestData = { + ...this.state.testData, + preferences: { + ...this.state.testData.preferences, + theme: this.state.testData.preferences.theme === 'light' ? 'dark' : 'light' + } + }; + + this.setState({ testData: newTestData }); + + // Auto-save if enabled + if (newTestData.preferences.autoSave) { + await this.saveState(); + } + }; + + private toggleAutoSave = async () => { + const newTestData = { + ...this.state.testData, + preferences: { + ...this.state.testData.preferences, + autoSave: !this.state.testData.preferences.autoSave + } + }; + + this.setState({ testData: newTestData }); + await this.saveState(); // Always save this preference change + }; + + render() { + const { isConfigured, testData } = this.state; + + if (!isConfigured) { + return ( +
+

Plugin State Test

+

Initializing plugin state service...

+
+ ); + } + + return ( +
+

Plugin State Test

+ +
+

Current State:

+

Counter: {testData.counter}

+

Message: {testData.message}

+

Theme: {testData.preferences.theme}

+

Auto-save: {testData.preferences.autoSave ? 'Enabled' : 'Disabled'}

+
+ +
+

Actions:

+ + + + + + + +
+ +
+

State Management:

+ + + + + +
+ +
+

Instructions:

+
    +
  • Modify the state using the action buttons
  • +
  • Navigate to another page and come back to test persistence
  • +
  • Check browser console for detailed logs
  • +
  • State is stored in sessionStorage with key: braindrive_plugin_state_test-plugin
  • +
+
+
+ ); + } +} + +export default PluginStateTest; \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a50e155..1cad7f8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -69,6 +69,89 @@ export interface RequiredServices { [serviceName: string]: ServiceRequirement; } +// Plugin State Management Types +export interface PluginStateConfig { + pluginId: string; + stateStrategy: 'none' | 'session' | 'persistent' | 'custom'; + preserveKeys?: string[]; + stateSchema?: { + [key: string]: { + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + required?: boolean; + default?: any; + }; + }; + serialize?: (state: any) => string; + deserialize?: (serialized: string) => any; + maxStateSize?: number; + ttl?: number; +} + +// Enhanced Plugin State Configuration for Phase 3 +export interface EnhancedPluginStateConfig extends PluginStateConfig { + // Advanced filtering options + excludeKeys?: string[]; + includePatterns?: RegExp[]; + excludePatterns?: RegExp[]; + + // State transformation options + transformers?: { + beforeSave?: (state: any) => any; + afterLoad?: (state: any) => any; + }; + + // Validation options + validation?: { + strict?: boolean; + allowUnknownKeys?: boolean; + customValidators?: Map boolean>; + }; + + // Storage optimization + compression?: { + enabled?: boolean; + threshold?: number; + }; + + // Lifecycle hooks + hooks?: { + beforeSave?: (state: any) => Promise | any; + afterSave?: (state: any) => Promise | void; + beforeLoad?: () => Promise | void; + afterLoad?: (state: any) => Promise | any; + onError?: (error: Error, operation: 'save' | 'load' | 'clear') => void; + }; + + // Performance options + performance?: { + debounceMs?: number; + maxRetries?: number; + timeout?: number; + }; +} + +// Plugin State Service Interface for plugins +export interface PluginStateServiceInterface { + configure(config: PluginStateConfig | EnhancedPluginStateConfig): void; + getConfiguration(): PluginStateConfig | EnhancedPluginStateConfig | null; + saveState(state: any): Promise; + getState(): Promise; + clearState(): Promise; + validateState(state: any): boolean; + sanitizeState(state: any): any; + onSave(callback: (state: any) => void): () => void; + onRestore(callback: (state: any) => void): () => void; + onClear(callback: () => void): () => void; +} + +// Plugin State Factory Interface for plugins +export interface PluginStateFactoryInterface { + createPluginStateService(pluginId: string): PluginStateServiceInterface; + getPluginStateService(pluginId: string): PluginStateServiceInterface | null; + destroyPluginStateService(pluginId: string): Promise; + listActivePlugins(): string[]; +} + export interface DynamicModuleConfig { id: string; name: string; @@ -95,6 +178,7 @@ export interface DynamicModuleConfig { type?: 'frontend' | 'backend'; requiredServices?: RequiredServices; enabled?: boolean; + stateConfig?: Omit; // Plugin state configuration } export interface DynamicPluginConfig { diff --git a/frontend/src/utils/serviceBridge.ts b/frontend/src/utils/serviceBridge.ts index 4aabca2..65da25f 100644 --- a/frontend/src/utils/serviceBridge.ts +++ b/frontend/src/utils/serviceBridge.ts @@ -3,93 +3,285 @@ import { ServiceRequirement } from '../types'; export interface ServiceError { serviceName: string; error: string; + severity: 'error' | 'warning'; + timestamp: number; +} + +export interface ServiceAvailabilityCheck { + serviceName: string; + isAvailable: boolean; + version?: string; + capabilities?: string[]; + lastChecked: number; } /** - * Creates service bridges based on required services and a service provider function - * @param requiredServices Map of service names to their requirements - * @param getService Function to retrieve a service by name - * @returns Object containing service bridges and any errors encountered + * Enhanced service bridge with comprehensive error handling and availability checks */ -export function createServiceBridges( - requiredServices: Record | undefined, - getService: (name: string) => any -): { serviceBridges: Record; errors: ServiceError[] } { - const errors: ServiceError[] = []; - const serviceBridges: Record = {}; - - // console.log('[ServiceBridge] Creating service bridges for:', requiredServices); - - if (!requiredServices) { - console.warn('[ServiceBridge] No required services provided'); - return { serviceBridges, errors }; +export class ServiceBridgeManager { + private static instance: ServiceBridgeManager; + private serviceAvailability: Map = new Map(); + private errorHistory: ServiceError[] = []; + private maxErrorHistory = 100; + + static getInstance(): ServiceBridgeManager { + if (!ServiceBridgeManager.instance) { + ServiceBridgeManager.instance = new ServiceBridgeManager(); + } + return ServiceBridgeManager.instance; } - - Object.entries(requiredServices).forEach(([serviceName, requirements]) => { - // console.log(`[ServiceBridge] Processing service: ${serviceName}`, requirements); + + /** + * Check if a service is available and meets requirements + */ + checkServiceAvailability(serviceName: string, getService: (name: string) => any): ServiceAvailabilityCheck { + const timestamp = Date.now(); try { - // Get the service instance - this should be a pre-initialized service object, not a hook const service = getService(serviceName); - // console.log(`[ServiceBridge] Service ${serviceName} retrieved:`, service ? 'Found' : 'Not Found'); - if (!service) { - throw new Error(`Service not found`); + const check: ServiceAvailabilityCheck = { + serviceName, + isAvailable: false, + lastChecked: timestamp + }; + this.serviceAvailability.set(serviceName, check); + return check; } - // Validate required methods if specified - if (requirements.methods) { - // console.log(`[ServiceBridge] Validating methods for ${serviceName}:`, requirements.methods); + // Get service metadata if available + const version = service.getVersion ? service.getVersion() : undefined; + const capabilities = service.getCapabilities ? service.getCapabilities() : undefined; + + const check: ServiceAvailabilityCheck = { + serviceName, + isAvailable: true, + version: version ? `${version.major}.${version.minor}.${version.patch}` : undefined, + capabilities, + lastChecked: timestamp + }; + + this.serviceAvailability.set(serviceName, check); + return check; + } catch (error) { + const check: ServiceAvailabilityCheck = { + serviceName, + isAvailable: false, + lastChecked: timestamp + }; + this.serviceAvailability.set(serviceName, check); + + this.addError({ + serviceName, + error: error instanceof Error ? error.message : 'Unknown error during availability check', + severity: 'error', + timestamp + }); + + return check; + } + } + + /** + * Get cached service availability information + */ + getServiceAvailability(serviceName: string): ServiceAvailabilityCheck | null { + return this.serviceAvailability.get(serviceName) || null; + } + + /** + * Get all service availability information + */ + getAllServiceAvailability(): ServiceAvailabilityCheck[] { + return Array.from(this.serviceAvailability.values()); + } + + /** + * Add an error to the history + */ + private addError(error: ServiceError): void { + this.errorHistory.unshift(error); + if (this.errorHistory.length > this.maxErrorHistory) { + this.errorHistory = this.errorHistory.slice(0, this.maxErrorHistory); + } + } + + /** + * Get error history + */ + getErrorHistory(): ServiceError[] { + return [...this.errorHistory]; + } + + /** + * Clear error history + */ + clearErrorHistory(): void { + this.errorHistory = []; + } + + /** + * Create a safe method wrapper with error handling + */ + private createSafeMethodWrapper( + serviceName: string, + methodName: string, + serviceMethod: Function, + service: any + ): Function { + return function(...args: any[]) { + try { + const result = serviceMethod.apply(service, args); - const missingMethods = requirements.methods.filter( - method => typeof service[method] !== 'function' - ); + // Handle promises with error catching + if (result && typeof result.then === 'function') { + return result.catch((error: Error) => { + const bridgeManager = ServiceBridgeManager.getInstance(); + bridgeManager.addError({ + serviceName, + error: `Method ${methodName} failed: ${error.message}`, + severity: 'error', + timestamp: Date.now() + }); + console.error(`[ServiceBridge] Promise rejection in ${serviceName}.${methodName}:`, error); + throw error; + }); + } - if (missingMethods.length > 0) { - console.error(`[ServiceBridge] Missing methods for ${serviceName}:`, missingMethods); - throw new Error( - `Missing required methods: ${missingMethods.join(', ')}` + return result; + } catch (error) { + const bridgeManager = ServiceBridgeManager.getInstance(); + bridgeManager.addError({ + serviceName, + error: `Method ${methodName} failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + severity: 'error', + timestamp: Date.now() + }); + console.error(`[ServiceBridge] Error in ${serviceName}.${methodName}:`, error); + throw error; + } + }; + } + + /** + * Create service bridges with enhanced error handling + */ + createServiceBridges( + requiredServices: Record | undefined, + getService: (name: string) => any + ): { serviceBridges: Record; errors: ServiceError[] } { + const errors: ServiceError[] = []; + const serviceBridges: Record = {}; + + if (!requiredServices) { + console.warn('[ServiceBridge] No required services provided'); + return { serviceBridges, errors }; + } + + Object.entries(requiredServices).forEach(([serviceName, requirements]) => { + + try { + // Check service availability first + const availabilityCheck = this.checkServiceAvailability(serviceName, getService); + + if (!availabilityCheck.isAvailable) { + throw new Error(`Service not available: ${serviceName}`); + } + + const service = getService(serviceName); + + // Validate required methods if specified + if (requirements.methods) { + const missingMethods = requirements.methods.filter( + method => typeof service[method] !== 'function' ); - } else { - // console.log(`[ServiceBridge] All required methods found for ${serviceName}`); + + if (missingMethods.length > 0) { + console.error(`[ServiceBridge] Missing methods for ${serviceName}:`, missingMethods); + throw new Error( + `Missing required methods: ${missingMethods.join(', ')}` + ); + } } - } - // Create a service bridge object with methods that call the actual service - if (requirements.methods) { - // Create a new object with just the required methods - const bridge = requirements.methods.reduce((acc, method) => { - // Store a reference to the method to avoid capturing 'this' - const serviceMethod = service[method]; - // console.log(`[ServiceBridge] Creating bridge for method: ${method}`); + // Create a service bridge object with methods that call the actual service + if (requirements.methods) { + // Create a new object with just the required methods + const bridge = requirements.methods.reduce((acc, method) => { + const serviceMethod = service[method]; + + // Create a safe wrapper function + acc[method] = this.createSafeMethodWrapper(serviceName, method, serviceMethod, service); + return acc; + }, {} as Record); - // Create a wrapper function that calls the service method - acc[method] = function(...args: any[]) { - // console.log(`[ServiceBridge] Calling bridged method ${serviceName}.${method} with args:`, args); - return serviceMethod.apply(service, args); - }; - return acc; - }, {} as Record); + serviceBridges[serviceName] = bridge; + } else { + // If no specific methods are required, use the entire service + serviceBridges[serviceName] = service; + } + } catch (error) { + const serviceError: ServiceError = { + serviceName, + error: error instanceof Error ? error.message : 'Unknown error', + severity: 'error', + timestamp: Date.now() + }; - serviceBridges[serviceName] = bridge; - // console.log(`[ServiceBridge] Service bridge created for ${serviceName}:`, Object.keys(bridge)); - } else { - // If no specific methods are required, use the entire service - serviceBridges[serviceName] = service; - // console.log(`[ServiceBridge] Full service used for ${serviceName} (no specific methods required)`); + console.error(`[ServiceBridge] Error creating bridge for ${serviceName}:`, error); + errors.push(serviceError); + this.addError(serviceError); } - } catch (error) { - console.error(`[ServiceBridge] Error creating bridge for ${serviceName}:`, error); - errors.push({ - serviceName, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - }); - - // console.log('[ServiceBridge] Final service bridges:', Object.keys(serviceBridges)); - // console.log('[ServiceBridge] Errors:', errors); - - return { serviceBridges, errors }; + }); + + return { serviceBridges, errors }; + } +} + +/** + * Legacy function for backward compatibility + * Creates service bridges based on required services and a service provider function + * @param requiredServices Map of service names to their requirements + * @param getService Function to retrieve a service by name + * @returns Object containing service bridges and any errors encountered + */ +export function createServiceBridges( + requiredServices: Record | undefined, + getService: (name: string) => any +): { serviceBridges: Record; errors: ServiceError[] } { + const bridgeManager = ServiceBridgeManager.getInstance(); + return bridgeManager.createServiceBridges(requiredServices, getService); +} + +/** + * Get service availability information + */ +export function getServiceAvailability(serviceName: string): ServiceAvailabilityCheck | null { + const bridgeManager = ServiceBridgeManager.getInstance(); + return bridgeManager.getServiceAvailability(serviceName); +} + +/** + * Get all service availability information + */ +export function getAllServiceAvailability(): ServiceAvailabilityCheck[] { + const bridgeManager = ServiceBridgeManager.getInstance(); + return bridgeManager.getAllServiceAvailability(); +} + +/** + * Get service bridge error history + */ +export function getServiceBridgeErrors(): ServiceError[] { + const bridgeManager = ServiceBridgeManager.getInstance(); + return bridgeManager.getErrorHistory(); +} + +/** + * Clear service bridge error history + */ +export function clearServiceBridgeErrors(): void { + const bridgeManager = ServiceBridgeManager.getInstance(); + bridgeManager.clearErrorHistory(); }