Files
markitect-main/markitect/spaces/services/space_service.py
tegwick 9b12875681 feat(spaces): implement Phase 0-1 of Information Space Service
Phase 0 - Project Organization:
- Create docs/PROJECT_STRUCTURE.md documenting codebase layout
- Create markitect/core/ with parser, serializer, document_manager, workspace
- Create markitect/schema/ consolidating 6 schema_*.py modules
- Create markitect/storage/ with database module
- Maintain backward compatibility via re-exports from original locations
- Add docs/roadmap/information-space-service/ with README and WORKPLAN

Phase 1 - Foundation (Weeks 1-3):
- Week 1: Core domain models (InformationSpace, SpaceDocument, SpaceConfig,
  SpaceMetadata, SpaceVariable, TransclusionReference, SpaceStatus)
- Week 2: Repository layer with interfaces (ISpaceRepository,
  IDocumentAssociationRepository, IVariableRepository, IReferenceRepository)
  and SQLite implementations with foreign key cascade deletes
- Week 3: SpaceService orchestration layer with full CRUD, document,
  variable, and reference tracking operations

Test coverage: 124 tests (25 model + 63 repository + 36 integration)

Capabilities delivered:
- CAP-001: InformationSpace entity with lifecycle management
- CAP-002: SpaceRepository CRUD with SQLite backing
- CAP-003: Document-Space associations with path-based organization
- CAP-004: Space metadata and configuration schemas
- CAP-005: Database schema with migrations

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

660 lines
19 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,
)
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,
):
"""
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
"""
self._space_repo = space_repo
self._document_repo = document_repo
self._variable_repo = variable_repo
self._reference_repo = reference_repo
# =========================================================================
# 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,
)
return self._space_repo.create(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
return self._space_repo.update(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)
return self._space_repo.delete(space_id)
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()
return self._space_repo.update(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()
return self._space_repo.update(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,
)
return self._document_repo.add_document(document)
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
"""
# Clear any references from this document first
document = self._document_repo.get_document(document_id)
if document:
self._reference_repo.clear_references_from(document_id, document.space_id)
return self._document_repo.remove_document(document_id)
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
"""
if not new_path.startswith("/"):
new_path = "/" + new_path
return self._document_repo.move_document(document_id, new_path)
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
"""
self._document_repo.update_content_hash(document_id, 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,
)
return self._variable_repo.set_variable(variable)
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
"""
return self._variable_repo.delete_variable(space_id, name)
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(),
}