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,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()