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:
@@ -51,6 +51,15 @@ from .repositories import (
|
||||
initialize_space_tables,
|
||||
)
|
||||
|
||||
# Phase 2: Event System
|
||||
from .events import (
|
||||
SpaceEvent,
|
||||
SpaceEventType,
|
||||
EventBus,
|
||||
get_event_bus,
|
||||
reset_event_bus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Models
|
||||
"InformationSpace",
|
||||
@@ -73,4 +82,10 @@ __all__ = [
|
||||
"SqliteVariableRepository",
|
||||
"SqliteReferenceRepository",
|
||||
"initialize_space_tables",
|
||||
# Event System
|
||||
"SpaceEvent",
|
||||
"SpaceEventType",
|
||||
"EventBus",
|
||||
"get_event_bus",
|
||||
"reset_event_bus",
|
||||
]
|
||||
|
||||
@@ -3,14 +3,55 @@ Event system for Information Spaces.
|
||||
|
||||
This package provides event-driven architecture for space operations:
|
||||
- SpaceEvent: Event dataclass with type, payload, timestamp
|
||||
- SpaceEventType: Enum of all event types
|
||||
- EventBus: In-process publish/subscribe for space events
|
||||
- Event handlers and registration
|
||||
- Event factory functions for common events
|
||||
|
||||
Events emitted:
|
||||
- SPACE_CREATED, SPACE_UPDATED, SPACE_DELETED
|
||||
- DOCUMENT_ADDED, DOCUMENT_UPDATED, DOCUMENT_REMOVED
|
||||
- RENDER_COMPLETED, SYNC_COMPLETED
|
||||
- Space lifecycle: SPACE_CREATED, SPACE_UPDATED, SPACE_DELETED, SPACE_ACTIVATED, SPACE_ARCHIVED
|
||||
- Document: DOCUMENT_ADDED, DOCUMENT_UPDATED, DOCUMENT_REMOVED, DOCUMENT_MOVED, DOCUMENT_CONTENT_CHANGED
|
||||
- Variable: VARIABLE_SET, VARIABLE_DELETED
|
||||
- Reference: REFERENCE_ADDED, REFERENCE_CLEARED
|
||||
- Rendering: RENDER_STARTED, RENDER_COMPLETED, RENDER_FAILED
|
||||
- Sync: SYNC_STARTED, SYNC_COMPLETED, SYNC_CONFLICT
|
||||
- Cache: CACHE_INVALIDATED
|
||||
"""
|
||||
|
||||
# Events will be implemented in Phase 2
|
||||
__all__ = []
|
||||
from .models import (
|
||||
SpaceEvent,
|
||||
SpaceEventType,
|
||||
space_created_event,
|
||||
space_updated_event,
|
||||
space_deleted_event,
|
||||
document_added_event,
|
||||
document_updated_event,
|
||||
document_removed_event,
|
||||
document_content_changed_event,
|
||||
cache_invalidated_event,
|
||||
)
|
||||
from .bus import (
|
||||
EventBus,
|
||||
HandlerRegistration,
|
||||
get_event_bus,
|
||||
reset_event_bus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Event models
|
||||
"SpaceEvent",
|
||||
"SpaceEventType",
|
||||
# Event factories
|
||||
"space_created_event",
|
||||
"space_updated_event",
|
||||
"space_deleted_event",
|
||||
"document_added_event",
|
||||
"document_updated_event",
|
||||
"document_removed_event",
|
||||
"document_content_changed_event",
|
||||
"cache_invalidated_event",
|
||||
# Event bus
|
||||
"EventBus",
|
||||
"HandlerRegistration",
|
||||
"get_event_bus",
|
||||
"reset_event_bus",
|
||||
]
|
||||
|
||||
402
markitect/spaces/events/bus.py
Normal file
402
markitect/spaces/events/bus.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
EventBus for Information Spaces.
|
||||
|
||||
This module provides an in-process publish/subscribe system
|
||||
for space events. It supports both synchronous and asynchronous
|
||||
event handlers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Callable, Dict, List, Optional, Set, Union, Any
|
||||
from dataclasses import dataclass, field
|
||||
import weakref
|
||||
|
||||
from .models import SpaceEvent, SpaceEventType
|
||||
|
||||
|
||||
# Type aliases for handlers
|
||||
SyncHandler = Callable[[SpaceEvent], None]
|
||||
AsyncHandler = Callable[[SpaceEvent], Any] # Coroutine
|
||||
Handler = Union[SyncHandler, AsyncHandler]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandlerRegistration:
|
||||
"""
|
||||
Registration info for an event handler.
|
||||
|
||||
Attributes:
|
||||
handler: The handler function
|
||||
is_async: Whether the handler is async
|
||||
priority: Handler priority (lower = earlier execution)
|
||||
handler_id: Unique ID for this registration
|
||||
"""
|
||||
|
||||
handler: Handler
|
||||
is_async: bool = False
|
||||
priority: int = 100
|
||||
handler_id: str = field(default_factory=lambda: str(id(None)))
|
||||
|
||||
def __post_init__(self):
|
||||
self.handler_id = str(id(self.handler))
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""
|
||||
In-process event bus for space events.
|
||||
|
||||
Provides publish/subscribe functionality for space operations.
|
||||
Handlers can be registered for specific event types or for all events.
|
||||
|
||||
Usage:
|
||||
bus = EventBus()
|
||||
|
||||
# Register a handler
|
||||
def on_space_created(event: SpaceEvent):
|
||||
print(f"Space created: {event.payload['name']}")
|
||||
|
||||
bus.subscribe(SpaceEventType.SPACE_CREATED, on_space_created)
|
||||
|
||||
# Emit an event
|
||||
bus.emit(SpaceEvent(
|
||||
event_type=SpaceEventType.SPACE_CREATED,
|
||||
space_id="space-123",
|
||||
payload={"name": "my-docs"}
|
||||
))
|
||||
|
||||
# Unsubscribe
|
||||
bus.unsubscribe(SpaceEventType.SPACE_CREATED, on_space_created)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the event bus."""
|
||||
# Handlers indexed by event type
|
||||
self._handlers: Dict[SpaceEventType, List[HandlerRegistration]] = defaultdict(
|
||||
list
|
||||
)
|
||||
# Handlers that receive all events
|
||||
self._global_handlers: List[HandlerRegistration] = []
|
||||
# Track all handler IDs for quick lookup
|
||||
self._handler_ids: Set[str] = set()
|
||||
# Event history (optional, for debugging)
|
||||
self._history: List[SpaceEvent] = []
|
||||
self._history_enabled: bool = False
|
||||
self._max_history: int = 1000
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
event_type: Optional[SpaceEventType],
|
||||
handler: Handler,
|
||||
priority: int = 100,
|
||||
) -> str:
|
||||
"""
|
||||
Subscribe a handler to events.
|
||||
|
||||
Args:
|
||||
event_type: The event type to subscribe to, or None for all events
|
||||
handler: The handler function (sync or async)
|
||||
priority: Handler priority (lower = earlier execution)
|
||||
|
||||
Returns:
|
||||
Handler ID that can be used to unsubscribe
|
||||
"""
|
||||
is_async = asyncio.iscoroutinefunction(handler)
|
||||
registration = HandlerRegistration(
|
||||
handler=handler,
|
||||
is_async=is_async,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
if event_type is None:
|
||||
self._global_handlers.append(registration)
|
||||
self._global_handlers.sort(key=lambda r: r.priority)
|
||||
else:
|
||||
self._handlers[event_type].append(registration)
|
||||
self._handlers[event_type].sort(key=lambda r: r.priority)
|
||||
|
||||
self._handler_ids.add(registration.handler_id)
|
||||
return registration.handler_id
|
||||
|
||||
def subscribe_all(self, handler: Handler, priority: int = 100) -> str:
|
||||
"""
|
||||
Subscribe a handler to all events.
|
||||
|
||||
Args:
|
||||
handler: The handler function
|
||||
priority: Handler priority
|
||||
|
||||
Returns:
|
||||
Handler ID
|
||||
"""
|
||||
return self.subscribe(None, handler, priority)
|
||||
|
||||
def unsubscribe(
|
||||
self,
|
||||
event_type: Optional[SpaceEventType],
|
||||
handler: Handler,
|
||||
) -> bool:
|
||||
"""
|
||||
Unsubscribe a handler from events.
|
||||
|
||||
Args:
|
||||
event_type: The event type, or None for global handlers
|
||||
handler: The handler to remove
|
||||
|
||||
Returns:
|
||||
True if handler was found and removed
|
||||
"""
|
||||
handler_id = str(id(handler))
|
||||
|
||||
if event_type is None:
|
||||
for i, reg in enumerate(self._global_handlers):
|
||||
if reg.handler_id == handler_id:
|
||||
self._global_handlers.pop(i)
|
||||
self._handler_ids.discard(handler_id)
|
||||
return True
|
||||
else:
|
||||
handlers = self._handlers.get(event_type, [])
|
||||
for i, reg in enumerate(handlers):
|
||||
if reg.handler_id == handler_id:
|
||||
handlers.pop(i)
|
||||
self._handler_ids.discard(handler_id)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def unsubscribe_by_id(self, handler_id: str) -> bool:
|
||||
"""
|
||||
Unsubscribe a handler by its ID.
|
||||
|
||||
Args:
|
||||
handler_id: The handler ID returned by subscribe
|
||||
|
||||
Returns:
|
||||
True if handler was found and removed
|
||||
"""
|
||||
# Check global handlers
|
||||
for i, reg in enumerate(self._global_handlers):
|
||||
if reg.handler_id == handler_id:
|
||||
self._global_handlers.pop(i)
|
||||
self._handler_ids.discard(handler_id)
|
||||
return True
|
||||
|
||||
# Check type-specific handlers
|
||||
for handlers in self._handlers.values():
|
||||
for i, reg in enumerate(handlers):
|
||||
if reg.handler_id == handler_id:
|
||||
handlers.pop(i)
|
||||
self._handler_ids.discard(handler_id)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def emit(self, event: SpaceEvent) -> List[Exception]:
|
||||
"""
|
||||
Emit an event synchronously.
|
||||
|
||||
Calls all registered handlers for the event type.
|
||||
Async handlers are run in a new event loop if needed.
|
||||
|
||||
Args:
|
||||
event: The event to emit
|
||||
|
||||
Returns:
|
||||
List of exceptions raised by handlers (empty if all succeeded)
|
||||
"""
|
||||
if self._history_enabled:
|
||||
self._record_history(event)
|
||||
|
||||
exceptions: List[Exception] = []
|
||||
|
||||
# Collect all handlers to call
|
||||
handlers = list(self._global_handlers)
|
||||
handlers.extend(self._handlers.get(event.event_type, []))
|
||||
handlers.sort(key=lambda r: r.priority)
|
||||
|
||||
for registration in handlers:
|
||||
try:
|
||||
if registration.is_async:
|
||||
# Run async handler
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# Create a task but don't await it here
|
||||
asyncio.create_task(registration.handler(event))
|
||||
else:
|
||||
asyncio.run(registration.handler(event))
|
||||
else:
|
||||
registration.handler(event)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Handler {registration.handler_id} raised exception for event {event.event_type}"
|
||||
)
|
||||
exceptions.append(e)
|
||||
|
||||
return exceptions
|
||||
|
||||
async def emit_async(self, event: SpaceEvent) -> List[Exception]:
|
||||
"""
|
||||
Emit an event asynchronously.
|
||||
|
||||
Args:
|
||||
event: The event to emit
|
||||
|
||||
Returns:
|
||||
List of exceptions raised by handlers
|
||||
"""
|
||||
if self._history_enabled:
|
||||
self._record_history(event)
|
||||
|
||||
exceptions: List[Exception] = []
|
||||
|
||||
# Collect all handlers to call
|
||||
handlers = list(self._global_handlers)
|
||||
handlers.extend(self._handlers.get(event.event_type, []))
|
||||
handlers.sort(key=lambda r: r.priority)
|
||||
|
||||
for registration in handlers:
|
||||
try:
|
||||
if registration.is_async:
|
||||
await registration.handler(event)
|
||||
else:
|
||||
registration.handler(event)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Handler {registration.handler_id} raised exception for event {event.event_type}"
|
||||
)
|
||||
exceptions.append(e)
|
||||
|
||||
return exceptions
|
||||
|
||||
def has_handlers(self, event_type: SpaceEventType) -> bool:
|
||||
"""
|
||||
Check if there are any handlers for an event type.
|
||||
|
||||
Args:
|
||||
event_type: The event type to check
|
||||
|
||||
Returns:
|
||||
True if there are handlers (including global handlers)
|
||||
"""
|
||||
return bool(self._global_handlers) or bool(
|
||||
self._handlers.get(event_type, [])
|
||||
)
|
||||
|
||||
def handler_count(self, event_type: Optional[SpaceEventType] = None) -> int:
|
||||
"""
|
||||
Get the number of handlers registered.
|
||||
|
||||
Args:
|
||||
event_type: Specific event type, or None for total count
|
||||
|
||||
Returns:
|
||||
Number of handlers
|
||||
"""
|
||||
if event_type is None:
|
||||
count = len(self._global_handlers)
|
||||
for handlers in self._handlers.values():
|
||||
count += len(handlers)
|
||||
return count
|
||||
else:
|
||||
return len(self._handlers.get(event_type, [])) + len(
|
||||
self._global_handlers
|
||||
)
|
||||
|
||||
def clear(self, event_type: Optional[SpaceEventType] = None) -> int:
|
||||
"""
|
||||
Clear handlers.
|
||||
|
||||
Args:
|
||||
event_type: Specific event type, or None to clear all
|
||||
|
||||
Returns:
|
||||
Number of handlers removed
|
||||
"""
|
||||
if event_type is None:
|
||||
count = self.handler_count()
|
||||
self._handlers.clear()
|
||||
self._global_handlers.clear()
|
||||
self._handler_ids.clear()
|
||||
return count
|
||||
else:
|
||||
handlers = self._handlers.pop(event_type, [])
|
||||
for reg in handlers:
|
||||
self._handler_ids.discard(reg.handler_id)
|
||||
return len(handlers)
|
||||
|
||||
# History management
|
||||
|
||||
def enable_history(self, max_events: int = 1000) -> None:
|
||||
"""Enable event history recording."""
|
||||
self._history_enabled = True
|
||||
self._max_history = max_events
|
||||
|
||||
def disable_history(self) -> None:
|
||||
"""Disable event history recording."""
|
||||
self._history_enabled = False
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear the event history."""
|
||||
self._history.clear()
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
event_type: Optional[SpaceEventType] = None,
|
||||
space_id: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[SpaceEvent]:
|
||||
"""
|
||||
Get event history.
|
||||
|
||||
Args:
|
||||
event_type: Filter by event type
|
||||
space_id: Filter by space ID
|
||||
limit: Maximum number of events to return
|
||||
|
||||
Returns:
|
||||
List of events (newest first)
|
||||
"""
|
||||
events = list(reversed(self._history))
|
||||
|
||||
if event_type is not None:
|
||||
events = [e for e in events if e.event_type == event_type]
|
||||
|
||||
if space_id is not None:
|
||||
events = [e for e in events if e.space_id == space_id]
|
||||
|
||||
if limit is not None:
|
||||
events = events[:limit]
|
||||
|
||||
return events
|
||||
|
||||
def _record_history(self, event: SpaceEvent) -> None:
|
||||
"""Record an event in history."""
|
||||
self._history.append(event)
|
||||
if len(self._history) > self._max_history:
|
||||
self._history = self._history[-self._max_history :]
|
||||
|
||||
|
||||
# Global event bus instance (optional singleton pattern)
|
||||
_default_bus: Optional[EventBus] = None
|
||||
|
||||
|
||||
def get_event_bus() -> EventBus:
|
||||
"""Get the default global event bus instance."""
|
||||
global _default_bus
|
||||
if _default_bus is None:
|
||||
_default_bus = EventBus()
|
||||
return _default_bus
|
||||
|
||||
|
||||
def reset_event_bus() -> None:
|
||||
"""Reset the default global event bus (useful for testing)."""
|
||||
global _default_bus
|
||||
_default_bus = None
|
||||
239
markitect/spaces/events/models.py
Normal file
239
markitect/spaces/events/models.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Event models for Information Spaces.
|
||||
|
||||
This module defines the event types and data structures used
|
||||
by the event system for space operations.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
import uuid
|
||||
|
||||
|
||||
class SpaceEventType(Enum):
|
||||
"""
|
||||
Types of events that can occur in the space system.
|
||||
|
||||
Events are organized by category:
|
||||
- Space lifecycle: creation, updates, deletion, status changes
|
||||
- Document operations: add, update, remove, move
|
||||
- Variable operations: set, delete
|
||||
- Rendering: start, complete, fail
|
||||
- Sync: start, complete, conflict
|
||||
"""
|
||||
|
||||
# Space lifecycle events
|
||||
SPACE_CREATED = "space.created"
|
||||
SPACE_UPDATED = "space.updated"
|
||||
SPACE_DELETED = "space.deleted"
|
||||
SPACE_ACTIVATED = "space.activated"
|
||||
SPACE_ARCHIVED = "space.archived"
|
||||
|
||||
# Document events
|
||||
DOCUMENT_ADDED = "document.added"
|
||||
DOCUMENT_UPDATED = "document.updated"
|
||||
DOCUMENT_REMOVED = "document.removed"
|
||||
DOCUMENT_MOVED = "document.moved"
|
||||
DOCUMENT_CONTENT_CHANGED = "document.content_changed"
|
||||
|
||||
# Variable events
|
||||
VARIABLE_SET = "variable.set"
|
||||
VARIABLE_DELETED = "variable.deleted"
|
||||
|
||||
# Reference events
|
||||
REFERENCE_ADDED = "reference.added"
|
||||
REFERENCE_CLEARED = "reference.cleared"
|
||||
|
||||
# Rendering events
|
||||
RENDER_STARTED = "render.started"
|
||||
RENDER_COMPLETED = "render.completed"
|
||||
RENDER_FAILED = "render.failed"
|
||||
|
||||
# Sync events
|
||||
SYNC_STARTED = "sync.started"
|
||||
SYNC_COMPLETED = "sync.completed"
|
||||
SYNC_CONFLICT = "sync.conflict"
|
||||
|
||||
# Cache events
|
||||
CACHE_INVALIDATED = "cache.invalidated"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpaceEvent:
|
||||
"""
|
||||
Represents an event in the space system.
|
||||
|
||||
Events are immutable records of operations that have occurred.
|
||||
They carry enough information for handlers to react appropriately.
|
||||
|
||||
Attributes:
|
||||
event_type: The type of event
|
||||
space_id: The ID of the affected space
|
||||
payload: Event-specific data
|
||||
event_id: Unique identifier for this event
|
||||
timestamp: When the event occurred
|
||||
source: Optional identifier of the event source
|
||||
correlation_id: Optional ID to correlate related events
|
||||
"""
|
||||
|
||||
event_type: SpaceEventType
|
||||
space_id: str
|
||||
payload: Dict[str, Any] = field(default_factory=dict)
|
||||
event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
source: Optional[str] = None
|
||||
correlation_id: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert the event to a dictionary for serialization."""
|
||||
return {
|
||||
"event_id": self.event_id,
|
||||
"event_type": self.event_type.value,
|
||||
"space_id": self.space_id,
|
||||
"payload": self.payload,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"source": self.source,
|
||||
"correlation_id": self.correlation_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SpaceEvent":
|
||||
"""Create an event from a dictionary."""
|
||||
return cls(
|
||||
event_id=data.get("event_id", str(uuid.uuid4())),
|
||||
event_type=SpaceEventType(data["event_type"]),
|
||||
space_id=data["space_id"],
|
||||
payload=data.get("payload", {}),
|
||||
timestamp=datetime.fromisoformat(data["timestamp"])
|
||||
if data.get("timestamp")
|
||||
else datetime.now(),
|
||||
source=data.get("source"),
|
||||
correlation_id=data.get("correlation_id"),
|
||||
)
|
||||
|
||||
|
||||
# Convenience factory functions for common events
|
||||
|
||||
|
||||
def space_created_event(
|
||||
space_id: str,
|
||||
name: str,
|
||||
source: Optional[str] = None,
|
||||
) -> SpaceEvent:
|
||||
"""Create a SPACE_CREATED event."""
|
||||
return SpaceEvent(
|
||||
event_type=SpaceEventType.SPACE_CREATED,
|
||||
space_id=space_id,
|
||||
payload={"name": name},
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def space_updated_event(
|
||||
space_id: str,
|
||||
changes: Dict[str, Any],
|
||||
source: Optional[str] = None,
|
||||
) -> SpaceEvent:
|
||||
"""Create a SPACE_UPDATED event."""
|
||||
return SpaceEvent(
|
||||
event_type=SpaceEventType.SPACE_UPDATED,
|
||||
space_id=space_id,
|
||||
payload={"changes": changes},
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def space_deleted_event(
|
||||
space_id: str,
|
||||
name: str,
|
||||
source: Optional[str] = None,
|
||||
) -> SpaceEvent:
|
||||
"""Create a SPACE_DELETED event."""
|
||||
return SpaceEvent(
|
||||
event_type=SpaceEventType.SPACE_DELETED,
|
||||
space_id=space_id,
|
||||
payload={"name": name},
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def document_added_event(
|
||||
space_id: str,
|
||||
document_id: str,
|
||||
space_path: str,
|
||||
source: Optional[str] = None,
|
||||
) -> SpaceEvent:
|
||||
"""Create a DOCUMENT_ADDED event."""
|
||||
return SpaceEvent(
|
||||
event_type=SpaceEventType.DOCUMENT_ADDED,
|
||||
space_id=space_id,
|
||||
payload={"document_id": document_id, "space_path": space_path},
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def document_updated_event(
|
||||
space_id: str,
|
||||
document_id: str,
|
||||
changes: Dict[str, Any],
|
||||
source: Optional[str] = None,
|
||||
) -> SpaceEvent:
|
||||
"""Create a DOCUMENT_UPDATED event."""
|
||||
return SpaceEvent(
|
||||
event_type=SpaceEventType.DOCUMENT_UPDATED,
|
||||
space_id=space_id,
|
||||
payload={"document_id": document_id, "changes": changes},
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def document_removed_event(
|
||||
space_id: str,
|
||||
document_id: str,
|
||||
space_path: str,
|
||||
source: Optional[str] = None,
|
||||
) -> SpaceEvent:
|
||||
"""Create a DOCUMENT_REMOVED event."""
|
||||
return SpaceEvent(
|
||||
event_type=SpaceEventType.DOCUMENT_REMOVED,
|
||||
space_id=space_id,
|
||||
payload={"document_id": document_id, "space_path": space_path},
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def document_content_changed_event(
|
||||
space_id: str,
|
||||
document_id: str,
|
||||
old_hash: Optional[str],
|
||||
new_hash: str,
|
||||
source: Optional[str] = None,
|
||||
) -> SpaceEvent:
|
||||
"""Create a DOCUMENT_CONTENT_CHANGED event."""
|
||||
return SpaceEvent(
|
||||
event_type=SpaceEventType.DOCUMENT_CONTENT_CHANGED,
|
||||
space_id=space_id,
|
||||
payload={
|
||||
"document_id": document_id,
|
||||
"old_hash": old_hash,
|
||||
"new_hash": new_hash,
|
||||
},
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def cache_invalidated_event(
|
||||
space_id: str,
|
||||
document_ids: list,
|
||||
reason: str,
|
||||
source: Optional[str] = None,
|
||||
) -> SpaceEvent:
|
||||
"""Create a CACHE_INVALIDATED event."""
|
||||
return SpaceEvent(
|
||||
event_type=SpaceEventType.CACHE_INVALIDATED,
|
||||
space_id=space_id,
|
||||
payload={"document_ids": document_ids, "reason": reason},
|
||||
source=source,
|
||||
)
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user