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>
This commit is contained in:
2026-02-08 02:02:46 +01:00
parent 6ebcc0f60e
commit 9b12875681
45 changed files with 9818 additions and 4300 deletions

View File

@@ -0,0 +1,76 @@
"""
Information Spaces package for MarkiTect.
This package provides the Information Space abstraction, enabling:
- First-class space entities with identity, metadata, and lifecycle
- Event-driven change tracking and notifications
- Persistent transclusion context with cross-space references
- HTML rendering with caching and theme support
- Bidirectional directory synchronization
- Composable space hierarchies
Package Structure:
- models: Core domain models (InformationSpace, SpaceDocument, SpaceConfig)
- events: Event system (SpaceEvent, EventBus, handlers)
- repositories: Data access layer (ISpaceRepository, SqliteSpaceRepository)
- transclusion: Persistent transclusion context and reference tracking
- rendering: Space rendering (HTML, themes)
- sync: Directory synchronization (export, import, bidirectional)
- services: Business logic (SpaceService)
- history: Optional git-based version control
Usage:
from markitect.spaces import SpaceService, InformationSpace
service = SpaceService()
space = await service.create_space("my-docs")
await service.add_document(space, "/intro.md", content="# Intro")
await service.render(space, output_dir="./html/")
"""
# Phase 1: Foundation
from .models import (
InformationSpace,
SpaceDocument,
SpaceConfig,
SpaceMetadata,
SpaceVariable,
TransclusionReference,
SpaceStatus,
)
from .services import SpaceService
from .repositories import (
ISpaceRepository,
IDocumentAssociationRepository,
IVariableRepository,
IReferenceRepository,
SqliteSpaceRepository,
SqliteDocumentRepository,
SqliteVariableRepository,
SqliteReferenceRepository,
initialize_space_tables,
)
__all__ = [
# Models
"InformationSpace",
"SpaceDocument",
"SpaceConfig",
"SpaceMetadata",
"SpaceVariable",
"TransclusionReference",
"SpaceStatus",
# Services
"SpaceService",
# Repository Interfaces
"ISpaceRepository",
"IDocumentAssociationRepository",
"IVariableRepository",
"IReferenceRepository",
# SQLite Implementations
"SqliteSpaceRepository",
"SqliteDocumentRepository",
"SqliteVariableRepository",
"SqliteReferenceRepository",
"initialize_space_tables",
]

View File

@@ -0,0 +1,16 @@
"""
Event system for Information Spaces.
This package provides event-driven architecture for space operations:
- SpaceEvent: Event dataclass with type, payload, timestamp
- EventBus: In-process publish/subscribe for space events
- Event handlers and registration
Events emitted:
- SPACE_CREATED, SPACE_UPDATED, SPACE_DELETED
- DOCUMENT_ADDED, DOCUMENT_UPDATED, DOCUMENT_REMOVED
- RENDER_COMPLETED, SYNC_COMPLETED
"""
# Events will be implemented in Phase 2
__all__ = []

View File

@@ -0,0 +1,13 @@
"""
Git history tracking for Information Spaces (Optional Phase 8).
This package provides version control integration:
- IHistoryBackend: Abstract history backend interface
- GitHistoryBackend: Git implementation
- Event-driven commit triggers
- History query API (log, diff, branches)
- Versioned read/render operations
"""
# History tracking will be implemented in Phase 8
__all__ = []

329
markitect/spaces/models.py Normal file
View File

@@ -0,0 +1,329 @@
"""
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(),
}

View File

@@ -0,0 +1,12 @@
"""
Rendering system for Information Spaces.
This package provides space rendering capabilities:
- SpaceRenderer: Abstract renderer interface
- MarkdownToHTMLRenderer: HTML output renderer
- Theme support and customization
- Render caching with invalidation
"""
# Rendering will be implemented in Phase 4
__all__ = []

View File

@@ -0,0 +1,38 @@
"""
Repository layer for Information Spaces.
This package provides data access abstractions:
- ISpaceRepository: Abstract interface for space CRUD operations
- SqliteSpaceRepository: SQLite implementation
- IDocumentAssociationRepository: Document-space association storage
- IVariableRepository: Space variable storage
- IReferenceRepository: Transclusion reference tracking
"""
from .interfaces import (
ISpaceRepository,
IDocumentAssociationRepository,
IVariableRepository,
IReferenceRepository,
)
from .sqlite import (
SqliteSpaceRepository,
SqliteDocumentRepository,
SqliteVariableRepository,
SqliteReferenceRepository,
initialize_space_tables,
)
__all__ = [
# Interfaces
"ISpaceRepository",
"IDocumentAssociationRepository",
"IVariableRepository",
"IReferenceRepository",
# SQLite implementations
"SqliteSpaceRepository",
"SqliteDocumentRepository",
"SqliteVariableRepository",
"SqliteReferenceRepository",
"initialize_space_tables",
]

View File

@@ -0,0 +1,409 @@
"""
Repository interfaces for Information Spaces.
This module defines abstract base classes for space data access,
following the repository pattern for clean separation of concerns.
"""
from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any
from ..models import (
InformationSpace,
SpaceDocument,
SpaceVariable,
TransclusionReference,
)
class ISpaceRepository(ABC):
"""
Abstract repository interface for InformationSpace persistence.
Implementations should handle CRUD operations for spaces,
including proper transaction management and error handling.
"""
@abstractmethod
def create(self, space: InformationSpace) -> InformationSpace:
"""
Create a new space in the repository.
Args:
space: The space to create
Returns:
The created space with any generated fields populated
Raises:
ValueError: If space with same name already exists
"""
pass
@abstractmethod
def get_by_id(self, space_id: str) -> Optional[InformationSpace]:
"""
Retrieve a space by its ID.
Args:
space_id: The unique space identifier
Returns:
The space if found, None otherwise
"""
pass
@abstractmethod
def get_by_name(self, name: str) -> Optional[InformationSpace]:
"""
Retrieve a space by its unique name.
Args:
name: The space name
Returns:
The space if found, None otherwise
"""
pass
@abstractmethod
def list_all(self, include_archived: bool = False) -> List[InformationSpace]:
"""
List all spaces in the repository.
Args:
include_archived: Whether to include archived spaces
Returns:
List of all spaces
"""
pass
@abstractmethod
def update(self, space: InformationSpace) -> InformationSpace:
"""
Update an existing space.
Args:
space: The space with updated values
Returns:
The updated space
Raises:
ValueError: If space does not exist
"""
pass
@abstractmethod
def delete(self, space_id: str) -> bool:
"""
Delete a space by ID.
Args:
space_id: The space ID to delete
Returns:
True if deleted, False if not found
"""
pass
@abstractmethod
def exists(self, space_id: str) -> bool:
"""
Check if a space exists.
Args:
space_id: The space ID to check
Returns:
True if exists, False otherwise
"""
pass
@abstractmethod
def get_children(self, parent_space_id: str) -> List[InformationSpace]:
"""
Get all child spaces of a parent space.
Args:
parent_space_id: The parent space ID
Returns:
List of child spaces
"""
pass
class IDocumentAssociationRepository(ABC):
"""
Abstract repository interface for SpaceDocument associations.
Manages the relationship between documents and spaces.
"""
@abstractmethod
def add_document(self, document: SpaceDocument) -> SpaceDocument:
"""
Add a document to a space.
Args:
document: The document association to create
Returns:
The created document association
Raises:
ValueError: If document path already exists in space
"""
pass
@abstractmethod
def get_document(self, document_id: str) -> Optional[SpaceDocument]:
"""
Get a document association by ID.
Args:
document_id: The document association ID
Returns:
The document if found, None otherwise
"""
pass
@abstractmethod
def get_by_space_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 (e.g., "/intro.md")
Returns:
The document if found, None otherwise
"""
pass
@abstractmethod
def list_by_space(self, space_id: str) -> List[SpaceDocument]:
"""
List all documents in a space.
Args:
space_id: The space ID
Returns:
List of documents in the space, ordered by order_index
"""
pass
@abstractmethod
def update_document(self, document: SpaceDocument) -> SpaceDocument:
"""
Update a document association.
Args:
document: The document with updated values
Returns:
The updated document
Raises:
ValueError: If document does not exist
"""
pass
@abstractmethod
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
"""
pass
@abstractmethod
def move_document(self, document_id: str, new_space_path: str) -> SpaceDocument:
"""
Move a document to a new path within the space.
Args:
document_id: The document association ID
new_space_path: The new path within the space
Returns:
The updated document
Raises:
ValueError: If new path already exists
"""
pass
@abstractmethod
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
"""
pass
@abstractmethod
def update_content_hash(self, document_id: str, content_hash: str) -> None:
"""
Update the content hash for change detection.
Args:
document_id: The document association ID
content_hash: New content hash
"""
pass
class IVariableRepository(ABC):
"""
Abstract repository interface for SpaceVariable storage.
Manages space-level variables for transclusion context.
"""
@abstractmethod
def set_variable(self, variable: SpaceVariable) -> SpaceVariable:
"""
Set a variable value.
Args:
variable: The variable to set
Returns:
The saved variable
"""
pass
@abstractmethod
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
"""
pass
@abstractmethod
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
"""
pass
@abstractmethod
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
"""
pass
class IReferenceRepository(ABC):
"""
Abstract repository interface for TransclusionReference tracking.
Manages the dependency graph for cache invalidation.
"""
@abstractmethod
def add_reference(self, reference: TransclusionReference) -> TransclusionReference:
"""
Add a transclusion reference.
Args:
reference: The reference to add
Returns:
The saved reference
"""
pass
@abstractmethod
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 from this document
"""
pass
@abstractmethod
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 to this document
"""
pass
@abstractmethod
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 deleted
"""
pass
@abstractmethod
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
"""
pass

View File

@@ -0,0 +1,713 @@
"""
SQLite implementation of space repositories.
This module provides SQLite-backed implementations of the repository
interfaces for persistent storage of Information Spaces.
"""
import sqlite3
import json
from pathlib import Path
from typing import List, Optional
from datetime import datetime
from .interfaces import (
ISpaceRepository,
IDocumentAssociationRepository,
IVariableRepository,
IReferenceRepository,
)
from ..models import (
InformationSpace,
SpaceDocument,
SpaceVariable,
TransclusionReference,
SpaceStatus,
SpaceMetadata,
SpaceConfig,
)
# SQL Schema for space tables
SPACE_TABLES_SQL = """
-- Information Spaces table
CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
metadata JSON,
config JSON,
parent_space_id TEXT REFERENCES spaces(id),
status TEXT DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Space documents association table
CREATE TABLE IF NOT EXISTS space_documents (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
document_id TEXT NOT NULL,
space_path TEXT NOT NULL,
order_index INTEGER DEFAULT 0,
metadata JSON,
content_hash TEXT,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(space_id, space_path)
);
-- Space variables for transclusion context
CREATE TABLE IF NOT EXISTS space_variables (
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value JSON,
scope TEXT DEFAULT 'space',
PRIMARY KEY(space_id, name)
);
-- Transclusion reference tracking for cache invalidation
CREATE TABLE IF NOT EXISTS transclusion_references (
source_doc_id TEXT NOT NULL,
target_doc_id TEXT NOT NULL,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(source_doc_id, target_doc_id, space_id)
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_spaces_name ON spaces(name);
CREATE INDEX IF NOT EXISTS idx_spaces_parent ON spaces(parent_space_id);
CREATE INDEX IF NOT EXISTS idx_spaces_status ON spaces(status);
CREATE INDEX IF NOT EXISTS idx_space_documents_space ON space_documents(space_id);
CREATE INDEX IF NOT EXISTS idx_space_documents_path ON space_documents(space_id, space_path);
CREATE INDEX IF NOT EXISTS idx_transclusion_refs_source ON transclusion_references(source_doc_id, space_id);
CREATE INDEX IF NOT EXISTS idx_transclusion_refs_target ON transclusion_references(target_doc_id, space_id);
"""
def initialize_space_tables(db_path: str) -> None:
"""
Initialize the space-related database tables.
Args:
db_path: Path to the SQLite database file
"""
# Ensure directory exists
db_dir = Path(db_path).parent
if db_dir and not db_dir.exists():
db_dir.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
try:
cursor = conn.cursor()
cursor.executescript(SPACE_TABLES_SQL)
conn.commit()
finally:
conn.close()
class SqliteSpaceRepository(ISpaceRepository):
"""
SQLite implementation of the space repository.
Provides persistent storage for InformationSpace entities
using SQLite as the backend.
"""
def __init__(self, db_path: str):
"""
Initialize the repository.
Args:
db_path: Path to the SQLite database file
"""
self.db_path = db_path
initialize_space_tables(db_path)
def _get_connection(self) -> sqlite3.Connection:
"""Get a database connection with foreign keys enabled."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def _row_to_space(self, row: sqlite3.Row) -> InformationSpace:
"""Convert a database row to an InformationSpace."""
metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {}
config_dict = json.loads(row["config"]) if row["config"] else {}
return InformationSpace(
id=row["id"],
name=row["name"],
description=row["description"],
metadata=SpaceMetadata.from_dict(metadata_dict),
config=SpaceConfig.from_dict(config_dict),
parent_space_id=row["parent_space_id"],
status=SpaceStatus(row["status"]),
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(),
updated_at=datetime.fromisoformat(row["updated_at"]) if row["updated_at"] else datetime.now(),
)
def create(self, space: InformationSpace) -> InformationSpace:
"""Create a new space."""
conn = self._get_connection()
try:
cursor = conn.cursor()
# Check if name already exists
cursor.execute("SELECT id FROM spaces WHERE name = ?", (space.name,))
if cursor.fetchone():
raise ValueError(f"Space with name '{space.name}' already exists")
cursor.execute(
"""
INSERT INTO spaces (id, name, description, metadata, config, parent_space_id, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
space.id,
space.name,
space.description,
json.dumps(space.metadata.to_dict() if isinstance(space.metadata, SpaceMetadata) else space.metadata),
json.dumps(space.config.to_dict() if isinstance(space.config, SpaceConfig) else space.config),
space.parent_space_id,
space.status.value if isinstance(space.status, SpaceStatus) else space.status,
space.created_at.isoformat(),
space.updated_at.isoformat(),
),
)
conn.commit()
return space
finally:
conn.close()
def get_by_id(self, space_id: str) -> Optional[InformationSpace]:
"""Get a space by ID."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM spaces WHERE id = ?", (space_id,))
row = cursor.fetchone()
return self._row_to_space(row) if row else None
finally:
conn.close()
def get_by_name(self, name: str) -> Optional[InformationSpace]:
"""Get a space by name."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM spaces WHERE name = ?", (name,))
row = cursor.fetchone()
return self._row_to_space(row) if row else None
finally:
conn.close()
def list_all(self, include_archived: bool = False) -> List[InformationSpace]:
"""List all spaces."""
conn = self._get_connection()
try:
cursor = conn.cursor()
if include_archived:
cursor.execute("SELECT * FROM spaces WHERE status != 'deleted' ORDER BY name")
else:
cursor.execute("SELECT * FROM spaces WHERE status NOT IN ('archived', 'deleted') ORDER BY name")
return [self._row_to_space(row) for row in cursor.fetchall()]
finally:
conn.close()
def update(self, space: InformationSpace) -> InformationSpace:
"""Update a space."""
conn = self._get_connection()
try:
cursor = conn.cursor()
# Check if space exists
cursor.execute("SELECT id FROM spaces WHERE id = ?", (space.id,))
if not cursor.fetchone():
raise ValueError(f"Space with id '{space.id}' does not exist")
space.touch() # Update timestamp
cursor.execute(
"""
UPDATE spaces SET
name = ?,
description = ?,
metadata = ?,
config = ?,
parent_space_id = ?,
status = ?,
updated_at = ?
WHERE id = ?
""",
(
space.name,
space.description,
json.dumps(space.metadata.to_dict() if isinstance(space.metadata, SpaceMetadata) else space.metadata),
json.dumps(space.config.to_dict() if isinstance(space.config, SpaceConfig) else space.config),
space.parent_space_id,
space.status.value if isinstance(space.status, SpaceStatus) else space.status,
space.updated_at.isoformat(),
space.id,
),
)
conn.commit()
return space
finally:
conn.close()
def delete(self, space_id: str) -> bool:
"""Delete a space."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("DELETE FROM spaces WHERE id = ?", (space_id,))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def exists(self, space_id: str) -> bool:
"""Check if a space exists."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM spaces WHERE id = ?", (space_id,))
return cursor.fetchone() is not None
finally:
conn.close()
def get_children(self, parent_space_id: str) -> List[InformationSpace]:
"""Get child spaces."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM spaces WHERE parent_space_id = ? ORDER BY name",
(parent_space_id,),
)
return [self._row_to_space(row) for row in cursor.fetchall()]
finally:
conn.close()
class SqliteDocumentRepository(IDocumentAssociationRepository):
"""
SQLite implementation of the document association repository.
"""
def __init__(self, db_path: str):
"""Initialize the repository."""
self.db_path = db_path
initialize_space_tables(db_path)
def _get_connection(self) -> sqlite3.Connection:
"""Get a database connection with foreign keys enabled."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def _row_to_document(self, row: sqlite3.Row) -> SpaceDocument:
"""Convert a database row to a SpaceDocument."""
metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {}
return SpaceDocument(
id=row["id"],
space_id=row["space_id"],
document_id=row["document_id"],
space_path=row["space_path"],
order_index=row["order_index"],
metadata=metadata_dict,
content_hash=row["content_hash"],
added_at=datetime.fromisoformat(row["added_at"]) if row["added_at"] else datetime.now(),
)
def add_document(self, document: SpaceDocument) -> SpaceDocument:
"""Add a document to a space."""
conn = self._get_connection()
try:
cursor = conn.cursor()
# Check if path already exists in space
cursor.execute(
"SELECT id FROM space_documents WHERE space_id = ? AND space_path = ?",
(document.space_id, document.space_path),
)
if cursor.fetchone():
raise ValueError(f"Document path '{document.space_path}' already exists in space")
cursor.execute(
"""
INSERT INTO space_documents (id, space_id, document_id, space_path, order_index, metadata, content_hash, added_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
document.id,
document.space_id,
document.document_id,
document.space_path,
document.order_index,
json.dumps(document.metadata),
document.content_hash,
document.added_at.isoformat(),
),
)
conn.commit()
return document
finally:
conn.close()
def get_document(self, document_id: str) -> Optional[SpaceDocument]:
"""Get a document by ID."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM space_documents WHERE id = ?", (document_id,))
row = cursor.fetchone()
return self._row_to_document(row) if row else None
finally:
conn.close()
def get_by_space_path(self, space_id: str, space_path: str) -> Optional[SpaceDocument]:
"""Get a document by its path within a space."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM space_documents WHERE space_id = ? AND space_path = ?",
(space_id, space_path),
)
row = cursor.fetchone()
return self._row_to_document(row) if row else None
finally:
conn.close()
def list_by_space(self, space_id: str) -> List[SpaceDocument]:
"""List all documents in a space."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM space_documents WHERE space_id = ? ORDER BY order_index, space_path",
(space_id,),
)
return [self._row_to_document(row) for row in cursor.fetchall()]
finally:
conn.close()
def update_document(self, document: SpaceDocument) -> SpaceDocument:
"""Update a document."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT id FROM space_documents WHERE id = ?", (document.id,))
if not cursor.fetchone():
raise ValueError(f"Document with id '{document.id}' does not exist")
cursor.execute(
"""
UPDATE space_documents SET
document_id = ?,
space_path = ?,
order_index = ?,
metadata = ?,
content_hash = ?
WHERE id = ?
""",
(
document.document_id,
document.space_path,
document.order_index,
json.dumps(document.metadata),
document.content_hash,
document.id,
),
)
conn.commit()
return document
finally:
conn.close()
def remove_document(self, document_id: str) -> bool:
"""Remove a document from a space."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute("DELETE FROM space_documents WHERE id = ?", (document_id,))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def move_document(self, document_id: str, new_space_path: str) -> SpaceDocument:
"""Move a document to a new path."""
conn = self._get_connection()
try:
cursor = conn.cursor()
# Get current document
cursor.execute("SELECT * FROM space_documents WHERE id = ?", (document_id,))
row = cursor.fetchone()
if not row:
raise ValueError(f"Document with id '{document_id}' does not exist")
# Check if new path already exists
cursor.execute(
"SELECT id FROM space_documents WHERE space_id = ? AND space_path = ? AND id != ?",
(row["space_id"], new_space_path, document_id),
)
if cursor.fetchone():
raise ValueError(f"Document path '{new_space_path}' already exists")
cursor.execute(
"UPDATE space_documents SET space_path = ? WHERE id = ?",
(new_space_path, document_id),
)
conn.commit()
document = self._row_to_document(row)
document.space_path = new_space_path
return document
finally:
conn.close()
def reorder_documents(self, space_id: str, document_ids: List[str]) -> None:
"""Reorder documents within a space."""
conn = self._get_connection()
try:
cursor = conn.cursor()
for index, doc_id in enumerate(document_ids):
cursor.execute(
"UPDATE space_documents SET order_index = ? WHERE id = ? AND space_id = ?",
(index, doc_id, space_id),
)
conn.commit()
finally:
conn.close()
def update_content_hash(self, document_id: str, content_hash: str) -> None:
"""Update the content hash."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"UPDATE space_documents SET content_hash = ? WHERE id = ?",
(content_hash, document_id),
)
conn.commit()
finally:
conn.close()
class SqliteVariableRepository(IVariableRepository):
"""
SQLite implementation of the variable repository.
"""
def __init__(self, db_path: str):
"""Initialize the repository."""
self.db_path = db_path
initialize_space_tables(db_path)
def _get_connection(self) -> sqlite3.Connection:
"""Get a database connection with foreign keys enabled."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def set_variable(self, variable: SpaceVariable) -> SpaceVariable:
"""Set a variable value."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"""
INSERT OR REPLACE INTO space_variables (space_id, name, value, scope)
VALUES (?, ?, ?, ?)
""",
(
variable.space_id,
variable.name,
json.dumps(variable.value),
variable.scope,
),
)
conn.commit()
return variable
finally:
conn.close()
def get_variable(self, space_id: str, name: str) -> Optional[SpaceVariable]:
"""Get a variable by name."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM space_variables WHERE space_id = ? AND name = ?",
(space_id, name),
)
row = cursor.fetchone()
if not row:
return None
return SpaceVariable(
space_id=row["space_id"],
name=row["name"],
value=json.loads(row["value"]) if row["value"] else None,
scope=row["scope"],
)
finally:
conn.close()
def list_variables(self, space_id: str, scope: Optional[str] = None) -> List[SpaceVariable]:
"""List variables in a space."""
conn = self._get_connection()
try:
cursor = conn.cursor()
if scope:
cursor.execute(
"SELECT * FROM space_variables WHERE space_id = ? AND scope = ?",
(space_id, scope),
)
else:
cursor.execute(
"SELECT * FROM space_variables WHERE space_id = ?",
(space_id,),
)
return [
SpaceVariable(
space_id=row["space_id"],
name=row["name"],
value=json.loads(row["value"]) if row["value"] else None,
scope=row["scope"],
)
for row in cursor.fetchall()
]
finally:
conn.close()
def delete_variable(self, space_id: str, name: str) -> bool:
"""Delete a variable."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"DELETE FROM space_variables WHERE space_id = ? AND name = ?",
(space_id, name),
)
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
class SqliteReferenceRepository(IReferenceRepository):
"""
SQLite implementation of the reference repository.
"""
def __init__(self, db_path: str):
"""Initialize the repository."""
self.db_path = db_path
initialize_space_tables(db_path)
def _get_connection(self) -> sqlite3.Connection:
"""Get a database connection with foreign keys enabled."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def add_reference(self, reference: TransclusionReference) -> TransclusionReference:
"""Add a transclusion reference."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"""
INSERT OR REPLACE INTO transclusion_references (source_doc_id, target_doc_id, space_id, created_at)
VALUES (?, ?, ?, ?)
""",
(
reference.source_doc_id,
reference.target_doc_id,
reference.space_id,
reference.created_at.isoformat(),
),
)
conn.commit()
return reference
finally:
conn.close()
def get_references_from(self, source_doc_id: str, space_id: str) -> List[TransclusionReference]:
"""Get references from a source document."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM transclusion_references WHERE source_doc_id = ? AND space_id = ?",
(source_doc_id, space_id),
)
return [
TransclusionReference(
source_doc_id=row["source_doc_id"],
target_doc_id=row["target_doc_id"],
space_id=row["space_id"],
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(),
)
for row in cursor.fetchall()
]
finally:
conn.close()
def get_references_to(self, target_doc_id: str, space_id: str) -> List[TransclusionReference]:
"""Get references to a target document."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM transclusion_references WHERE target_doc_id = ? AND space_id = ?",
(target_doc_id, space_id),
)
return [
TransclusionReference(
source_doc_id=row["source_doc_id"],
target_doc_id=row["target_doc_id"],
space_id=row["space_id"],
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(),
)
for row in cursor.fetchall()
]
finally:
conn.close()
def clear_references_from(self, source_doc_id: str, space_id: str) -> int:
"""Clear references from a source document."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"DELETE FROM transclusion_references WHERE source_doc_id = ? AND space_id = ?",
(source_doc_id, space_id),
)
conn.commit()
return cursor.rowcount
finally:
conn.close()
def get_dependents(self, document_id: str, space_id: str) -> List[str]:
"""Get documents that depend on this document."""
conn = self._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT DISTINCT source_doc_id FROM transclusion_references WHERE target_doc_id = ? AND space_id = ?",
(document_id, space_id),
)
return [row["source_doc_id"] for row in cursor.fetchall()]
finally:
conn.close()

View File

@@ -0,0 +1,14 @@
"""
Service layer for Information Spaces.
This package provides the main orchestration service:
- SpaceService: Main API for space operations
- RenderService: Rendering orchestration (Phase 4)
- SyncService: Synchronization coordination (Phase 5)
"""
from .space_service import SpaceService
__all__ = [
"SpaceService",
]

View File

@@ -0,0 +1,659 @@
"""
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(),
}

View File

@@ -0,0 +1,13 @@
"""
Directory synchronization for Information Spaces.
This package provides filesystem integration:
- SpaceToDirectory exporter using VariantFactory
- DirectoryToSpace importer
- Bidirectional sync coordinator
- Filesystem watcher for external changes
- Conflict detection and resolution
"""
# Directory sync will be implemented in Phase 5
__all__ = []

View File

@@ -0,0 +1,12 @@
"""
Persistent transclusion context for Information Spaces.
This package extends the existing TransclusionContext with:
- Database persistence for context state
- Cross-space reference resolution
- Reference graph for dependency tracking
- Variable scope layers (space, document, request)
"""
# Transclusion extensions will be implemented in Phase 3
__all__ = []