Implement addressable artifacts with content-based identity and change detection. Core Features: - Artifact model with SHA-256 content digests - ArtifactReference for cross-space addressing - IArtifactRepository interface for pluggable storage - SQLiteArtifactRepository implementation - ArtifactService for high-level operations - Content digest calculation utilities Database: - prompt_artifacts table with indexes - Support for artifact metadata and types - UNIQUE constraint on space_id+name Tests (41 passing): - 26 model tests (metadata, artifacts, references, digests) - 15 repository tests (CRUD, queries, constraints) Implements: - FR-1.1: Unique addressability by name and ID - FR-1.2: Content digest computation and storage - FR-1.3: Cross-space artifact references Files Created: - markitect/prompts/models.py - markitect/prompts/repositories/interfaces.py - markitect/prompts/repositories/sqlite.py - markitect/prompts/services/artifact_service.py - migrations/prompts/001_create_artifacts_table.sql - tests/unit/prompts/test_artifact_models.py - tests/unit/prompts/test_artifact_repository.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
333 lines
10 KiB
Python
333 lines
10 KiB
Python
"""
|
|
SQLite implementation of artifact repositories.
|
|
|
|
This module provides SQLite-backed implementations of the repository
|
|
interfaces for persistent storage of prompt artifacts.
|
|
"""
|
|
|
|
import sqlite3
|
|
import json
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
from datetime import datetime
|
|
|
|
from markitect.prompts.repositories.interfaces import (
|
|
IArtifactRepository,
|
|
ArtifactNotFoundError,
|
|
DuplicateArtifactError,
|
|
RepositoryError,
|
|
)
|
|
from markitect.prompts.models import Artifact, ArtifactType, ArtifactMetadata
|
|
|
|
|
|
# SQL Schema for artifact tables
|
|
ARTIFACT_TABLES_SQL = """
|
|
-- Prompt artifacts table
|
|
CREATE TABLE IF NOT EXISTS prompt_artifacts (
|
|
id TEXT PRIMARY KEY,
|
|
space_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
artifact_type TEXT NOT NULL,
|
|
content_digest TEXT NOT NULL,
|
|
content_size INTEGER DEFAULT 0,
|
|
metadata JSON,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(space_id, name)
|
|
);
|
|
|
|
-- Indexes for performance
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_space ON prompt_artifacts(space_id);
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_digest ON prompt_artifacts(content_digest);
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_type ON prompt_artifacts(artifact_type);
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_name ON prompt_artifacts(space_id, name);
|
|
"""
|
|
|
|
|
|
def initialize_artifact_tables(db_path: str) -> None:
|
|
"""
|
|
Initialize the artifact-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:
|
|
conn.executescript(ARTIFACT_TABLES_SQL)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
class SQLiteArtifactRepository(IArtifactRepository):
|
|
"""
|
|
SQLite implementation of artifact repository.
|
|
|
|
Provides persistent storage for artifacts with content digest tracking.
|
|
"""
|
|
|
|
def __init__(self, db_path: str):
|
|
"""
|
|
Initialize repository with database path.
|
|
|
|
Args:
|
|
db_path: Path to SQLite database file
|
|
"""
|
|
self.db_path = db_path
|
|
initialize_artifact_tables(db_path)
|
|
|
|
def _get_connection(self) -> sqlite3.Connection:
|
|
"""Get a database connection."""
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def create(self, artifact: Artifact) -> Artifact:
|
|
"""
|
|
Persist a new artifact.
|
|
|
|
Args:
|
|
artifact: Artifact to create
|
|
|
|
Returns:
|
|
Created artifact
|
|
|
|
Raises:
|
|
DuplicateArtifactError: If artifact with same space_id+name exists
|
|
RepositoryError: On other persistence errors
|
|
"""
|
|
conn = self._get_connection()
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO prompt_artifacts (
|
|
id, space_id, name, artifact_type, content_digest,
|
|
content_size, metadata, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
artifact.id,
|
|
artifact.space_id,
|
|
artifact.name,
|
|
artifact.artifact_type.value,
|
|
artifact.content_digest,
|
|
artifact.content_size,
|
|
json.dumps(artifact.metadata.to_dict()),
|
|
artifact.created_at.isoformat(),
|
|
artifact.updated_at.isoformat(),
|
|
),
|
|
)
|
|
conn.commit()
|
|
return artifact
|
|
except sqlite3.IntegrityError as e:
|
|
if "UNIQUE constraint" in str(e):
|
|
raise DuplicateArtifactError(
|
|
f"Artifact '{artifact.name}' already exists in space '{artifact.space_id}'"
|
|
)
|
|
raise RepositoryError(f"Database integrity error: {e}")
|
|
except Exception as e:
|
|
raise RepositoryError(f"Failed to create artifact: {e}")
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_by_id(self, artifact_id: str) -> Optional[Artifact]:
|
|
"""
|
|
Retrieve artifact by ID.
|
|
|
|
Args:
|
|
artifact_id: Artifact identifier
|
|
|
|
Returns:
|
|
Artifact if found, None otherwise
|
|
"""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.execute(
|
|
"SELECT * FROM prompt_artifacts WHERE id = ?",
|
|
(artifact_id,)
|
|
)
|
|
row = cursor.fetchone()
|
|
return self._row_to_artifact(row) if row else None
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_by_name(self, space_id: str, name: str) -> Optional[Artifact]:
|
|
"""
|
|
Retrieve artifact by space and name.
|
|
|
|
Args:
|
|
space_id: Space identifier
|
|
name: Artifact name
|
|
|
|
Returns:
|
|
Artifact if found, None otherwise
|
|
"""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.execute(
|
|
"SELECT * FROM prompt_artifacts WHERE space_id = ? AND name = ?",
|
|
(space_id, name)
|
|
)
|
|
row = cursor.fetchone()
|
|
return self._row_to_artifact(row) if row else None
|
|
finally:
|
|
conn.close()
|
|
|
|
def get_by_digest(self, content_digest: str) -> List[Artifact]:
|
|
"""
|
|
Find artifacts with matching content digest.
|
|
|
|
Args:
|
|
content_digest: SHA-256 digest to match
|
|
|
|
Returns:
|
|
List of artifacts with matching digest
|
|
"""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.execute(
|
|
"SELECT * FROM prompt_artifacts WHERE content_digest = ?",
|
|
(content_digest,)
|
|
)
|
|
return [self._row_to_artifact(row) for row in cursor.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
def list_by_space(
|
|
self,
|
|
space_id: str,
|
|
artifact_type: Optional[ArtifactType] = None,
|
|
) -> List[Artifact]:
|
|
"""
|
|
List all artifacts in a space.
|
|
|
|
Args:
|
|
space_id: Space identifier
|
|
artifact_type: Optional type filter
|
|
|
|
Returns:
|
|
List of artifacts in space
|
|
"""
|
|
conn = self._get_connection()
|
|
try:
|
|
if artifact_type:
|
|
cursor = conn.execute(
|
|
"SELECT * FROM prompt_artifacts WHERE space_id = ? AND artifact_type = ? ORDER BY name",
|
|
(space_id, artifact_type.value)
|
|
)
|
|
else:
|
|
cursor = conn.execute(
|
|
"SELECT * FROM prompt_artifacts WHERE space_id = ? ORDER BY name",
|
|
(space_id,)
|
|
)
|
|
return [self._row_to_artifact(row) for row in cursor.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
|
|
def update(self, artifact: Artifact) -> Artifact:
|
|
"""
|
|
Update an existing artifact.
|
|
|
|
Args:
|
|
artifact: Artifact with updated data
|
|
|
|
Returns:
|
|
Updated artifact
|
|
|
|
Raises:
|
|
ArtifactNotFoundError: If artifact doesn't exist
|
|
RepositoryError: On other persistence errors
|
|
"""
|
|
conn = self._get_connection()
|
|
try:
|
|
# Update timestamp
|
|
artifact.updated_at = datetime.utcnow()
|
|
|
|
cursor = conn.execute(
|
|
"""
|
|
UPDATE prompt_artifacts
|
|
SET content_digest = ?, content_size = ?, metadata = ?, updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
artifact.content_digest,
|
|
artifact.content_size,
|
|
json.dumps(artifact.metadata.to_dict()),
|
|
artifact.updated_at.isoformat(),
|
|
artifact.id,
|
|
),
|
|
)
|
|
if cursor.rowcount == 0:
|
|
raise ArtifactNotFoundError(f"Artifact with ID '{artifact.id}' not found")
|
|
conn.commit()
|
|
return artifact
|
|
except ArtifactNotFoundError:
|
|
raise
|
|
except Exception as e:
|
|
raise RepositoryError(f"Failed to update artifact: {e}")
|
|
finally:
|
|
conn.close()
|
|
|
|
def delete(self, artifact_id: str) -> bool:
|
|
"""
|
|
Delete an artifact.
|
|
|
|
Args:
|
|
artifact_id: Artifact identifier
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
"""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.execute(
|
|
"DELETE FROM prompt_artifacts WHERE id = ?",
|
|
(artifact_id,)
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
finally:
|
|
conn.close()
|
|
|
|
def exists(self, space_id: str, name: str) -> bool:
|
|
"""
|
|
Check if artifact exists.
|
|
|
|
Args:
|
|
space_id: Space identifier
|
|
name: Artifact name
|
|
|
|
Returns:
|
|
True if artifact exists
|
|
"""
|
|
conn = self._get_connection()
|
|
try:
|
|
cursor = conn.execute(
|
|
"SELECT COUNT(*) FROM prompt_artifacts WHERE space_id = ? AND name = ?",
|
|
(space_id, name)
|
|
)
|
|
count = cursor.fetchone()[0]
|
|
return count > 0
|
|
finally:
|
|
conn.close()
|
|
|
|
def _row_to_artifact(self, row: sqlite3.Row) -> Artifact:
|
|
"""Convert database row to Artifact instance."""
|
|
metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {}
|
|
return Artifact(
|
|
id=row["id"],
|
|
space_id=row["space_id"],
|
|
name=row["name"],
|
|
artifact_type=ArtifactType(row["artifact_type"]),
|
|
content_digest=row["content_digest"],
|
|
content_size=row["content_size"],
|
|
metadata=ArtifactMetadata.from_dict(metadata_dict),
|
|
created_at=datetime.fromisoformat(row["created_at"]),
|
|
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
)
|