""" SpaceService - Main orchestration service for Information Spaces. This module provides the primary API for space operations, coordinating between repositories, event handling, and transclusion context management. """ from typing import List, Optional, Dict, Any from pathlib import Path from ..models import ( InformationSpace, SpaceDocument, SpaceVariable, TransclusionReference, SpaceStatus, SpaceConfig, SpaceMetadata, ) from ..repositories.interfaces import ( ISpaceRepository, IDocumentAssociationRepository, IVariableRepository, IReferenceRepository, ) from ..events import ( EventBus, SpaceEvent, SpaceEventType, space_created_event, space_updated_event, space_deleted_event, document_added_event, document_removed_event, document_content_changed_event, ) class SpaceService: """ Main orchestration service for Information Space operations. Provides a high-level API for managing spaces, documents, variables, and transclusion references. This service coordinates between the repository layer and future event/rendering systems. Usage: service = SpaceService( space_repo=SqliteSpaceRepository(db_path), document_repo=SqliteDocumentRepository(db_path), variable_repo=SqliteVariableRepository(db_path), reference_repo=SqliteReferenceRepository(db_path), ) # Create a space space = service.create_space(name="my-docs", description="My documentation") # Add documents service.add_document(space.id, "/intro.md", document_id="doc-1") """ def __init__( self, space_repo: ISpaceRepository, document_repo: IDocumentAssociationRepository, variable_repo: IVariableRepository, reference_repo: IReferenceRepository, event_bus: Optional[EventBus] = None, ): """ Initialize the SpaceService. Args: space_repo: Repository for space CRUD operations document_repo: Repository for document associations variable_repo: Repository for space variables reference_repo: Repository for transclusion references event_bus: Optional event bus for emitting events """ self._space_repo = space_repo self._document_repo = document_repo self._variable_repo = variable_repo self._reference_repo = reference_repo self._event_bus = event_bus def _emit(self, event: SpaceEvent) -> None: """Emit an event if event bus is configured.""" if self._event_bus: self._event_bus.emit(event) # ========================================================================= # Space CRUD Operations # ========================================================================= def create_space( self, name: str, description: Optional[str] = None, config: Optional[SpaceConfig] = None, metadata: Optional[SpaceMetadata] = None, parent_space_id: Optional[str] = None, ) -> InformationSpace: """ Create a new information space. Args: name: Unique name for the space description: Optional description config: Optional configuration (defaults provided if None) metadata: Optional metadata (defaults provided if None) parent_space_id: Optional parent space for hierarchy Returns: The created InformationSpace Raises: ValueError: If name is empty or already exists """ if not name or not name.strip(): raise ValueError("Space name cannot be empty") # Validate parent exists if specified if parent_space_id: parent = self._space_repo.get_by_id(parent_space_id) if not parent: raise ValueError(f"Parent space '{parent_space_id}' not found") space = InformationSpace( name=name.strip(), description=description, config=config or SpaceConfig(), metadata=metadata or SpaceMetadata(), parent_space_id=parent_space_id, ) created_space = self._space_repo.create(space) self._emit(space_created_event(created_space.id, created_space.name)) return created_space def get_space(self, space_id: str) -> Optional[InformationSpace]: """ Get a space by its ID. Args: space_id: The space ID Returns: The space if found, None otherwise """ return self._space_repo.get_by_id(space_id) def get_space_by_name(self, name: str) -> Optional[InformationSpace]: """ Get a space by its name. Args: name: The space name Returns: The space if found, None otherwise """ return self._space_repo.get_by_name(name) def list_spaces(self, include_archived: bool = False) -> List[InformationSpace]: """ List all spaces. Args: include_archived: Whether to include archived spaces Returns: List of spaces """ return self._space_repo.list_all(include_archived=include_archived) def update_space( self, space_id: str, name: Optional[str] = None, description: Optional[str] = None, config: Optional[SpaceConfig] = None, metadata: Optional[SpaceMetadata] = None, ) -> InformationSpace: """ Update a space's properties. Args: space_id: The space ID to update name: New name (optional) description: New description (optional) config: New config (optional) metadata: New metadata (optional) Returns: The updated space Raises: ValueError: If space not found or name already taken """ space = self._space_repo.get_by_id(space_id) if not space: raise ValueError(f"Space '{space_id}' not found") if name is not None: if not name.strip(): raise ValueError("Space name cannot be empty") # Check if name is taken by another space existing = self._space_repo.get_by_name(name.strip()) if existing and existing.id != space_id: raise ValueError(f"Space name '{name}' already exists") space.name = name.strip() if description is not None: space.description = description if config is not None: space.config = config if metadata is not None: space.metadata = metadata updated_space = self._space_repo.update(space) # Build changes dict for event changes = {} if name is not None: changes["name"] = name if description is not None: changes["description"] = description if config is not None: changes["config"] = "updated" if metadata is not None: changes["metadata"] = "updated" self._emit(space_updated_event(space_id, changes)) return updated_space def delete_space(self, space_id: str, cascade: bool = True) -> bool: """ Delete a space. Args: space_id: The space ID to delete cascade: If True, delete all child spaces too Returns: True if deleted, False if not found Raises: ValueError: If space has children and cascade is False """ space = self._space_repo.get_by_id(space_id) if not space: return False children = self._space_repo.get_children(space_id) if children and not cascade: raise ValueError( f"Space '{space_id}' has {len(children)} child spaces. " "Set cascade=True to delete them." ) # Delete children first (if cascade) if cascade: for child in children: self.delete_space(child.id, cascade=True) space_name = space.name result = self._space_repo.delete(space_id) if result: self._emit(space_deleted_event(space_id, space_name)) return result def activate_space(self, space_id: str) -> InformationSpace: """ Activate a space (change status from draft to active). Args: space_id: The space ID Returns: The updated space Raises: ValueError: If space not found """ space = self._space_repo.get_by_id(space_id) if not space: raise ValueError(f"Space '{space_id}' not found") space.activate() updated_space = self._space_repo.update(space) self._emit(SpaceEvent( event_type=SpaceEventType.SPACE_ACTIVATED, space_id=space_id, payload={"status": "active"}, )) return updated_space def archive_space(self, space_id: str) -> InformationSpace: """ Archive a space. Args: space_id: The space ID Returns: The updated space Raises: ValueError: If space not found """ space = self._space_repo.get_by_id(space_id) if not space: raise ValueError(f"Space '{space_id}' not found") space.archive() updated_space = self._space_repo.update(space) self._emit(SpaceEvent( event_type=SpaceEventType.SPACE_ARCHIVED, space_id=space_id, payload={"status": "archived"}, )) return updated_space def get_child_spaces(self, parent_space_id: str) -> List[InformationSpace]: """ Get all child spaces of a parent. Args: parent_space_id: The parent space ID Returns: List of child spaces """ return self._space_repo.get_children(parent_space_id) # ========================================================================= # Document Operations # ========================================================================= def add_document( self, space_id: str, space_path: str, document_id: Optional[str] = None, order_index: int = 0, metadata: Optional[Dict[str, Any]] = None, content_hash: Optional[str] = None, ) -> SpaceDocument: """ Add a document to a space. Args: space_id: The space ID space_path: Path within the space (e.g., "/intro.md") document_id: External document ID (optional) order_index: Position in space ordering metadata: Document metadata content_hash: Content hash for change detection Returns: The created document association Raises: ValueError: If space not found or path already exists """ if not self._space_repo.exists(space_id): raise ValueError(f"Space '{space_id}' not found") # Normalize path if not space_path.startswith("/"): space_path = "/" + space_path document = SpaceDocument( space_id=space_id, document_id=document_id or "", space_path=space_path, order_index=order_index, metadata=metadata or {}, content_hash=content_hash, ) added_doc = self._document_repo.add_document(document) self._emit(document_added_event(space_id, added_doc.id, space_path)) return added_doc def get_document(self, document_id: str) -> Optional[SpaceDocument]: """ Get a document by its association ID. Args: document_id: The document association ID Returns: The document if found, None otherwise """ return self._document_repo.get_document(document_id) def get_document_by_path( self, space_id: str, space_path: str ) -> Optional[SpaceDocument]: """ Get a document by its path within a space. Args: space_id: The space ID space_path: The path within the space Returns: The document if found, None otherwise """ # Normalize path if not space_path.startswith("/"): space_path = "/" + space_path return self._document_repo.get_by_space_path(space_id, space_path) def list_documents(self, space_id: str) -> List[SpaceDocument]: """ List all documents in a space. Args: space_id: The space ID Returns: List of documents ordered by order_index """ return self._document_repo.list_by_space(space_id) def remove_document(self, document_id: str) -> bool: """ Remove a document from a space. Args: document_id: The document association ID Returns: True if removed, False if not found """ # Get document info before removal for event document = self._document_repo.get_document(document_id) if not document: return False # Clear any references from this document first self._reference_repo.clear_references_from(document_id, document.space_id) result = self._document_repo.remove_document(document_id) if result: self._emit(document_removed_event( document.space_id, document_id, document.space_path )) return result def move_document(self, document_id: str, new_path: str) -> SpaceDocument: """ Move a document to a new path within its space. Args: document_id: The document association ID new_path: The new path Returns: The updated document Raises: ValueError: If document not found or new path exists """ # Get old path for event old_doc = self._document_repo.get_document(document_id) old_path = old_doc.space_path if old_doc else None if not new_path.startswith("/"): new_path = "/" + new_path moved_doc = self._document_repo.move_document(document_id, new_path) if old_doc: self._emit(SpaceEvent( event_type=SpaceEventType.DOCUMENT_MOVED, space_id=moved_doc.space_id, payload={ "document_id": document_id, "old_path": old_path, "new_path": new_path, }, )) return moved_doc def reorder_documents(self, space_id: str, document_ids: List[str]) -> None: """ Reorder documents within a space. Args: space_id: The space ID document_ids: Ordered list of document IDs """ self._document_repo.reorder_documents(space_id, document_ids) def update_document_hash(self, document_id: str, content_hash: str) -> None: """ Update the content hash for a document. Args: document_id: The document association ID content_hash: The new content hash """ # Get old hash for event document = self._document_repo.get_document(document_id) old_hash = document.content_hash if document else None self._document_repo.update_content_hash(document_id, content_hash) if document and old_hash != content_hash: self._emit(document_content_changed_event( document.space_id, document_id, old_hash, content_hash, )) # ========================================================================= # Variable Operations # ========================================================================= def set_variable( self, space_id: str, name: str, value: Any, scope: str = "space", ) -> SpaceVariable: """ Set a variable in a space. Args: space_id: The space ID name: Variable name value: Variable value (any JSON-serializable value) scope: Variable scope ("space" or "document") Returns: The saved variable Raises: ValueError: If space not found """ if not self._space_repo.exists(space_id): raise ValueError(f"Space '{space_id}' not found") variable = SpaceVariable( space_id=space_id, name=name, value=value, scope=scope, ) saved_var = self._variable_repo.set_variable(variable) self._emit(SpaceEvent( event_type=SpaceEventType.VARIABLE_SET, space_id=space_id, payload={"name": name, "scope": scope}, )) return saved_var def get_variable(self, space_id: str, name: str) -> Optional[SpaceVariable]: """ Get a variable by name. Args: space_id: The space ID name: Variable name Returns: The variable if found, None otherwise """ return self._variable_repo.get_variable(space_id, name) def list_variables( self, space_id: str, scope: Optional[str] = None ) -> List[SpaceVariable]: """ List all variables in a space. Args: space_id: The space ID scope: Optional scope filter Returns: List of variables """ return self._variable_repo.list_variables(space_id, scope) def delete_variable(self, space_id: str, name: str) -> bool: """ Delete a variable. Args: space_id: The space ID name: Variable name Returns: True if deleted, False if not found """ result = self._variable_repo.delete_variable(space_id, name) if result: self._emit(SpaceEvent( event_type=SpaceEventType.VARIABLE_DELETED, space_id=space_id, payload={"name": name}, )) return result def get_variables_dict(self, space_id: str) -> Dict[str, Any]: """ Get all variables as a dictionary. Useful for transclusion context. Args: space_id: The space ID Returns: Dictionary of variable names to values """ variables = self._variable_repo.list_variables(space_id) return {var.name: var.value for var in variables} # ========================================================================= # Reference Operations # ========================================================================= def add_reference( self, source_doc_id: str, target_doc_id: str, space_id: str, ) -> TransclusionReference: """ Add a transclusion reference. Args: source_doc_id: The source document ID target_doc_id: The target document ID space_id: The space ID Returns: The created reference """ reference = TransclusionReference( source_doc_id=source_doc_id, target_doc_id=target_doc_id, space_id=space_id, ) return self._reference_repo.add_reference(reference) def get_references_from( self, source_doc_id: str, space_id: str ) -> List[TransclusionReference]: """ Get all references from a source document. Args: source_doc_id: The source document ID space_id: The space ID Returns: List of references """ return self._reference_repo.get_references_from(source_doc_id, space_id) def get_references_to( self, target_doc_id: str, space_id: str ) -> List[TransclusionReference]: """ Get all references to a target document. Args: target_doc_id: The target document ID space_id: The space ID Returns: List of references """ return self._reference_repo.get_references_to(target_doc_id, space_id) def clear_references_from(self, source_doc_id: str, space_id: str) -> int: """ Clear all references from a source document. Args: source_doc_id: The source document ID space_id: The space ID Returns: Number of references cleared """ return self._reference_repo.clear_references_from(source_doc_id, space_id) def get_dependents(self, document_id: str, space_id: str) -> List[str]: """ Get all documents that depend on a given document. Used for cache invalidation - returns documents that need to be re-rendered when the target document changes. Args: document_id: The document ID space_id: The space ID Returns: List of dependent document IDs """ return self._reference_repo.get_dependents(document_id, space_id) # ========================================================================= # Convenience Methods # ========================================================================= def space_exists(self, space_id: str) -> bool: """ Check if a space exists. Args: space_id: The space ID Returns: True if exists, False otherwise """ return self._space_repo.exists(space_id) def get_space_stats(self, space_id: str) -> Dict[str, Any]: """ Get statistics about a space. Args: space_id: The space ID Returns: Dictionary with statistics Raises: ValueError: If space not found """ space = self._space_repo.get_by_id(space_id) if not space: raise ValueError(f"Space '{space_id}' not found") documents = self._document_repo.list_by_space(space_id) variables = self._variable_repo.list_variables(space_id) children = self._space_repo.get_children(space_id) return { "space_id": space_id, "name": space.name, "status": space.status.value, "document_count": len(documents), "variable_count": len(variables), "child_space_count": len(children), "created_at": space.created_at.isoformat(), "updated_at": space.updated_at.isoformat(), }