Files
markitect-main/markitect/spaces/services/space_service.py
tegwick 0a494b2011 feat(spaces): implement Phase 2 Event System
Week 4 - Event Infrastructure:
- Create SpaceEventType enum with 18 event types covering space lifecycle,
  document operations, variables, references, rendering, sync, and cache
- Create SpaceEvent dataclass with serialization/deserialization
- Create EventBus with sync/async handler support, priority ordering,
  global handlers, and optional event history
- Add event factory functions for common events

Week 5 - Event Integration:
- Wire EventBus into SpaceService as optional dependency
- Emit events for all space operations:
  - SPACE_CREATED, SPACE_UPDATED, SPACE_DELETED, SPACE_ACTIVATED, SPACE_ARCHIVED
  - DOCUMENT_ADDED, DOCUMENT_REMOVED, DOCUMENT_MOVED, DOCUMENT_CONTENT_CHANGED
  - VARIABLE_SET, VARIABLE_DELETED
- Create integration tests for event propagation patterns

Test coverage: 187 tests total
- 43 unit tests for event system
- 20 integration tests for event propagation
- 124 existing tests continue to pass

Capabilities delivered:
- CAP-010: SpaceEvent base with type, payload, timestamp
- CAP-011: EventBus with in-process publish/subscribe
- CAP-012: Event handlers registry with priority support
- CAP-013: Change detection via content hash comparison

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 07:41:47 +01:00

763 lines
22 KiB
Python

"""
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(),
}