diff --git a/src/basic_memory/services/document_service.py b/src/basic_memory/services/document_service.py new file mode 100644 index 00000000..9dfa804b --- /dev/null +++ b/src/basic_memory/services/document_service.py @@ -0,0 +1,198 @@ +"""Service for managing documents in the system.""" + +import hashlib +from pathlib import Path +from typing import Optional, Dict, Any + +from loguru import logger + +from basic_memory.models import Document +from basic_memory.repository.document_repository import DocumentRepository +from basic_memory.services.service import BaseService + + +class DocumentError(Exception): + """Base exception for document operations.""" + + pass + + +class DocumentNotFoundError(DocumentError): + """Raised when a document doesn't exist.""" + + pass + + +class DocumentWriteError(DocumentError): + """Raised when document file operations fail.""" + + pass + + +class DocumentService(BaseService[DocumentRepository]): + """ + Service for managing documents and their metadata. + + Handles both file operations and database tracking, keeping + them in sync. The filesystem is the source of truth. + """ + + def __init__(self, document_repository: DocumentRepository): + super().__init__(document_repository) + + async def compute_checksum(self, content: str) -> str: + """Compute SHA-256 checksum of content.""" + return hashlib.sha256(content.encode()).hexdigest() + + async def create_document( + self, path: str, content: str, metadata: Optional[Dict[str, Any]] = None + ) -> Document: + """ + Create a new document. + + Args: + path: Path where to create the document + content: Document content + metadata: Optional metadata to store + + Returns: + Created document record + + Raises: + DocumentWriteError: If file cannot be written + """ + logger.debug(f"Creating document at {path}") + + # Ensure parent directories exist + file_path = Path(path) + file_path.parent.mkdir(parents=True, exist_ok=True) + + # Write file first + try: + file_path.write_text(content) + except Exception as e: + raise DocumentWriteError(f"Failed to write document file: {e}") + + # After file is written, create database record + try: + checksum = await self.compute_checksum(content) + doc = await self.repository.create( + {"path": str(path), "checksum": checksum, "doc_metadata": metadata} + ) + return doc + except Exception: + # If database operation fails, clean up the file + file_path.unlink(missing_ok=True) + raise + + async def read_document(self, path: str) -> tuple[Document, str]: + """ + Read a document and its content. + + Args: + path: Path to the document + + Returns: + Tuple of (document record, content) + + Raises: + DocumentNotFoundError: If document doesn't exist + """ + logger.debug(f"Reading document at {path}") + + # Check if file exists + file_path = Path(path) + if not file_path.exists(): + raise DocumentNotFoundError(f"Document not found: {path}") + + # Read content first since file is source of truth + try: + content = file_path.read_text() + except Exception as e: + raise DocumentError(f"Failed to read document: {e}") + + # Get document record + doc = await self.repository.find_by_path(str(path)) + if not doc: + # File exists but no DB record - create one + checksum = await self.compute_checksum(content) + doc = await self.repository.create({"path": str(path), "checksum": checksum}) + + return doc, content + + async def update_document( + self, path: str, content: str, metadata: Optional[Dict[str, Any]] = None + ) -> Document: + """ + Update an existing document. + + Args: + path: Path to the document + content: New content + metadata: Optional new metadata + + Returns: + Updated document record + + Raises: + DocumentNotFoundError: If document doesn't exist + DocumentWriteError: If update fails + """ + logger.debug(f"Updating document at {path}") + + # Verify file exists + file_path = Path(path) + if not file_path.exists(): + raise DocumentNotFoundError(f"Document not found: {path}") + + # Write new content first + try: + file_path.write_text(content) + except Exception as e: + raise DocumentWriteError(f"Failed to write document: {e}") + + # Update database record + doc = await self.repository.find_by_path(str(path)) + if not doc: + # File exists but no DB record - create one + checksum = await self.compute_checksum(content) + return await self.repository.create( + {"path": str(path), "checksum": checksum, "doc_metadata": metadata} + ) + + # Update existing record + checksum = await self.compute_checksum(content) + update_data = {"checksum": checksum} + if metadata is not None: + update_data["doc_metadata"] = metadata # pyright: ignore [reportArgumentType] + + return await self.repository.update(doc.id, update_data) + + async def delete_document(self, path: str) -> None: + """ + Delete a document. + + Args: + path: Path to the document + + Raises: + DocumentNotFoundError: If document doesn't exist + DocumentWriteError: If deletion fails + """ + logger.debug(f"Deleting document at {path}") + + # Verify file exists + file_path = Path(path) + if not file_path.exists(): + raise DocumentNotFoundError(f"Document not found: {path}") + + # Delete file first + try: + file_path.unlink() + except Exception as e: + raise DocumentWriteError(f"Failed to delete document: {e}") + + # Delete database record if it exists + doc = await self.repository.find_by_path(str(path)) + if doc: + await self.repository.delete(doc.id) diff --git a/tests/repository/test_document_repository.py b/tests/repository/test_document_repository.py index 525f30cc..341dbdbd 100644 --- a/tests/repository/test_document_repository.py +++ b/tests/repository/test_document_repository.py @@ -25,6 +25,39 @@ async def sample_doc(session_maker): return doc +@pytest.mark.asyncio +async def test_basic_document_operations(document_repository: DocumentRepository): + """Smoke test for basic document operations.""" + # Create + doc_data = { + "path": "test/basic.md", + "checksum": "test123", + "doc_metadata": {"type": "test"} + } + doc = await document_repository.create(doc_data) + assert doc.path == "test/basic.md" + + # Read + found = await document_repository.find_by_path("test/basic.md") + assert found is not None + assert found.checksum == "test123" + + # Update + updated = await document_repository.update( + doc.id, + {"checksum": "changed123"} + ) + assert updated.checksum == "changed123" + + # Delete + result = await document_repository.delete(doc.id) + assert result is True + + # Verify deletion + not_found = await document_repository.find_by_path("test/basic.md") + assert not_found is None + + @pytest.mark.asyncio async def test_create_document(document_repository: DocumentRepository): """Test creating a new document."""