Files
markitect-main/markitect/graphql/space_resolvers.py
tegwick 7de57a389d feat(spaces): implement Phase 6 API Layer
Implements API layer for Information Spaces:
- GraphQL schema types for spaces, documents, variables
- GraphQL queries and mutations for space operations
- CLI command group with all space management commands
- Resolver functions connecting GraphQL to SpaceService
- 38 unit tests for API components

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

563 lines
16 KiB
Python

"""
GraphQL resolvers for Information Spaces.
Implements resolver functions for space queries and mutations.
"""
from typing import Optional, Dict, Any, List
from pathlib import Path
from .space_schema import (
InformationSpaceType,
SpaceDocumentType,
SpaceVariableType,
SpaceConfigType,
SpaceMetadataType,
RenderResultType,
SyncResultType,
SyncConflictType,
CreateSpacePayload,
UpdateSpacePayload,
DeleteSpacePayload,
AddDocumentPayload,
RemoveDocumentPayload,
SetVariablePayload,
RenderDocumentPayload,
SyncSpacePayload,
)
def get_space_service():
"""Get the space service instance."""
# This would be configured during app initialization
# For now, we create a new instance
from ..spaces.services.space_service import SpaceService
from ..spaces.repositories.sqlite import (
SqliteSpaceRepository,
SqliteDocumentRepository,
SqliteVariableRepository,
SqliteReferenceRepository,
)
# Use default database path
import os
db_path = Path(os.path.expanduser("~/.markitect/spaces.db"))
db_path.parent.mkdir(parents=True, exist_ok=True)
return SpaceService(
space_repo=SqliteSpaceRepository(db_path),
document_repo=SqliteDocumentRepository(db_path),
variable_repo=SqliteVariableRepository(db_path),
reference_repo=SqliteReferenceRepository(db_path),
)
def _space_to_graphql(space) -> InformationSpaceType:
"""Convert a space model to GraphQL type."""
config = None
if space.config:
config = SpaceConfigType(
default_variant=space.config.default_variant,
enable_caching=space.config.enable_caching,
theme=space.config.theme,
history_enabled=space.config.history_enabled,
variable_scope=space.config.variable_scope,
)
metadata = None
if space.metadata:
metadata = SpaceMetadataType(
tags=space.metadata.tags if hasattr(space.metadata, 'tags') else [],
author=space.metadata.author if hasattr(space.metadata, 'author') else None,
custom=space.metadata.custom if hasattr(space.metadata, 'custom') else {},
)
return InformationSpaceType(
id=space.id,
name=space.name,
description=space.description,
status=space.status.value if hasattr(space.status, 'value') else str(space.status),
config=config,
metadata=metadata,
parent_space_id=space.parent_space_id,
created_at=space.created_at,
updated_at=space.updated_at,
)
def _document_to_graphql(doc) -> SpaceDocumentType:
"""Convert a document model to GraphQL type."""
return SpaceDocumentType(
id=doc.id,
space_id=doc.space_id,
document_id=doc.document_id,
space_path=doc.space_path,
order_index=doc.order_index,
metadata=doc.metadata,
added_at=doc.added_at,
)
def _variable_to_graphql(var) -> SpaceVariableType:
"""Convert a variable model to GraphQL type."""
return SpaceVariableType(
space_id=var.space_id,
name=var.name,
value=var.value,
scope=var.scope,
)
# Query Resolvers
def resolve_space(root, info, id=None, name=None):
"""Resolve a single space by ID or name."""
service = get_space_service()
if id:
space = service.get_space(id)
elif name:
space = service.get_space_by_name(name)
else:
return None
if space:
return _space_to_graphql(space)
return None
def resolve_spaces(root, info, status=None, limit=50, offset=0):
"""Resolve list of spaces."""
service = get_space_service()
spaces = service.list_spaces(status=status, limit=limit, offset=offset)
return [_space_to_graphql(s) for s in spaces]
def resolve_space_documents(root, info, space_id):
"""Resolve documents in a space."""
service = get_space_service()
documents = service.list_documents(space_id)
return [_document_to_graphql(d) for d in documents]
def resolve_space_document(root, info, space_id, space_path):
"""Resolve a specific document in a space."""
service = get_space_service()
doc = service.get_document(space_id, space_path)
if doc:
return _document_to_graphql(doc)
return None
def resolve_space_variables(root, info, space_id, scope=None):
"""Resolve variables in a space."""
service = get_space_service()
variables = service.list_variables(space_id, scope=scope)
return [_variable_to_graphql(v) for v in variables]
def resolve_space_variable(root, info, space_id, name):
"""Resolve a specific variable."""
service = get_space_service()
var = service.get_variable(space_id, name)
if var:
return _variable_to_graphql(var)
return None
# Mutation Resolvers
def resolve_create_space(root, info, input):
"""Create a new space."""
try:
service = get_space_service()
# Build config and metadata from input
from ..spaces.models import SpaceConfig, SpaceMetadata
config = SpaceConfig()
if input.config:
if input.config.default_variant:
config.default_variant = input.config.default_variant
if input.config.enable_caching is not None:
config.enable_caching = input.config.enable_caching
if input.config.theme:
config.theme = input.config.theme
if input.config.history_enabled is not None:
config.history_enabled = input.config.history_enabled
metadata = SpaceMetadata()
if input.metadata:
if input.metadata.tags:
metadata.tags = input.metadata.tags
if input.metadata.author:
metadata.author = input.metadata.author
if input.metadata.custom:
metadata.custom = input.metadata.custom
space = service.create_space(
name=input.name,
description=input.description,
config=config,
metadata=metadata,
parent_space_id=input.parent_space_id,
)
return CreateSpacePayload(
space=_space_to_graphql(space),
success=True,
errors=[],
)
except Exception as e:
return CreateSpacePayload(
space=None,
success=False,
errors=[str(e)],
)
def resolve_update_space(root, info, id, input):
"""Update a space."""
try:
service = get_space_service()
updates = {}
if input.name:
updates["name"] = input.name
if input.description:
updates["description"] = input.description
space = service.update_space(id, **updates)
return UpdateSpacePayload(
space=_space_to_graphql(space),
success=True,
errors=[],
)
except Exception as e:
return UpdateSpacePayload(
space=None,
success=False,
errors=[str(e)],
)
def resolve_delete_space(root, info, id):
"""Delete a space."""
try:
service = get_space_service()
service.delete_space(id)
return DeleteSpacePayload(
success=True,
deleted_id=id,
errors=[],
)
except Exception as e:
return DeleteSpacePayload(
success=False,
deleted_id=None,
errors=[str(e)],
)
def resolve_activate_space(root, info, id):
"""Activate a draft space."""
try:
service = get_space_service()
space = service.activate_space(id)
return UpdateSpacePayload(
space=_space_to_graphql(space),
success=True,
errors=[],
)
except Exception as e:
return UpdateSpacePayload(
space=None,
success=False,
errors=[str(e)],
)
def resolve_archive_space(root, info, id):
"""Archive a space."""
try:
service = get_space_service()
space = service.archive_space(id)
return UpdateSpacePayload(
space=_space_to_graphql(space),
success=True,
errors=[],
)
except Exception as e:
return UpdateSpacePayload(
space=None,
success=False,
errors=[str(e)],
)
def resolve_add_document(root, info, space_id, input):
"""Add a document to a space."""
try:
service = get_space_service()
doc = service.add_document(
space_id=space_id,
document_id=input.document_id,
space_path=input.space_path,
order_index=input.order_index or 0,
metadata=input.metadata,
)
return AddDocumentPayload(
document=_document_to_graphql(doc),
success=True,
errors=[],
)
except Exception as e:
return AddDocumentPayload(
document=None,
success=False,
errors=[str(e)],
)
def resolve_remove_document(root, info, space_id, space_path):
"""Remove a document from a space."""
try:
service = get_space_service()
service.remove_document(space_id, space_path)
return RemoveDocumentPayload(
success=True,
removed_path=space_path,
errors=[],
)
except Exception as e:
return RemoveDocumentPayload(
success=False,
removed_path=None,
errors=[str(e)],
)
def resolve_set_variable(root, info, space_id, name, value, scope=None):
"""Set a variable in a space."""
try:
service = get_space_service()
var = service.set_variable(
space_id=space_id,
name=name,
value=value,
scope=scope or "space",
)
return SetVariablePayload(
variable=_variable_to_graphql(var),
success=True,
errors=[],
)
except Exception as e:
return SetVariablePayload(
variable=None,
success=False,
errors=[str(e)],
)
def resolve_delete_variable(root, info, space_id, name):
"""Delete a variable from a space."""
try:
service = get_space_service()
service.delete_variable(space_id, name)
return SetVariablePayload(
variable=None,
success=True,
errors=[],
)
except Exception as e:
return SetVariablePayload(
variable=None,
success=False,
errors=[str(e)],
)
def resolve_render_document(root, info, space_id, document_path, options=None):
"""Render a document to HTML."""
try:
from ..spaces.rendering import SpaceRenderingService, RenderConfig, ThemeConfig
service = get_space_service()
doc = service.get_document(space_id, document_path)
if not doc:
return RenderDocumentPayload(
result=None,
success=False,
errors=[f"Document not found: {document_path}"],
)
# Get render config from options
theme_name = "default"
include_toc = False
force_refresh = False
if options:
if options.theme:
theme_name = options.theme
if options.include_toc:
include_toc = options.include_toc
if options.force_refresh:
force_refresh = options.force_refresh
config = RenderConfig(
theme=ThemeConfig(name=theme_name, layers=[theme_name]),
include_toc=include_toc,
)
renderer = SpaceRenderingService()
# Would need actual content here
result = renderer.render_document(
content="# Placeholder", # Would get actual content
document_id=doc.document_id,
space_id=space_id,
force_refresh=force_refresh,
)
return RenderDocumentPayload(
result=RenderResultType(
content=result.content,
format=result.format.value,
content_hash=result.content_hash,
source_hash=result.source_hash,
document_id=result.document_id,
cached=False,
),
success=True,
errors=[],
)
except Exception as e:
return RenderDocumentPayload(
result=None,
success=False,
errors=[str(e)],
)
def resolve_sync_space(root, info, space_id, directory, options=None):
"""Sync a space with a directory."""
try:
from ..spaces.sync import (
BidirectionalSyncCoordinator,
SyncConfig,
SyncDirection,
ConflictResolution,
)
service = get_space_service()
space = service.get_space(space_id)
if not space:
return SyncSpacePayload(
result=None,
conflicts=[],
success=False,
errors=[f"Space not found: {space_id}"],
)
# Build sync config
direction = SyncDirection.BIDIRECTIONAL
conflict_res = ConflictResolution.NEWER_WINS
dry_run = False
if options:
if options.direction:
direction = SyncDirection(options.direction)
if options.conflict_resolution:
conflict_res = ConflictResolution(options.conflict_resolution)
if options.dry_run:
dry_run = options.dry_run
config = SyncConfig(
direction=direction,
conflict_resolution=conflict_res,
dry_run=dry_run,
)
coordinator = BidirectionalSyncCoordinator(config)
documents = service.list_documents(space_id)
def content_provider(doc_id):
return "" # Would get actual content
result = coordinator.sync(
space=space,
documents=documents,
content_provider=content_provider,
directory=Path(directory),
)
return SyncSpacePayload(
result=SyncResultType(
success=result.success,
created_count=result.created_count,
updated_count=result.updated_count,
deleted_count=result.deleted_count,
conflict_count=len(result.conflicts),
errors=list(result.errors.values()),
),
conflicts=[
SyncConflictType(
path=c.path,
space_hash=c.space_state.content_hash if c.space_state else None,
directory_hash=c.directory_state.content_hash if c.directory_state else None,
resolution=c.resolution.value if hasattr(c.resolution, 'value') else str(c.resolution),
winner=c.winner,
)
for c in result.conflicts
],
success=result.success,
errors=[],
)
except Exception as e:
return SyncSpacePayload(
result=None,
conflicts=[],
success=False,
errors=[str(e)],
)
def resolve_invalidate_cache(root, info, space_id, document_path=None):
"""Invalidate render cache."""
try:
from ..spaces.rendering import SpaceRenderingService
service = SpaceRenderingService()
if document_path:
service.invalidate_document(document_path, space_id)
else:
service.invalidate_space(space_id)
return True
except Exception:
return False