"""SQLite asset registry repository.""" from __future__ import annotations import json import sqlite3 from pathlib import Path from typing import Any from kontextual_engine.core import ( Actor, AssetRepresentation, AssetVersion, AuditEvent, KnowledgeAsset, LifecycleState, MetadataRecord, RepresentationKind, ) from kontextual_engine.errors import NotFoundError, ValidationError class SQLiteAssetRegistryRepository: def __init__(self, path: str | Path) -> None: self.path = Path(path) self.path.parent.mkdir(parents=True, exist_ok=True) self._initialize() def save_actor(self, actor: Actor) -> Actor: with self._connect() as conn: conn.execute( """ insert into actors (id, actor_type, payload) values (?, ?, ?) on conflict(id) do update set actor_type=excluded.actor_type, payload=excluded.payload """, (actor.id, actor.actor_type.value, _json(actor.to_dict())), ) return actor def get_actor(self, actor_id: str) -> Actor: row = self._one("select payload from actors where id = ?", (actor_id,)) if row is None: raise NotFoundError("Actor not found", details={"actor_id": actor_id}) return Actor.from_dict(_loads(row["payload"])) def save_asset(self, asset: KnowledgeAsset) -> KnowledgeAsset: with self._connect() as conn: conn.execute( """ insert into assets (id, title, asset_type, lifecycle, payload) values (?, ?, ?, ?, ?) on conflict(id) do update set title=excluded.title, asset_type=excluded.asset_type, lifecycle=excluded.lifecycle, payload=excluded.payload """, ( asset.id, asset.title, asset.classification.asset_type, asset.lifecycle.value, _json(asset.to_dict()), ), ) return asset def get_asset(self, asset_id: str) -> KnowledgeAsset: row = self._one("select payload from assets where id = ?", (asset_id,)) if row is None: raise NotFoundError("Asset not found", details={"asset_id": asset_id}) return KnowledgeAsset.from_dict(_loads(row["payload"])) def list_assets( self, *, lifecycle: LifecycleState | None = None, asset_type: str | None = None, ) -> list[KnowledgeAsset]: clauses = [] params: list[Any] = [] if lifecycle is not None: clauses.append("lifecycle = ?") params.append(lifecycle.value) if asset_type is not None: clauses.append("asset_type = ?") params.append(asset_type) where = f" where {' and '.join(clauses)}" if clauses else "" rows = self._all(f"select payload from assets{where} order by title, id", tuple(params)) return [KnowledgeAsset.from_dict(_loads(row["payload"])) for row in rows] def save_representation(self, representation: AssetRepresentation) -> AssetRepresentation: try: with self._connect() as conn: conn.execute( """ insert into representations (id, asset_id, kind, digest, payload) values (?, ?, ?, ?, ?) on conflict(id) do update set asset_id=excluded.asset_id, kind=excluded.kind, digest=excluded.digest, payload=excluded.payload """, ( representation.representation_id, representation.asset_id, representation.kind.value, representation.digest, _json(representation.to_dict()), ), ) except sqlite3.IntegrityError as exc: raise ValidationError( "Representation references an unknown asset", details={ "asset_id": representation.asset_id, "representation_id": representation.representation_id, }, ) from exc return representation def get_representation(self, representation_id: str) -> AssetRepresentation: row = self._one("select payload from representations where id = ?", (representation_id,)) if row is None: raise NotFoundError( "Representation not found", details={"representation_id": representation_id}, ) return AssetRepresentation.from_dict(_loads(row["payload"])) def list_representations( self, *, asset_id: str | None = None, kind: RepresentationKind | None = None, ) -> list[AssetRepresentation]: clauses = [] params: list[Any] = [] if asset_id is not None: clauses.append("asset_id = ?") params.append(asset_id) if kind is not None: clauses.append("kind = ?") params.append(kind.value) where = f" where {' and '.join(clauses)}" if clauses else "" rows = self._all( f"select payload from representations{where} order by asset_id, kind, id", tuple(params), ) return [AssetRepresentation.from_dict(_loads(row["payload"])) for row in rows] def save_metadata_record(self, asset_id: str, record: MetadataRecord) -> MetadataRecord: try: with self._connect() as conn: conn.execute( """ insert into metadata_records (id, asset_id, key, payload) values (?, ?, ?, ?) on conflict(id) do update set asset_id=excluded.asset_id, key=excluded.key, payload=excluded.payload """, (record.record_id, asset_id, record.key, _json(record.to_dict())), ) except sqlite3.IntegrityError as exc: raise ValidationError( "Metadata record references an unknown asset", details={"asset_id": asset_id, "record_id": record.record_id}, ) from exc return record def list_metadata_records(self, asset_id: str) -> list[MetadataRecord]: rows = self._all( "select payload from metadata_records where asset_id = ? order by key, id", (asset_id,), ) if not rows: self.get_asset(asset_id) return [MetadataRecord.from_dict(_loads(row["payload"])) for row in rows] def save_version(self, version: AssetVersion) -> AssetVersion: try: with self._connect() as conn: conn.execute( """ insert into asset_versions (id, asset_id, sequence, change_type, payload) values (?, ?, ?, ?, ?) """, ( version.version_id, version.asset_id, version.sequence, version.change_type.value, _json(version.to_dict()), ), ) except sqlite3.IntegrityError as exc: raise ValidationError( "Version sequence already exists for asset", details={"asset_id": version.asset_id, "sequence": version.sequence}, ) from exc return version def list_versions(self, asset_id: str) -> list[AssetVersion]: rows = self._all( "select payload from asset_versions where asset_id = ? order by sequence", (asset_id,), ) if not rows: self.get_asset(asset_id) return [AssetVersion.from_dict(_loads(row["payload"])) for row in rows] def save_audit_event(self, event: AuditEvent) -> AuditEvent: with self._connect() as conn: conn.execute( """ insert into audit_events (id, target, actor_id, correlation_id, outcome, occurred_at, payload) values (?, ?, ?, ?, ?, ?, ?) on conflict(id) do update set target=excluded.target, actor_id=excluded.actor_id, correlation_id=excluded.correlation_id, outcome=excluded.outcome, occurred_at=excluded.occurred_at, payload=excluded.payload """, ( event.event_id, event.target, event.actor_id, event.correlation_id, event.outcome.value, event.occurred_at, _json(event.to_dict()), ), ) return event def list_audit_events( self, *, target: str | None = None, correlation_id: str | None = None, ) -> list[AuditEvent]: clauses = [] params: list[Any] = [] if target is not None: clauses.append("target = ?") params.append(target) if correlation_id is not None: clauses.append("correlation_id = ?") params.append(correlation_id) where = f" where {' and '.join(clauses)}" if clauses else "" rows = self._all(f"select payload from audit_events{where} order by occurred_at, id", tuple(params)) return [AuditEvent.from_dict(_loads(row["payload"])) for row in rows] def _initialize(self) -> None: with self._connect() as conn: conn.executescript( """ create table if not exists actors ( id text primary key, actor_type text not null, payload text not null ); create table if not exists assets ( id text primary key, title text not null, asset_type text not null, lifecycle text not null, payload text not null ); create table if not exists representations ( id text primary key, asset_id text not null references assets(id) on delete cascade, kind text not null, digest text not null, payload text not null ); create table if not exists metadata_records ( id text primary key, asset_id text not null references assets(id) on delete cascade, key text not null, payload text not null ); create table if not exists asset_versions ( id text primary key, asset_id text not null references assets(id) on delete cascade, sequence integer not null, change_type text not null, payload text not null, unique(asset_id, sequence) ); create table if not exists audit_events ( id text primary key, target text not null, actor_id text not null, correlation_id text not null, outcome text not null, occurred_at text not null, payload text not null, foreign key(actor_id) references actors(id) ); create index if not exists idx_assets_lifecycle on assets(lifecycle); create index if not exists idx_representations_asset on representations(asset_id); create index if not exists idx_metadata_asset on metadata_records(asset_id); create index if not exists idx_versions_asset on asset_versions(asset_id); create index if not exists idx_audit_target on audit_events(target); create index if not exists idx_audit_correlation on audit_events(correlation_id); """ ) def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(self.path) conn.row_factory = sqlite3.Row conn.execute("pragma foreign_keys = on") return conn def _one(self, query: str, params: tuple[Any, ...]) -> sqlite3.Row | None: with self._connect() as conn: return conn.execute(query, params).fetchone() def _all(self, query: str, params: tuple[Any, ...]) -> list[sqlite3.Row]: with self._connect() as conn: return list(conn.execute(query, params).fetchall()) def _json(value: dict[str, Any]) -> str: return json.dumps(value, sort_keys=True, separators=(",", ":")) def _loads(value: str) -> dict[str, Any]: return json.loads(value)