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

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

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

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

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

714 lines
25 KiB
Python

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