diff --git a/src/basic_memory/models.py b/src/basic_memory/models.py index 34967a40..53ad0d8c 100644 --- a/src/basic_memory/models.py +++ b/src/basic_memory/models.py @@ -1,11 +1,29 @@ from datetime import datetime, UTC -from typing import List -from sqlalchemy import String, DateTime, ForeignKey, Text +from typing import List, Optional +from sqlalchemy import String, DateTime, ForeignKey, Text, TypeDecorator from sqlalchemy.orm import Mapped, mapped_column, relationship, DeclarativeBase from sqlalchemy.ext.asyncio import AsyncAttrs -def utc_now(): +class UTCDateTime(TypeDecorator): + """Automatically handle UTC timezone for datetime columns""" + impl = DateTime + cache_ok = True + + def process_bind_param(self, value: Optional[datetime], dialect): + if value is not None: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + return value + + def process_result_value(self, value: Optional[datetime], dialect): + if value is not None: + return value.replace(tzinfo=UTC) + return value + + +def utc_now() -> datetime: """Helper to get current UTC time""" return datetime.now(UTC) @@ -36,10 +54,10 @@ class Entity(Base): description: Mapped[str] = mapped_column(Text, nullable=False, default="") references: Mapped[str] = mapped_column(Text, nullable=False, default="") created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=utc_now + UTCDateTime, default=utc_now ) updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), + UTCDateTime, default=utc_now, onupdate=utc_now ) @@ -82,7 +100,7 @@ class Observation(Base): ) content: Mapped[str] = mapped_column(String) created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), + UTCDateTime, default=utc_now ) context: Mapped[str | None] = mapped_column(String, nullable=True) @@ -114,7 +132,7 @@ class Relation(Base): ) relation_type: Mapped[str] = mapped_column(String) created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), + UTCDateTime, default=utc_now ) context: Mapped[str | None] = mapped_column(String, nullable=True) diff --git a/src/basic_memory/service.py b/src/basic_memory/service.py new file mode 100644 index 00000000..76138eb6 --- /dev/null +++ b/src/basic_memory/service.py @@ -0,0 +1,200 @@ +from datetime import datetime, UTC +from pathlib import Path +from typing import List, Optional, Tuple, Dict +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 + + async def _get_repos(self) -> Tuple[EntityRepository, ObservationRepository, RelationRepository]: + """Get repository instances with a shared session""" + session = self.session_maker() + try: + return ( + EntityRepository(session, Entity), + ObservationRepository(session, Observation), + RelationRepository(session, Relation) + ) + except: + await session.close() + raise + + @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.""" + entity_repo, _, _ = await self._get_repos() + + 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, context: Optional[str] = None) -> Entity: + """Create a new entity.""" + entity_id = self._generate_id(name) + entity_data = { + "id": entity_id, + "name": name, + "entity_type": type, + "context": context, + "created_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 + try: + entity_repo, _, _ = await self._get_repos() + 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: + entity_repo, _, _ = await self._get_repos() + 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.""" + # Implementation depends on what needs cleanup + pass \ No newline at end of file diff --git a/tests/.coverage b/tests/.coverage deleted file mode 100644 index 0fe772f5..00000000 Binary files a/tests/.coverage and /dev/null differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..afdf5137 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +import pytest +import pytest_asyncio +import tempfile +from pathlib import Path + +from basic_memory.service import MemoryService +from basic_memory.db import init_connection + +@pytest_asyncio.fixture +async def memory_service(): + """Fixture providing initialized MemoryService with temp directories.""" + # Create temp directory that is cleaned up after the test + with tempfile.TemporaryDirectory() as temp_dir: + # Override project path + test_project_path = Path(temp_dir) / "test-project" + + # Initialize database connection + init_connection("test-project") + + # Initialize service with test configuration + service = MemoryService("test-project") + service.project_path = test_project_path + + # Initialize project structure + await service.initialize_project() + + yield service + + # Cleanup happens automatically when temp directory is removed + +@pytest.fixture +def sample_entity_files(memory_service): + """Fixture providing sample markdown files.""" + entity_dir = memory_service.project_path / "entities" + + # Create sample files + files = { + "test-entity-1": "# Test Entity 1\n\nContent", + "test-entity-2": "# Test Entity 2\n\nContent" + } + + for name, content in files.items(): + (entity_dir / f"{name}.md").write_text(content) + + return files \ No newline at end of file diff --git a/tests/test_service_create.py b/tests/test_service_create.py new file mode 100644 index 00000000..d5ff11ba --- /dev/null +++ b/tests/test_service_create.py @@ -0,0 +1,98 @@ +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", + context="test context" + ) + + # Assert + assert isinstance(entity, Entity) + assert entity.name == "Test Entity" + assert entity.entity_type == "test" + assert entity.context == "test context" + + # 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