diff --git a/src/basic_memory/service.py b/src/basic_memory/service.py deleted file mode 100644 index 8f9de45d..00000000 --- a/src/basic_memory/service.py +++ /dev/null @@ -1,207 +0,0 @@ -from datetime import datetime, UTC -from pathlib import Path -from typing import List, Optional, Tuple, Dict, AsyncContextManager -from contextlib import asynccontextmanager -from uuid import uuid4 -import shutil - -from sqlalchemy.ext.asyncio import AsyncSession -from basic_memory.db import get_sessionmaker -from basic_memory.models import Entity, Observation, Relation -from basic_memory.repository import EntityRepository, ObservationRepository, RelationRepository - -class MemoryServiceError(Exception): - """Base exception for memory service errors""" - pass - -class FileOperationError(MemoryServiceError): - """Raised when file operations fail""" - pass - -class DatabaseSyncError(MemoryServiceError): - """Raised when database sync fails - indicates reindex may be needed""" - pass - -class EntityNotFoundError(MemoryServiceError): - """Raised when an entity cannot be found in the filesystem""" - pass - -class MemoryService: - """ - Core service layer for basic-memory. - - Implementation follows "filesystem is source of truth" principle: - 1. Write to filesystem first - 2. Update database indexes second - 3. Database is treated as disposable/rebuild-able index - """ - - def __init__(self, project_name: str): - self.project_name = project_name - self.session_maker = get_sessionmaker() - self.project_path = Path.home() / ".basic-memory" / "projects" / project_name - - async def initialize_project(self): - """ - Initialize project directory structure. - Called when creating a new project or ensuring structure exists. - """ - try: - (self.project_path / "entities").mkdir(parents=True, exist_ok=True) - # Add other directories as needed (e.g., for attachments) - except Exception as e: - raise FileOperationError(f"Failed to initialize project structure: {str(e)}") from e - - @asynccontextmanager - async def _get_repos(self) -> AsyncContextManager[Tuple[EntityRepository, ObservationRepository, RelationRepository]]: - """Get repository instances with a shared session""" - session = self.session_maker() - try: - repos = ( - EntityRepository(session, Entity), - ObservationRepository(session, Observation), - RelationRepository(session, Relation) - ) - yield repos - await session.commit() - except: - await session.rollback() - raise - finally: - await session.close() - - @staticmethod - def _generate_id(name: str) -> str: - """Generate timestamp-based ID for an entity""" - timestamp = datetime.now(UTC).strftime("%Y%m%d") - normalized_name = name.lower().replace(" ", "-") - return f"{timestamp}-{normalized_name}-{uuid4().hex[:8]}" - - async def _write_entity_file(self, entity_data: dict) -> bool: - """Write entity to filesystem in markdown format.""" - try: - entity_path = self.project_path / "entities" / f"{entity_data['id']}.md" - entity_path.parent.mkdir(parents=True, exist_ok=True) - - # TODO: Replace with actual markdown formatting - content = f"# {entity_data['name']}\n\nStub content" - - # Write to temp file first, then rename for atomic operation - temp_path = entity_path.with_suffix('.tmp') - temp_path.write_text(content) - temp_path.rename(entity_path) - - return True - except Exception as e: - raise FileOperationError(f"Failed to write entity file: {str(e)}") from e - - async def _read_entity_file(self, entity_id: str) -> Dict: - """Read entity data from filesystem.""" - try: - entity_path = self.project_path / "entities" / f"{entity_id}.md" - if not entity_path.exists(): - raise EntityNotFoundError(f"Entity file not found: {entity_id}") - - # TODO: Implement actual markdown parsing - content = entity_path.read_text() - # Stub parsing - return {"id": entity_id, "content": content} - except EntityNotFoundError: - raise - except Exception as e: - raise FileOperationError(f"Failed to read entity file: {str(e)}") from e - - async def _update_db_index(self, entity_data: dict) -> Entity: - """Update database index with entity data using upsert pattern.""" - async with self._get_repos() as (entity_repo, _, _): - try: - # TODO: Implement proper upsert logic - try: - return await entity_repo.create(entity_data) - except: - existing = await entity_repo.find_by_id(entity_data['id']) - if existing: - return await entity_repo.update(entity_data['id'], entity_data) - raise - except Exception as e: - raise DatabaseSyncError(f"Failed to update database index: {str(e)}") from e - - async def create_entity(self, name: str, type: str, description: str = "") -> Entity: - """Create a new entity.""" - entity_id = self._generate_id(name) - entity_data = { - "id": entity_id, - "name": name, - "entity_type": type, - "description": description, - "references": "", - "created_at": datetime.now(UTC), - "updated_at": datetime.now(UTC) - } - - # Step 1: Write to filesystem (source of truth) - await self._write_entity_file(entity_data) - - # Step 2: Update database index (can be rebuilt if needed) - try: - return await self._update_db_index(entity_data) - except DatabaseSyncError as e: - print(f"Warning: Database sync failed, reindex may be needed: {str(e)}") - return Entity(**entity_data) - - async def get_entity(self, entity_id: str) -> Entity: - """Get entity by ID, reading from filesystem first.""" - # Read from filesystem (source of truth) - entity_data = await self._read_entity_file(entity_id) - - # Try to get from database for full object - async with self._get_repos() as (entity_repo, _, _): - try: - entity = await entity_repo.find_by_id(entity_id) - if entity is None: - # Reindex this entity if not in database - entity = await self._update_db_index(entity_data) - return entity - except DatabaseSyncError: - # Return basic entity from file data if DB fails - return Entity(**entity_data) - - async def delete_entity(self, entity_id: str) -> bool: - """Delete entity from filesystem and database.""" - try: - # Delete from filesystem first - entity_path = self.project_path / "entities" / f"{entity_id}.md" - if entity_path.exists(): - entity_path.unlink() - - # Try to delete from database, but don't error if it fails - try: - async with self._get_repos() as (entity_repo, _, _): - await entity_repo.delete(entity_id) - except DatabaseSyncError: - pass # Database cleanup can happen during reindex - - return True - except Exception as e: - raise FileOperationError(f"Failed to delete entity: {str(e)}") from e - - async def rebuild_index(self) -> None: - """Rebuild database index from filesystem contents.""" - try: - entities_dir = self.project_path / "entities" - if not entities_dir.exists(): - return - - for entity_file in entities_dir.glob("*.md"): - try: - entity_data = await self._read_entity_file(entity_file.stem) - await self._update_db_index(entity_data) - except Exception as e: - print(f"Warning: Failed to reindex {entity_file}: {str(e)}") - except Exception as e: - raise DatabaseSyncError(f"Failed to rebuild index: {str(e)}") from e - - async def cleanup(self) -> None: - """Clean up resources.""" - # For now, we rely on the session cleanup in _get_repos - pass \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 1f3a2736..9511b305 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ import pytest_asyncio import tempfile from pathlib import Path -from basic_memory.service import MemoryService from basic_memory.db import init_connection from basic_memory.models import Base diff --git a/tests/test_service_create.py b/tests/test_service_create.py deleted file mode 100644 index f69967c4..00000000 --- a/tests/test_service_create.py +++ /dev/null @@ -1,98 +0,0 @@ -import pytest -from datetime import datetime -from pathlib import Path - -from basic_memory.service import MemoryService, FileOperationError, DatabaseSyncError -from basic_memory.models import Entity - -pytestmark = pytest.mark.asyncio - - -# Happy Path Tests - -async def test_create_entity_success(memory_service): - """Test successful entity creation.""" - # Act - entity = await memory_service.create_entity( - name="Test Entity", - type="test", - description="test description" - ) - - # Assert - assert isinstance(entity, Entity) - assert entity.name == "Test Entity" - assert entity.entity_type == "test" - assert entity.description == "test description" - - # Verify file was created - entity_file = memory_service.project_path / "entities" / f"{entity.id}.md" - assert entity_file.exists() - content = entity_file.read_text() - assert "Test Entity" in content - -# Error Path Tests - -async def test_create_entity_file_error(memory_service, monkeypatch): - """Test handling of file write errors.""" - # Arrange - make file write fail - async def mock_write_fail(*args, **kwargs): - raise FileOperationError("Mock file write error") - monkeypatch.setattr(memory_service, "_write_entity_file", mock_write_fail) - - # Act & Assert - with pytest.raises(FileOperationError): - await memory_service.create_entity( - name="Test Entity", - type="test" - ) - -async def test_create_entity_db_error(memory_service, monkeypatch): - """Test handling of database errors.""" - # Arrange - make db update fail but file write succeed - async def mock_db_fail(*args, **kwargs): - raise DatabaseSyncError("Mock DB error") - monkeypatch.setattr(memory_service, "_update_db_index", mock_db_fail) - - # Act - entity = await memory_service.create_entity( - name="Test Entity", - type="test" - ) - - # Assert - assert isinstance(entity, Entity) # Should still return entity from file - entity_file = memory_service.project_path / "entities" / f"{entity.id}.md" - assert entity_file.exists() # File should still be created - -# Edge Cases - -async def test_create_entity_with_special_chars(memory_service): - """Test entity creation with special characters in name.""" - name = "Test & Entity! With @ Special #Chars" - entity = await memory_service.create_entity(name=name, type="test") - - assert entity.name == name - entity_file = memory_service.project_path / "entities" / f"{entity.id}.md" - assert entity_file.exists() - -async def test_create_entity_atomic_file_write(memory_service): - """Test that file writing is atomic (uses temp file).""" - # Act - entity = await memory_service.create_entity(name="Test Entity", type="test") - - # Assert - temp_file = memory_service.project_path / "entities" / f"{entity.id}.md.tmp" - assert not temp_file.exists() # Temp file should be cleaned up - - entity_file = memory_service.project_path / "entities" / f"{entity.id}.md" - assert entity_file.exists() # Final file should exist - -# TODO: Add tests for: -# - Concurrent operations -# - System crash simulation -# - Permission issues -# - File system full scenario -# - Long/unicode entity names -# - Empty/whitespace names -# - Other edge cases \ No newline at end of file