339 lines
13 KiB
Python
339 lines
13 KiB
Python
|
|
"""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)
|