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>
This commit is contained in:
2026-02-08 07:41:47 +01:00
parent 9b12875681
commit 0a494b2011
7 changed files with 1807 additions and 19 deletions

View File

@@ -23,6 +23,17 @@ from ..repositories.interfaces import (
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:
@@ -54,6 +65,7 @@ class SpaceService:
document_repo: IDocumentAssociationRepository,
variable_repo: IVariableRepository,
reference_repo: IReferenceRepository,
event_bus: Optional[EventBus] = None,
):
"""
Initialize the SpaceService.
@@ -63,11 +75,18 @@ class SpaceService:
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
@@ -114,7 +133,9 @@ class SpaceService:
parent_space_id=parent_space_id,
)
return self._space_repo.create(space)
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]:
"""
@@ -198,7 +219,21 @@ class SpaceService:
if metadata is not None:
space.metadata = metadata
return self._space_repo.update(space)
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:
"""
@@ -230,7 +265,11 @@ class SpaceService:
for child in children:
self.delete_space(child.id, cascade=True)
return self._space_repo.delete(space_id)
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:
"""
@@ -250,7 +289,13 @@ class SpaceService:
raise ValueError(f"Space '{space_id}' not found")
space.activate()
return self._space_repo.update(space)
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:
"""
@@ -270,7 +315,13 @@ class SpaceService:
raise ValueError(f"Space '{space_id}' not found")
space.archive()
return self._space_repo.update(space)
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]:
"""
@@ -330,7 +381,9 @@ class SpaceService:
content_hash=content_hash,
)
return self._document_repo.add_document(document)
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]:
"""
@@ -384,12 +437,20 @@ class SpaceService:
Returns:
True if removed, False if not found
"""
# Clear any references from this document first
# Get document info before removal for event
document = self._document_repo.get_document(document_id)
if document:
self._reference_repo.clear_references_from(document_id, document.space_id)
if not document:
return False
return self._document_repo.remove_document(document_id)
# 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:
"""
@@ -405,9 +466,26 @@ class SpaceService:
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
return self._document_repo.move_document(document_id, 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:
"""
@@ -427,8 +505,20 @@ class SpaceService:
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
# =========================================================================
@@ -465,7 +555,13 @@ class SpaceService:
scope=scope,
)
return self._variable_repo.set_variable(variable)
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]:
"""
@@ -506,7 +602,14 @@ class SpaceService:
Returns:
True if deleted, False if not found
"""
return self._variable_repo.delete_variable(space_id, name)
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]:
"""