mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
service logic wip
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user