Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
229 lines
7.9 KiB
Python
229 lines
7.9 KiB
Python
"""Canonical repository identity resolution across current and prior slugs.
|
|
|
|
The slug registry is the lookup boundary. Historical records deliberately keep
|
|
the slug they recorded; callers use ``slug_values`` when they need an identity-
|
|
wide read and ``canonical_slug`` when they create a new reference.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import noload
|
|
|
|
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
|
|
from api.models.managed_repo import ManagedRepo
|
|
from api.models.repository_rename import RepositorySlug
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RepositorySlugResolution:
|
|
repo: ManagedRepo
|
|
requested_slug: str
|
|
canonical_slug: str
|
|
slug_status: str
|
|
aliases: tuple[str, ...]
|
|
source_operation_id: uuid.UUID | None = None
|
|
|
|
@property
|
|
def slug_values(self) -> tuple[str, ...]:
|
|
return (self.canonical_slug, *self.aliases)
|
|
|
|
|
|
async def resolve_repository_slug(
|
|
session: AsyncSession,
|
|
slug: str,
|
|
*,
|
|
required: bool = True,
|
|
) -> RepositorySlugResolution | None:
|
|
"""Resolve a current or protected prior slug to one repository UUID."""
|
|
|
|
record = (
|
|
await session.execute(
|
|
select(RepositorySlug).where(RepositorySlug.slug == slug)
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
# Compatibility for databases upgraded before the identity backfill has run.
|
|
if record is None:
|
|
repo = (
|
|
await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
|
).scalar_one_or_none()
|
|
if repo is None:
|
|
if required:
|
|
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
|
return None
|
|
return RepositorySlugResolution(
|
|
repo=repo,
|
|
requested_slug=slug,
|
|
canonical_slug=repo.slug,
|
|
slug_status="canonical",
|
|
aliases=(),
|
|
)
|
|
|
|
repo = await session.get(ManagedRepo, record.repo_id)
|
|
if repo is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Repository slug registry entry '{slug}' has no repository",
|
|
)
|
|
records = list(
|
|
(
|
|
await session.execute(
|
|
select(RepositorySlug)
|
|
.where(RepositorySlug.repo_id == repo.id)
|
|
.order_by(RepositorySlug.kind.desc(), RepositorySlug.slug)
|
|
)
|
|
).scalars()
|
|
)
|
|
canonicals = [item for item in records if item.kind == "canonical"]
|
|
if len(canonicals) != 1 or canonicals[0].slug != repo.slug:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Repository '{repo.id}' has inconsistent canonical slug state",
|
|
)
|
|
return RepositorySlugResolution(
|
|
repo=repo,
|
|
requested_slug=slug,
|
|
canonical_slug=repo.slug,
|
|
slug_status=record.kind,
|
|
aliases=tuple(item.slug for item in records if item.kind == "alias"),
|
|
source_operation_id=record.source_operation_id,
|
|
)
|
|
|
|
|
|
async def repository_resolutions_for_repositories(
|
|
session: AsyncSession,
|
|
repositories: list[ManagedRepo],
|
|
) -> dict[uuid.UUID, RepositorySlugResolution]:
|
|
"""Resolve a repository collection with one slug-registry query.
|
|
|
|
Collection callers already hold canonical ``ManagedRepo`` rows. Resolving
|
|
every row through :func:`resolve_repository_slug` would issue two identity
|
|
queries per repository. Load the complete alias sets once instead while
|
|
retaining the pre-registry compatibility behavior.
|
|
"""
|
|
|
|
if not repositories:
|
|
return {}
|
|
|
|
repository_ids = [repository.id for repository in repositories]
|
|
records = list(
|
|
(
|
|
await session.execute(
|
|
select(RepositorySlug)
|
|
.options(
|
|
noload(RepositorySlug.repo),
|
|
noload(RepositorySlug.source_operation),
|
|
)
|
|
.where(RepositorySlug.repo_id.in_(repository_ids))
|
|
.order_by(RepositorySlug.repo_id, RepositorySlug.kind.desc(), RepositorySlug.slug)
|
|
)
|
|
).scalars()
|
|
)
|
|
records_by_repository: dict[uuid.UUID, list[RepositorySlug]] = {
|
|
repository_id: [] for repository_id in repository_ids
|
|
}
|
|
for record in records:
|
|
records_by_repository[record.repo_id].append(record)
|
|
|
|
resolutions: dict[uuid.UUID, RepositorySlugResolution] = {}
|
|
for repository in repositories:
|
|
repository_records = records_by_repository[repository.id]
|
|
requested_record = next(
|
|
(record for record in repository_records if record.slug == repository.slug),
|
|
None,
|
|
)
|
|
if requested_record is None:
|
|
# Compatibility for databases upgraded before the identity backfill.
|
|
resolutions[repository.id] = RepositorySlugResolution(
|
|
repo=repository,
|
|
requested_slug=repository.slug,
|
|
canonical_slug=repository.slug,
|
|
slug_status="canonical",
|
|
aliases=(),
|
|
)
|
|
continue
|
|
|
|
canonicals = [record for record in repository_records if record.kind == "canonical"]
|
|
if len(canonicals) != 1 or canonicals[0].slug != repository.slug:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Repository '{repository.id}' has inconsistent canonical slug state",
|
|
)
|
|
resolutions[repository.id] = RepositorySlugResolution(
|
|
repo=repository,
|
|
requested_slug=repository.slug,
|
|
canonical_slug=repository.slug,
|
|
slug_status=requested_record.kind,
|
|
aliases=tuple(
|
|
record.slug for record in repository_records if record.kind == "alias"
|
|
),
|
|
source_operation_id=requested_record.source_operation_id,
|
|
)
|
|
|
|
return resolutions
|
|
|
|
|
|
async def canonicalize_repository_slug(session: AsyncSession, value: str) -> str:
|
|
"""Canonicalize a value only when it is a registered repository identity."""
|
|
|
|
resolution = await resolve_repository_slug(session, value, required=False)
|
|
return resolution.canonical_slug if resolution is not None else value
|
|
|
|
|
|
async def repository_resolution_for_id(
|
|
session: AsyncSession, repo: ManagedRepo
|
|
) -> RepositorySlugResolution:
|
|
return await resolve_repository_slug(session, repo.slug) # type: ignore[return-value]
|
|
|
|
|
|
async def stale_external_references(
|
|
session: AsyncSession,
|
|
resolution: RepositorySlugResolution,
|
|
) -> list[dict[str, object]]:
|
|
"""Name external projections that still contain an historical slug.
|
|
|
|
These are handoffs to the projection owner, never implicit rewrite targets.
|
|
"""
|
|
|
|
if not resolution.aliases:
|
|
return []
|
|
aliases = list(resolution.aliases)
|
|
checks = (
|
|
(FabricGraphImport, "source_repo_slug"),
|
|
(FabricGraphNode, "source_repo_slug"),
|
|
(FabricGraphNode, "repo_slug"),
|
|
(FabricGraphEdge, "source_repo_slug"),
|
|
)
|
|
stale: list[dict[str, object]] = []
|
|
for model, field_name in checks:
|
|
field = getattr(model, field_name)
|
|
rows = (
|
|
await session.execute(
|
|
select(field, func.count()).where(field.in_(aliases)).group_by(field)
|
|
)
|
|
).all()
|
|
for value, count in rows:
|
|
stale.append(
|
|
{
|
|
"owner": "railiance-fabric",
|
|
"surface": model.__tablename__,
|
|
"field": field_name,
|
|
"value": value,
|
|
"count": count,
|
|
"status": "stale",
|
|
"handoff": "owner update or re-ingest required",
|
|
}
|
|
)
|
|
return stale
|
|
|
|
|
|
def affected_slug_predicate(field: object, slugs: tuple[str, ...]):
|
|
"""Match a JSONB slug-list against any name in one repository lineage."""
|
|
|
|
return or_(*(field.contains([slug]) for slug in slugs))
|