This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
@@ -0,0 +1,19 @@
from .base import NotFoundError, DuplicateError, TenantMismatchError
from .organization_repo import OrganizationRepository
from .project_repo import ProjectRepository
from .artifact_repo import ArtifactRepository
from .relationship_repo import RelationshipRepository
from .job_repo import JobRepository
from .audit_repo import AuditRepository
__all__ = [
"NotFoundError",
"DuplicateError",
"TenantMismatchError",
"OrganizationRepository",
"ProjectRepository",
"ArtifactRepository",
"RelationshipRepository",
"JobRepository",
"AuditRepository",
]
@@ -0,0 +1,148 @@
"""Artifact repository — single collection for every artifact type."""
from __future__ import annotations
from typing import Any, Optional
from uuid import UUID
from motor.motor_asyncio import AsyncIOMotorCollection
from asf_domain import (
Artifact,
ArtifactCreate,
ArtifactUpdate,
ArtifactType,
)
from .base import (
NotFoundError,
TenantMismatchError,
_now_utc,
_to_doc,
_from_doc,
generate_id,
)
class ArtifactRepository:
def __init__(self, collection: AsyncIOMotorCollection) -> None:
self._c = collection
def _q(self, tenant_id, project_id=None, extra=None) -> dict:
q: dict = {"tenant_id": tenant_id}
if project_id is not None:
q["project_id"] = project_id
if extra:
q.update(extra)
return q
# ---- CRUD ----
async def get(self, tenant_id: UUID, artifact_id: UUID) -> Optional[Artifact]:
doc = await self._c.find_one(self._q(tenant_id, extra={"_id": artifact_id}))
return _from_doc(Artifact, doc)
async def list(
self,
tenant_id: UUID,
*,
project_id: Optional[UUID] = None,
artifact_type: Optional[ArtifactType | str] = None,
status: Optional[str] = None,
search: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> list[Artifact]:
q = self._q(tenant_id, project_id)
if artifact_type is not None:
q["type"] = artifact_type if isinstance(artifact_type, str) else artifact_type.value
if status is not None:
q["status"] = status
if search:
q["$or"] = [
{"title": {"$regex": search, "$options": "i"}},
{"content": {"$regex": search, "$options": "i"}},
]
cur = (
self._c.find(q)
.sort("created_at", -1)
.skip(offset)
.limit(limit)
)
return [_from_doc(Artifact, d) for d in await cur.to_list(length=limit)]
async def count(
self,
tenant_id: UUID,
*,
project_id: Optional[UUID] = None,
artifact_type: Optional[str] = None,
) -> int:
q = self._q(tenant_id, project_id)
if artifact_type is not None:
q["type"] = artifact_type
return await self._c.count_documents(q)
async def create(
self,
tenant_id: UUID,
body: ArtifactCreate,
*,
created_by: Optional[UUID] = None,
) -> Artifact:
now = _now_utc()
a = Artifact(
id=generate_id(),
tenant_id=tenant_id,
created_at=now,
updated_at=now,
created_by=created_by,
version=1,
**body.model_dump(),
)
doc = _to_doc(a)
doc["_id"] = doc.pop("id")
await self._c.insert_one(doc)
return a
async def update(
self,
tenant_id: UUID,
artifact_id: UUID,
body: ArtifactUpdate,
) -> Artifact:
raw = await self._c.find_one({"_id": artifact_id})
if raw is None:
raise NotFoundError("Artifact", artifact_id)
if raw.get("tenant_id") != tenant_id:
raise TenantMismatchError("Artifact", artifact_id)
current: Artifact = _from_doc(Artifact, raw)
changes = body.model_dump(exclude_unset=True)
metadata_update = changes.pop("metadata", None)
final_metadata = current.metadata
if metadata_update is not None:
final_metadata = {**final_metadata, **metadata_update}
changes["metadata"] = final_metadata
if not changes:
return current
changes["version"] = current.version + 1
changes["updated_at"] = _now_utc()
await self._c.update_one({"_id": artifact_id}, {"$set": changes})
return current.model_copy(update=changes)
async def delete(self, tenant_id: UUID, artifact_id: UUID) -> bool:
raw = await self._c.find_one({"_id": artifact_id})
if raw is None:
raise NotFoundError("Artifact", artifact_id)
if raw.get("tenant_id") != tenant_id:
raise TenantMismatchError("Artifact", artifact_id)
res = await self._c.delete_one({"_id": artifact_id})
return res.deleted_count > 0
async def get_many(self, tenant_id: UUID, artifact_ids: list[UUID]) -> list[Artifact]:
if not artifact_ids:
return []
cur = self._c.find(self._q(tenant_id, extra={"_id": {"$in": artifact_ids}}))
return [_from_doc(Artifact, d) async for d in cur]
@@ -0,0 +1,56 @@
"""AuditEvent repository — append-only, never updates or deletes."""
from __future__ import annotations
from typing import Optional
from uuid import UUID
from motor.motor_asyncio import AsyncIOMotorCollection
from asf_domain import AuditEvent, AuditEventCreate
from .base import (
_now_utc,
_to_doc,
_from_doc,
generate_id,
)
class AuditRepository:
def __init__(self, collection: AsyncIOMotorCollection) -> None:
self._c = collection
async def append(
self,
tenant_id: UUID,
body: AuditEventCreate,
) -> AuditEvent:
now = _now_utc()
ae = AuditEvent(
id=generate_id(),
tenant_id=tenant_id,
created_at=now,
**body.model_dump(),
)
doc = _to_doc(ae)
doc["_id"] = doc.pop("id")
await self._c.insert_one(doc)
return ae
async def list(
self,
tenant_id: UUID,
*,
limit: int = 100,
offset: int = 0,
actor_id: Optional[UUID] = None,
resource_type: Optional[str] = None,
) -> list[AuditEvent]:
q: dict = {"tenant_id": tenant_id}
if actor_id is not None:
q["actor_id"] = actor_id
if resource_type is not None:
q["resource_type"] = resource_type
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
return [_from_doc(AuditEvent, d) async for d in cur]
@@ -0,0 +1,88 @@
"""Repository base exceptions + shared helpers."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Mapping
from uuid import UUID
from motor.motor_asyncio import AsyncIOMotorCollection
from pydantic import BaseModel
from asf_domain.base import generate_id
class ASFRepositoryError(Exception):
"""Base for all repository-layer errors."""
class NotFoundError(ASFRepositoryError):
def __init__(self, kind: str, entity_id: UUID | str) -> None:
super().__init__(f"{kind} not found: {entity_id}")
self.kind = kind
self.entity_id = entity_id
class DuplicateError(ASFRepositoryError):
def __init__(self, kind: str, key: str) -> None:
super().__init__(f"{kind} already exists: {key}")
self.kind = kind
self.key = key
class TenantMismatchError(ASFRepositoryError):
"""Raised when an operation targets an entity owned by another tenant."""
def __init__(self, kind: str, entity_id: UUID | str) -> None:
super().__init__(f"Tenant isolation violation: {kind} {entity_id}")
def _now_utc() -> datetime:
return datetime.now(tz=timezone.utc)
def _to_doc(m: BaseModel) -> dict[str, Any]:
return m.model_dump(mode="python", by_alias=False)
def _from_doc(cls, doc: Mapping[str, Any]):
"""Instantiate a Pydantic model from a Mongo document.
``_id`` → ``id`` rename handled explicitly. Motor already returns UUID
objects for UUID fields when uuidRepresentation=standard is set.
Unknown document fields are filtered out (matches ``extra = forbid``
used by most ASF domain models while still tolerating new audit-only
Mongo columns like ``updated_at`` / ``metadata``).
"""
if doc is None:
return None
d = dict(doc)
if "_id" in d and "id" not in d:
d["id"] = d.pop("_id")
allowed = set(cls.model_fields.keys())
known = {k: v for k, v in d.items() if k in allowed}
try:
return cls.model_validate(known)
except Exception:
for k in list(known.keys()):
f = cls.model_fields.get(k)
if f is None:
continue
annotation = getattr(f, "annotation", None)
# Wrap None for required fields with usable defaults — job usage, etc.
if known[k] is None and annotation is not None:
from typing import get_origin, get_args
origin = get_origin(annotation)
args = get_args(annotation)
# Default dict for JobUsage
name = ""
if hasattr(annotation, "__name__"):
name = annotation.__name__
for arg in args:
if hasattr(arg, "__name__"):
name = arg.__name__
break
if name == "JobUsage":
known[k] = {"extra": {}}
return cls.model_validate(known)
@@ -0,0 +1,127 @@
"""Job repository — persistent state of every async job."""
from __future__ import annotations
from typing import Optional
from uuid import UUID
from motor.motor_asyncio import AsyncIOMotorCollection
from asf_domain import (
Job,
JobCreate,
JobUpdate,
JobStatus,
JobPriority,
JobType,
)
from .base import (
NotFoundError,
TenantMismatchError,
_now_utc,
_to_doc,
_from_doc,
generate_id,
)
class JobRepository:
def __init__(self, collection: AsyncIOMotorCollection) -> None:
self._c = collection
def _q(self, tenant_id, project_id=None, extra=None) -> dict:
q: dict = {"tenant_id": tenant_id}
if project_id is not None:
q["project_id"] = project_id
if extra:
q.update(extra)
return q
# ---- CRUD ----
async def get(self, tenant_id: UUID, job_id: UUID) -> Optional[Job]:
doc = await self._c.find_one(self._q(tenant_id, extra={"_id": job_id}))
return _from_doc(Job, doc)
async def list(
self,
tenant_id: UUID,
*,
project_id: Optional[UUID] = None,
status: Optional[JobStatus] = None,
type: Optional[JobType] = None,
limit: int = 50,
offset: int = 0,
) -> list[Job]:
q = self._q(tenant_id, project_id)
if status is not None:
q["status"] = status.value if isinstance(status, JobStatus) else status
if type is not None:
q["type"] = type.value if isinstance(type, JobType) else type
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
return [_from_doc(Job, d) async for d in cur]
async def create(
self,
tenant_id: UUID,
body: JobCreate,
*,
requested_by: Optional[UUID] = None,
) -> Job:
now = _now_utc()
j = Job(
id=generate_id(),
tenant_id=tenant_id,
requested_by=requested_by,
status=JobStatus.QUEUED,
created_at=now,
usage={},
**body.model_dump(exclude_defaults=False),
)
doc = _to_doc(j)
doc["_id"] = doc.pop("id")
await self._c.insert_one(doc)
return j
async def update(
self, tenant_id: UUID, job_id: UUID, body: JobUpdate
) -> Job:
raw = await self._c.find_one({"_id": job_id})
if raw is None:
raise NotFoundError("Job", job_id)
if raw.get("tenant_id") != tenant_id:
raise TenantMismatchError("Job", job_id)
current: Job = _from_doc(Job, raw)
changes = body.model_dump(exclude_unset=True)
if not changes:
return current
changes["updated_at"] = _now_utc() if "updated_at" not in changes else changes["updated_at"]
await self._c.update_one({"_id": job_id}, {"$set": changes})
return current.model_copy(update=changes)
async def set_status(
self,
tenant_id: UUID,
job_id: UUID,
status: JobStatus,
*,
worker: Optional[str] = None,
error: Optional[str] = None,
) -> Job:
updates: dict = {"status": status, "updated_at": _now_utc()}
if status == JobStatus.RUNNING:
updates["started_at"] = updates["updated_at"]
if status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
updates["completed_at"] = updates["updated_at"]
if worker is not None:
updates["worker"] = worker
if error is not None:
updates["error"] = error
raw = await self._c.find_one_and_update(
{"_id": job_id, "tenant_id": tenant_id},
{"$set": updates},
return_document=True,
)
if raw is None:
raise NotFoundError("Job", job_id)
return _from_doc(Job, raw)
@@ -0,0 +1,73 @@
"""Organization repository — tenant boundary record.
Note that Organization records are NOT themselves tenant-scoped (they ARE
the tenant). Every other entity references tenant_id == organization.id.
"""
from __future__ import annotations
from typing import Any, Optional, Sequence
from uuid import UUID
from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo.errors import DuplicateKeyError
from asf_domain import (
Organization,
OrganizationCreate,
OrganizationUpdate,
)
from .base import (
NotFoundError,
DuplicateError,
_now_utc,
_to_doc,
_from_doc,
generate_id,
)
class OrganizationRepository:
def __init__(self, collection: AsyncIOMotorCollection) -> None:
self._c = collection
async def get(self, org_id: UUID) -> Optional[Organization]:
doc = await self._c.find_one({"_id": org_id})
return _from_doc(Organization, doc)
async def get_by_slug(self, slug: str) -> Optional[Organization]:
doc = await self._c.find_one({"slug": slug})
return _from_doc(Organization, doc)
async def list(self, *, limit: int = 100, offset: int = 0) -> list[Organization]:
cur = self._c.find({}).sort("created_at", -1).skip(offset).limit(limit)
return [_from_doc(Organization, d) for d in await cur.to_list(length=limit)]
async def create(self, body: OrganizationCreate, *, override_id: Optional[UUID] = None) -> Organization:
now = _now_utc()
org = Organization(
id=override_id or generate_id(),
**body.model_dump(),
created_at=now,
updated_at=now,
)
doc = _to_doc(org)
doc["_id"] = doc.pop("id")
try:
await self._c.insert_one(doc)
except DuplicateKeyError as exc:
raise DuplicateError("Organization", org.slug) from exc
return org
async def update(self, org_id: UUID, body: OrganizationUpdate) -> Organization:
current = await self.get(org_id)
if current is None:
raise NotFoundError("Organization", org_id)
changes = body.model_dump(exclude_unset=True)
if not changes:
return current
changes["updated_at"] = _now_utc()
await self._c.update_one({"_id": org_id}, {"$set": changes})
updated = current.model_copy(update=changes)
return updated
@@ -0,0 +1,88 @@
"""Project repository — always tenant-scoped."""
from __future__ import annotations
from typing import Optional
from uuid import UUID
from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo.errors import DuplicateKeyError
from asf_domain import Project, ProjectCreate, ProjectUpdate
from .base import (
NotFoundError,
DuplicateError,
TenantMismatchError,
_now_utc,
_to_doc,
_from_doc,
generate_id,
)
class ProjectRepository:
def __init__(self, collection: AsyncIOMotorCollection) -> None:
self._c = collection
# ---- internal helpers ----
def _q(self, tenant_id: UUID, extra: Optional[dict] = None) -> dict:
q: dict = {"tenant_id": tenant_id}
if extra:
q.update(extra)
return q
# ---- CRUD ----
async def get(self, tenant_id: UUID, project_id: UUID) -> Optional[Project]:
doc = await self._c.find_one(self._q(tenant_id, {"_id": project_id}))
return _from_doc(Project, doc)
async def get_by_key(self, tenant_id: UUID, key: str) -> Optional[Project]:
doc = await self._c.find_one(self._q(tenant_id, {"key": key}))
return _from_doc(Project, doc)
async def list(
self,
tenant_id: UUID,
*,
limit: int = 100,
offset: int = 0,
only_active: bool = True,
) -> list[Project]:
q = self._q(tenant_id)
if only_active:
q["is_active"] = True
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
return [_from_doc(Project, d) for d in await cur.to_list(length=limit)]
async def create(self, tenant_id: UUID, body: ProjectCreate) -> Project:
now = _now_utc()
p = Project(
id=generate_id(),
tenant_id=tenant_id,
created_at=now,
updated_at=now,
**body.model_dump(),
)
doc = _to_doc(p)
doc["_id"] = doc.pop("id")
try:
await self._c.insert_one(doc)
except DuplicateKeyError as exc:
raise DuplicateError("Project", f"{tenant_id}/{p.key}") from exc
return p
async def update(
self, tenant_id: UUID, project_id: UUID, body: ProjectUpdate
) -> Project:
current = await self._c.find_one({"_id": project_id})
if current is None:
raise NotFoundError("Project", project_id)
if current.get("tenant_id") != tenant_id:
raise TenantMismatchError("Project", project_id)
changes = body.model_dump(exclude_unset=True)
if not changes:
return _from_doc(Project, current)
changes["updated_at"] = _now_utc()
await self._c.update_one({"_id": project_id}, {"$set": changes})
return _from_doc(Project, {**current, **changes})
@@ -0,0 +1,134 @@
"""ArtifactRelationship repository — explicit directed edges.
Indexes support:
* find all outgoing edges from an artifact
* find all incoming edges to an artifact
* ensure uniqueness of a (source, target, type) triple
"""
from __future__ import annotations
from typing import Optional
from uuid import UUID
from motor.motor_asyncio import AsyncIOMotorCollection
from pymongo.errors import DuplicateKeyError
from asf_domain import ArtifactRelationship, ArtifactRelationshipCreate, RelationshipType
from .base import (
NotFoundError,
DuplicateError,
TenantMismatchError,
_now_utc,
_to_doc,
_from_doc,
generate_id,
)
class RelationshipRepository:
def __init__(
self,
collection: AsyncIOMotorCollection,
artifacts_collection: AsyncIOMotorCollection,
) -> None:
self._c = collection
self._arts = artifacts_collection
# ---- helpers ----
async def _assert_tenant_owns(
self, tenant_id: UUID, artifact_id: UUID
) -> tuple[bool, UUID | None]:
d = await self._arts.find_one(
{"_id": artifact_id, "tenant_id": tenant_id},
{"project_id": 1},
)
if d is None:
return False, None
return True, d.get("project_id")
# ---- CRUD ----
async def create(
self,
tenant_id: UUID,
body: ArtifactRelationshipCreate,
) -> ArtifactRelationship:
ok_src, src_prj = await self._assert_tenant_owns(tenant_id, body.source_artifact_id)
ok_tgt, tgt_prj = await self._assert_tenant_owns(tenant_id, body.target_artifact_id)
if not ok_src:
raise TenantMismatchError("Artifact", body.source_artifact_id)
if not ok_tgt:
raise TenantMismatchError("Artifact", body.target_artifact_id)
now = _now_utc()
r = ArtifactRelationship(
id=generate_id(),
tenant_id=tenant_id,
source_project_id=src_prj,
target_project_id=tgt_prj,
created_at=now,
updated_at=now,
**body.model_dump(),
)
doc = _to_doc(r)
doc["_id"] = doc.pop("id")
try:
await self._c.insert_one(doc)
except DuplicateKeyError as exc:
raise DuplicateError(
"ArtifactRelationship",
f"{body.source_artifact_id}-{body.relationship_type}-{body.target_artifact_id}",
) from exc
return r
async def delete(self, tenant_id: UUID, relationship_id: UUID) -> bool:
raw = await self._c.find_one({"_id": relationship_id})
if raw is None:
raise NotFoundError("ArtifactRelationship", relationship_id)
if raw.get("tenant_id") != tenant_id:
raise TenantMismatchError("ArtifactRelationship", relationship_id)
res = await self._c.delete_one({"_id": relationship_id})
return res.deleted_count > 0
# ---- traversal queries ----
async def outgoing(
self,
tenant_id: UUID,
artifact_id: UUID,
*,
relationship_type: Optional[RelationshipType] = None,
limit: int = 100,
) -> list[ArtifactRelationship]:
q: dict = {"tenant_id": tenant_id, "source_artifact_id": artifact_id}
if relationship_type is not None:
q["relationship_type"] = relationship_type
cur = self._c.find(q).sort("created_at", -1).limit(limit)
return [_from_doc(ArtifactRelationship, d) async for d in cur]
async def incoming(
self,
tenant_id: UUID,
artifact_id: UUID,
*,
relationship_type: Optional[RelationshipType] = None,
limit: int = 100,
) -> list[ArtifactRelationship]:
q: dict = {"tenant_id": tenant_id, "target_artifact_id": artifact_id}
if relationship_type is not None:
q["relationship_type"] = relationship_type
cur = self._c.find(q).sort("created_at", -1).limit(limit)
return [_from_doc(ArtifactRelationship, d) async for d in cur]
async def list_for_artifact(
self, tenant_id: UUID, artifact_id: UUID, *, limit: int = 200
) -> list[ArtifactRelationship]:
q = {
"tenant_id": tenant_id,
"$or": [
{"source_artifact_id": artifact_id},
{"target_artifact_id": artifact_id},
],
}
cur = self._c.find(q).sort("created_at", -1).limit(limit)
return [_from_doc(ArtifactRelationship, d) async for d in cur]