Files
markitect-main/markitect/spaces/models.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

330 lines
11 KiB
Python

"""
Core domain models for Information Spaces.
This module provides the foundational data models for the Information Space
abstraction, including the space entity, document associations, and configuration.
"""
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Any, List, Optional
from enum import Enum
class SpaceStatus(Enum):
"""Lifecycle status of an Information Space."""
DRAFT = "draft"
ACTIVE = "active"
ARCHIVED = "archived"
DELETED = "deleted"
@dataclass
class SpaceMetadata:
"""
Extensible metadata for an Information Space.
Attributes:
tags: List of tags for categorization
author: Author identifier
custom: Dictionary for custom metadata fields
"""
tags: List[str] = field(default_factory=list)
author: Optional[str] = None
custom: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert metadata to dictionary for serialization."""
return {
"tags": self.tags,
"author": self.author,
"custom": self.custom,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SpaceMetadata":
"""Create metadata from dictionary."""
return cls(
tags=data.get("tags", []),
author=data.get("author"),
custom=data.get("custom", {}),
)
@dataclass
class SpaceConfig:
"""
Configuration settings for an Information Space.
Attributes:
default_variant: Default directory variant for export (flat/hierarchical/semantic)
enable_caching: Whether to enable render caching
theme: Theme name for HTML rendering
history_enabled: Whether git history tracking is enabled (Phase 8)
history_backend: History backend type (default: "git")
history_options: Additional history backend options
variable_scope: Default variable scope resolution strategy
"""
default_variant: str = "hierarchical"
enable_caching: bool = True
theme: Optional[str] = None
history_enabled: bool = False
history_backend: str = "git"
history_options: Dict[str, Any] = field(default_factory=dict)
variable_scope: str = "space" # space, document, request
def to_dict(self) -> Dict[str, Any]:
"""Convert config to dictionary for serialization."""
return {
"default_variant": self.default_variant,
"enable_caching": self.enable_caching,
"theme": self.theme,
"history_enabled": self.history_enabled,
"history_backend": self.history_backend,
"history_options": self.history_options,
"variable_scope": self.variable_scope,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SpaceConfig":
"""Create config from dictionary."""
return cls(
default_variant=data.get("default_variant", "hierarchical"),
enable_caching=data.get("enable_caching", True),
theme=data.get("theme"),
history_enabled=data.get("history_enabled", False),
history_backend=data.get("history_backend", "git"),
history_options=data.get("history_options", {}),
variable_scope=data.get("variable_scope", "space"),
)
@dataclass
class SpaceDocument:
"""
Represents a document's membership in an Information Space.
Attributes:
id: Unique document membership identifier
space_id: ID of the containing space
document_id: Reference to the actual document
space_path: Path within the space (e.g., "/intro.md")
order_index: Ordering within the space
metadata: Document-specific metadata
content_hash: Hash of document content for change detection
added_at: Timestamp when document was added
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
space_id: str = ""
document_id: str = ""
space_path: str = ""
order_index: int = 0
metadata: Dict[str, Any] = field(default_factory=dict)
content_hash: Optional[str] = None
added_at: datetime = field(default_factory=datetime.now)
def to_dict(self) -> Dict[str, Any]:
"""Convert document association to dictionary."""
return {
"id": self.id,
"space_id": self.space_id,
"document_id": self.document_id,
"space_path": self.space_path,
"order_index": self.order_index,
"metadata": self.metadata,
"content_hash": self.content_hash,
"added_at": self.added_at.isoformat(),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SpaceDocument":
"""Create document association from dictionary."""
added_at = data.get("added_at")
if isinstance(added_at, str):
added_at = datetime.fromisoformat(added_at)
elif added_at is None:
added_at = datetime.now()
return cls(
id=data.get("id", str(uuid.uuid4())),
space_id=data.get("space_id", ""),
document_id=data.get("document_id", ""),
space_path=data.get("space_path", ""),
order_index=data.get("order_index", 0),
metadata=data.get("metadata", {}),
content_hash=data.get("content_hash"),
added_at=added_at,
)
@dataclass
class InformationSpace:
"""
First-class Information Space abstraction.
An Information Space is a container for documents with transclusion
relationships, persistent context, and lifecycle management.
Attributes:
id: Unique space identifier
name: Human-readable unique name
description: Optional description
metadata: Extensible metadata
config: Space configuration
parent_space_id: Optional parent space for inheritance
status: Current lifecycle status
created_at: Creation timestamp
updated_at: Last update timestamp
Example:
space = InformationSpace(
name="api-docs",
description="API Documentation",
config=SpaceConfig(theme="technical")
)
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = ""
description: Optional[str] = None
metadata: SpaceMetadata = field(default_factory=SpaceMetadata)
config: SpaceConfig = field(default_factory=SpaceConfig)
parent_space_id: Optional[str] = None
status: SpaceStatus = SpaceStatus.DRAFT
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
def __post_init__(self):
"""Validate space after initialization."""
if not self.name:
raise ValueError("Space name is required")
def to_dict(self) -> Dict[str, Any]:
"""Convert space to dictionary for serialization."""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"metadata": self.metadata.to_dict() if isinstance(self.metadata, SpaceMetadata) else self.metadata,
"config": self.config.to_dict() if isinstance(self.config, SpaceConfig) else self.config,
"parent_space_id": self.parent_space_id,
"status": self.status.value if isinstance(self.status, SpaceStatus) else self.status,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "InformationSpace":
"""Create space from dictionary."""
created_at = data.get("created_at")
if isinstance(created_at, str):
created_at = datetime.fromisoformat(created_at)
elif created_at is None:
created_at = datetime.now()
updated_at = data.get("updated_at")
if isinstance(updated_at, str):
updated_at = datetime.fromisoformat(updated_at)
elif updated_at is None:
updated_at = datetime.now()
status = data.get("status", "draft")
if isinstance(status, str):
status = SpaceStatus(status)
metadata = data.get("metadata", {})
if isinstance(metadata, dict):
metadata = SpaceMetadata.from_dict(metadata)
config = data.get("config", {})
if isinstance(config, dict):
config = SpaceConfig.from_dict(config)
return cls(
id=data.get("id", str(uuid.uuid4())),
name=data["name"],
description=data.get("description"),
metadata=metadata,
config=config,
parent_space_id=data.get("parent_space_id"),
status=status,
created_at=created_at,
updated_at=updated_at,
)
def activate(self) -> None:
"""Activate the space."""
self.status = SpaceStatus.ACTIVE
self.updated_at = datetime.now()
def archive(self) -> None:
"""Archive the space."""
self.status = SpaceStatus.ARCHIVED
self.updated_at = datetime.now()
def touch(self) -> None:
"""Update the last modified timestamp."""
self.updated_at = datetime.now()
@dataclass
class SpaceVariable:
"""
Variable stored at space level for transclusion context.
Attributes:
space_id: ID of the containing space
name: Variable name
value: Variable value (JSON-serializable)
scope: Variable scope (space, document, request)
"""
space_id: str
name: str
value: Any
scope: str = "space"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"space_id": self.space_id,
"name": self.name,
"value": self.value,
"scope": self.scope,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SpaceVariable":
"""Create from dictionary."""
return cls(
space_id=data["space_id"],
name=data["name"],
value=data["value"],
scope=data.get("scope", "space"),
)
@dataclass
class TransclusionReference:
"""
Tracks a transclusion reference between documents for cache invalidation.
Attributes:
source_doc_id: ID of the document containing the transclusion
target_doc_id: ID of the transcluded document
space_id: ID of the space containing the reference
created_at: When the reference was created
"""
source_doc_id: str
target_doc_id: str
space_id: str
created_at: datetime = field(default_factory=datetime.now)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"source_doc_id": self.source_doc_id,
"target_doc_id": self.target_doc_id,
"space_id": self.space_id,
"created_at": self.created_at.isoformat(),
}