Files
the-custodian/state-hub/api/routers/domains.py
tegwick 0eb2ef0650 perf(api): CUST-WP-0041 — DB indexes, TTL caches, noload on list endpoints
- Migration t7o8p9q0r1s2: indexes on tasks.status, tasks(workstream_id,status),
  workstreams.status, sbom_snapshots(repo_id,snapshot_at)
- workplan-index: 30 s TTL cache + ?refresh param (4171 ms → 16 ms on hit)
- /state/summary: 15 s TTL cache, bypassed on Cache-Control: no-cache
- /topics/: noload(workstreams, decisions, progress_events) (2382 ms → 115 ms)
- /domains/: noload(topics, repos, goals) (2252 ms → 39 ms)
- /repos/: noload(goals) (2222 ms → 599 ms first / fast on repeat)
- conftest: reset TTL caches between tests to prevent bleed-through

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:12:17 +02:00

173 lines
5.7 KiB
Python

import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, select
from sqlalchemy.orm import noload
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.domain import Domain
from api.models.extension_point import ExtensionPoint
from api.models.managed_repo import ManagedRepo
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic
from api.models.workstream import Workstream
from api.schemas.domain import DomainCreate, DomainDetail, DomainRead, DomainRename, DomainUpdate, RepoStub
router = APIRouter(prefix="/domains", tags=["domains"])
@router.get("/", response_model=list[DomainRead])
async def list_domains(
response: Response,
status: str | None = Query(None, description="active | archived | all"),
session: AsyncSession = Depends(get_session),
) -> list[Domain]:
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = select(Domain).options(
noload(Domain.topics),
noload(Domain.repos),
noload(Domain.goals),
).order_by(Domain.name)
if status and status != "all":
q = q.where(Domain.status == status)
elif status is None:
q = q.where(Domain.status == "active")
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=DomainRead, status_code=status.HTTP_201_CREATED)
async def create_domain(
body: DomainCreate,
session: AsyncSession = Depends(get_session),
) -> Domain:
existing = await session.execute(select(Domain).where(Domain.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Domain slug '{body.slug}' already exists")
domain = Domain(slug=body.slug, name=body.name, description=body.description)
session.add(domain)
await session.commit()
await session.refresh(domain)
return domain
@router.get("/{slug}", response_model=DomainDetail)
async def get_domain(
slug: str,
session: AsyncSession = Depends(get_session),
) -> DomainDetail:
domain = await _get_domain_by_slug(slug, session)
# Count topics
topic_count_row = await session.execute(
select(func.count()).select_from(Topic).where(Topic.domain_id == domain.id)
)
topic_count = topic_count_row.scalar_one()
# Count active workstreams (via topics)
topic_ids_row = await session.execute(
select(Topic.id).where(Topic.domain_id == domain.id)
)
topic_ids = [r[0] for r in topic_ids_row.all()]
ws_count = 0
if topic_ids:
ws_count_row = await session.execute(
select(func.count()).select_from(Workstream)
.where(Workstream.topic_id.in_(topic_ids))
.where(Workstream.status == "active")
)
ws_count = ws_count_row.scalar_one()
# Count EPs and TDs
ep_count_row = await session.execute(
select(func.count()).select_from(ExtensionPoint)
.where(ExtensionPoint.domain_id == domain.id)
)
ep_count = ep_count_row.scalar_one()
td_count_row = await session.execute(
select(func.count()).select_from(TechnicalDebt)
.where(TechnicalDebt.domain_id == domain.id)
)
td_count = td_count_row.scalar_one()
# Repos
repos_row = await session.execute(
select(ManagedRepo).where(ManagedRepo.domain_id == domain.id)
.where(ManagedRepo.status == "active")
.order_by(ManagedRepo.name)
)
repos = list(repos_row.scalars().all())
return DomainDetail(
id=domain.id,
slug=domain.slug,
name=domain.name,
description=domain.description,
status=domain.status,
created_at=domain.created_at,
updated_at=domain.updated_at,
topic_count=topic_count,
workstream_count=ws_count,
ep_count=ep_count,
td_count=td_count,
repos=[RepoStub.model_validate(r) for r in repos],
)
@router.patch("/{slug}/rename", response_model=DomainRead)
async def rename_domain(
slug: str,
body: DomainRename,
session: AsyncSession = Depends(get_session),
) -> Domain:
domain = await _get_domain_by_slug(slug, session)
if body.new_slug != slug:
conflict = await session.execute(select(Domain).where(Domain.slug == body.new_slug))
if conflict.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Slug '{body.new_slug}' already taken")
old_slug = domain.slug
domain.slug = body.new_slug
domain.name = body.new_name
await session.commit()
await session.refresh(domain)
return domain
@router.patch("/{slug}/archive", response_model=DomainRead)
async def archive_domain(
slug: str,
session: AsyncSession = Depends(get_session),
) -> Domain:
domain = await _get_domain_by_slug(slug, session)
# Reject if any active topics exist for this domain
active_topics = await session.execute(
select(func.count()).select_from(Topic)
.where(Topic.domain_id == domain.id)
.where(Topic.status == "active")
)
if active_topics.scalar_one() > 0:
raise HTTPException(
status_code=409,
detail="Cannot archive domain with active topics. Archive or reassign topics first.",
)
domain.status = "archived"
await session.commit()
await session.refresh(domain)
return domain
async def _get_domain_by_slug(slug: str, session: AsyncSession) -> Domain:
result = await session.execute(select(Domain).where(Domain.slug == slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain