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>
763 lines
22 KiB
Python
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(),
|
|
}
|