Remove tag system, use versions only for artifact references

Tags were mutable aliases that caused confusion alongside the immutable
version system. This removes tags entirely, keeping only PackageVersion
for artifact references.

Changes:
- Remove tags and tag_history tables (migration 012)
- Remove Tag model, TagRepository, and 6 tag API endpoints
- Update cache system to create versions instead of tags
- Update frontend to display versions instead of tags
- Remove tag-related schemas and types
- Update artifact cleanup service for version-based ref_count
This commit is contained in:
Mondo Diaz
2026-02-03 12:18:19 -06:00
parent 62c709e368
commit c4c9c20763
22 changed files with 423 additions and 2297 deletions

View File

@@ -28,7 +28,6 @@ from .models import (
Project, Project,
Package, Package,
Artifact, Artifact,
Tag,
ArtifactDependency, ArtifactDependency,
PackageVersion, PackageVersion,
) )
@@ -153,26 +152,20 @@ def parse_ensure_file(content: bytes) -> EnsureFileContent:
project = dep.get('project') project = dep.get('project')
package = dep.get('package') package = dep.get('package')
version = dep.get('version') version = dep.get('version')
tag = dep.get('tag')
if not project: if not project:
raise InvalidEnsureFileError(f"Dependency {i} missing 'project'") raise InvalidEnsureFileError(f"Dependency {i} missing 'project'")
if not package: if not package:
raise InvalidEnsureFileError(f"Dependency {i} missing 'package'") raise InvalidEnsureFileError(f"Dependency {i} missing 'package'")
if not version and not tag: if not version:
raise InvalidEnsureFileError( raise InvalidEnsureFileError(
f"Dependency {i} must have either 'version' or 'tag'" f"Dependency {i} must have 'version'"
)
if version and tag:
raise InvalidEnsureFileError(
f"Dependency {i} cannot have both 'version' and 'tag'"
) )
dependencies.append(EnsureFileDependency( dependencies.append(EnsureFileDependency(
project=project, project=project,
package=package, package=package,
version=version, version=version,
tag=tag,
)) ))
return EnsureFileContent(dependencies=dependencies) return EnsureFileContent(dependencies=dependencies)
@@ -226,7 +219,6 @@ def store_dependencies(
dependency_project=dep.project, dependency_project=dep.project,
dependency_package=dep.package, dependency_package=dep.package,
version_constraint=dep.version, version_constraint=dep.version,
tag_constraint=dep.tag,
) )
db.add(artifact_dep) db.add(artifact_dep)
created.append(artifact_dep) created.append(artifact_dep)
@@ -292,26 +284,21 @@ def get_reverse_dependencies(
if not artifact: if not artifact:
continue continue
# Find which package this artifact belongs to via tags or versions # Find which package this artifact belongs to via versions
tag = db.query(Tag).filter(Tag.artifact_id == dep.artifact_id).first() version_record = db.query(PackageVersion).filter(
if tag: PackageVersion.artifact_id == dep.artifact_id,
pkg = db.query(Package).filter(Package.id == tag.package_id).first() ).first()
if version_record:
pkg = db.query(Package).filter(Package.id == version_record.package_id).first()
if pkg: if pkg:
proj = db.query(Project).filter(Project.id == pkg.project_id).first() proj = db.query(Project).filter(Project.id == pkg.project_id).first()
if proj: if proj:
# Get version if available
version_record = db.query(PackageVersion).filter(
PackageVersion.artifact_id == dep.artifact_id,
PackageVersion.package_id == pkg.id,
).first()
dependents.append(DependentInfo( dependents.append(DependentInfo(
artifact_id=dep.artifact_id, artifact_id=dep.artifact_id,
project=proj.name, project=proj.name,
package=pkg.name, package=pkg.name,
version=version_record.version if version_record else None, version=version_record.version,
constraint_type="version" if dep.version_constraint else "tag", constraint_value=dep.version_constraint,
constraint_value=dep.version_constraint or dep.tag_constraint,
)) ))
total_pages = (total + limit - 1) // limit total_pages = (total + limit - 1) // limit
@@ -423,8 +410,7 @@ def _resolve_dependency_to_artifact(
db: Session, db: Session,
project_name: str, project_name: str,
package_name: str, package_name: str,
version: Optional[str], version: str,
tag: Optional[str],
) -> Optional[Tuple[str, str, int]]: ) -> Optional[Tuple[str, str, int]]:
""" """
Resolve a dependency constraint to an artifact ID. Resolve a dependency constraint to an artifact ID.
@@ -432,7 +418,6 @@ def _resolve_dependency_to_artifact(
Supports: Supports:
- Exact version matching (e.g., '1.2.3') - Exact version matching (e.g., '1.2.3')
- Version constraints (e.g., '>=1.9', '<2.0,>=1.5') - Version constraints (e.g., '>=1.9', '<2.0,>=1.5')
- Tag matching
- Wildcard ('*' for any version) - Wildcard ('*' for any version)
Args: Args:
@@ -440,10 +425,9 @@ def _resolve_dependency_to_artifact(
project_name: Project name project_name: Project name
package_name: Package name package_name: Package name
version: Version or version constraint version: Version or version constraint
tag: Tag constraint
Returns: Returns:
Tuple of (artifact_id, resolved_version_or_tag, size) or None if not found Tuple of (artifact_id, resolved_version, size) or None if not found
""" """
# Get project and package # Get project and package
project = db.query(Project).filter(Project.name == project_name).first() project = db.query(Project).filter(Project.name == project_name).first()
@@ -457,50 +441,24 @@ def _resolve_dependency_to_artifact(
if not package: if not package:
return None return None
if version: # Check if this is a version constraint (>=, <, etc.) or exact version
# Check if this is a version constraint (>=, <, etc.) or exact version if _is_version_constraint(version):
if _is_version_constraint(version): result = _resolve_version_constraint(db, package, version)
result = _resolve_version_constraint(db, package, version) if result:
if result: return result
return result else:
else: # Look up by exact version
# Look up by exact version pkg_version = db.query(PackageVersion).filter(
pkg_version = db.query(PackageVersion).filter( PackageVersion.package_id == package.id,
PackageVersion.package_id == package.id, PackageVersion.version == version,
PackageVersion.version == version,
).first()
if pkg_version:
artifact = db.query(Artifact).filter(
Artifact.id == pkg_version.artifact_id
).first()
if artifact:
return (artifact.id, version, artifact.size)
# Also check if there's a tag with this exact name
tag_record = db.query(Tag).filter(
Tag.package_id == package.id,
Tag.name == version,
).first() ).first()
if tag_record: if pkg_version:
artifact = db.query(Artifact).filter( artifact = db.query(Artifact).filter(
Artifact.id == tag_record.artifact_id Artifact.id == pkg_version.artifact_id
).first() ).first()
if artifact: if artifact:
return (artifact.id, version, artifact.size) return (artifact.id, version, artifact.size)
if tag:
# Look up by tag
tag_record = db.query(Tag).filter(
Tag.package_id == package.id,
Tag.name == tag,
).first()
if tag_record:
artifact = db.query(Artifact).filter(
Artifact.id == tag_record.artifact_id
).first()
if artifact:
return (artifact.id, tag, artifact.size)
return None return None
@@ -560,9 +518,9 @@ def _detect_package_cycle(
Package.name == package_name, Package.name == package_name,
).first() ).first()
if package: if package:
# Find all artifacts in this package via tags # Find all artifacts in this package via versions
tags = db.query(Tag).filter(Tag.package_id == package.id).all() versions = db.query(PackageVersion).filter(PackageVersion.package_id == package.id).all()
artifact_ids = {t.artifact_id for t in tags} artifact_ids = {v.artifact_id for v in versions}
# Get dependencies from all artifacts in this package # Get dependencies from all artifacts in this package
for artifact_id in artifact_ids: for artifact_id in artifact_ids:
@@ -605,8 +563,8 @@ def check_circular_dependencies(
db: Database session db: Database session
artifact_id: The artifact that will have these dependencies artifact_id: The artifact that will have these dependencies
new_dependencies: Dependencies to be added new_dependencies: Dependencies to be added
project_name: Project name (optional, will try to look up from tag if not provided) project_name: Project name (optional, will try to look up from version if not provided)
package_name: Package name (optional, will try to look up from tag if not provided) package_name: Package name (optional, will try to look up from version if not provided)
Returns: Returns:
Cycle path if detected, None otherwise Cycle path if detected, None otherwise
@@ -615,17 +573,19 @@ def check_circular_dependencies(
if project_name and package_name: if project_name and package_name:
current_path = f"{project_name}/{package_name}" current_path = f"{project_name}/{package_name}"
else: else:
# Try to look up from tag # Try to look up from version
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first() artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
if not artifact: if not artifact:
return None return None
# Find package for this artifact # Find package for this artifact via version
tag = db.query(Tag).filter(Tag.artifact_id == artifact_id).first() version_record = db.query(PackageVersion).filter(
if not tag: PackageVersion.artifact_id == artifact_id
).first()
if not version_record:
return None return None
package = db.query(Package).filter(Package.id == tag.package_id).first() package = db.query(Package).filter(Package.id == version_record.package_id).first()
if not package: if not package:
return None return None
@@ -682,7 +642,7 @@ def resolve_dependencies(
db: Database session db: Database session
project_name: Project name project_name: Project name
package_name: Package name package_name: Package name
ref: Tag or version reference ref: Version reference (or artifact:hash)
base_url: Base URL for download URLs base_url: Base URL for download URLs
Returns: Returns:
@@ -715,7 +675,7 @@ def resolve_dependencies(
root_version = artifact_id[:12] # Use short hash as version display root_version = artifact_id[:12] # Use short hash as version display
root_size = artifact.size root_size = artifact.size
else: else:
# Try to find artifact by tag or version # Try to find artifact by version
resolved = _resolve_dependency_to_artifact( resolved = _resolve_dependency_to_artifact(
db, project_name, package_name, ref, ref db, project_name, package_name, ref, ref
) )
@@ -820,12 +780,11 @@ def resolve_dependencies(
dep.dependency_project, dep.dependency_project,
dep.dependency_package, dep.dependency_package,
dep.version_constraint, dep.version_constraint,
dep.tag_constraint,
) )
if not resolved_dep: if not resolved_dep:
# Dependency not cached on server - track as missing but continue # Dependency not cached on server - track as missing but continue
constraint = dep.version_constraint or dep.tag_constraint constraint = dep.version_constraint
missing_dependencies.append(MissingDependency( missing_dependencies.append(MissingDependency(
project=dep.dependency_project, project=dep.dependency_project,
package=dep.dependency_package, package=dep.dependency_package,

View File

@@ -71,7 +71,6 @@ class Package(Base):
) )
project = relationship("Project", back_populates="packages") project = relationship("Project", back_populates="packages")
tags = relationship("Tag", back_populates="package", cascade="all, delete-orphan")
uploads = relationship( uploads = relationship(
"Upload", back_populates="package", cascade="all, delete-orphan" "Upload", back_populates="package", cascade="all, delete-orphan"
) )
@@ -120,7 +119,6 @@ class Artifact(Base):
ref_count = Column(Integer, default=1) ref_count = Column(Integer, default=1)
s3_key = Column(String(1024), nullable=False) s3_key = Column(String(1024), nullable=False)
tags = relationship("Tag", back_populates="artifact")
uploads = relationship("Upload", back_populates="artifact") uploads = relationship("Upload", back_populates="artifact")
versions = relationship("PackageVersion", back_populates="artifact") versions = relationship("PackageVersion", back_populates="artifact")
dependencies = relationship( dependencies = relationship(
@@ -151,65 +149,6 @@ class Artifact(Base):
) )
class Tag(Base):
__tablename__ = "tags"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
package_id = Column(
UUID(as_uuid=True),
ForeignKey("packages.id", ondelete="CASCADE"),
nullable=False,
)
name = Column(String(255), nullable=False)
artifact_id = Column(String(64), ForeignKey("artifacts.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
)
created_by = Column(String(255), nullable=False)
package = relationship("Package", back_populates="tags")
artifact = relationship("Artifact", back_populates="tags")
history = relationship(
"TagHistory", back_populates="tag", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_tags_package_id", "package_id"),
Index("idx_tags_artifact_id", "artifact_id"),
Index(
"idx_tags_package_name", "package_id", "name", unique=True
), # Composite unique index
Index(
"idx_tags_package_created_at", "package_id", "created_at"
), # For recent tags queries
)
class TagHistory(Base):
__tablename__ = "tag_history"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tag_id = Column(
UUID(as_uuid=True), ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
)
old_artifact_id = Column(String(64), ForeignKey("artifacts.id"))
new_artifact_id = Column(String(64), ForeignKey("artifacts.id"), nullable=False)
change_type = Column(String(20), nullable=False, default="update")
changed_at = Column(DateTime(timezone=True), default=datetime.utcnow)
changed_by = Column(String(255), nullable=False)
tag = relationship("Tag", back_populates="history")
__table_args__ = (
Index("idx_tag_history_tag_id", "tag_id"),
Index("idx_tag_history_changed_at", "changed_at"),
CheckConstraint(
"change_type IN ('create', 'update', 'delete')", name="check_change_type"
),
)
class PackageVersion(Base): class PackageVersion(Base):
"""Immutable version record for a package-artifact relationship. """Immutable version record for a package-artifact relationship.
@@ -249,7 +188,7 @@ class Upload(Base):
artifact_id = Column(String(64), ForeignKey("artifacts.id"), nullable=False) artifact_id = Column(String(64), ForeignKey("artifacts.id"), nullable=False)
package_id = Column(UUID(as_uuid=True), ForeignKey("packages.id"), nullable=False) package_id = Column(UUID(as_uuid=True), ForeignKey("packages.id"), nullable=False)
original_name = Column(String(1024)) original_name = Column(String(1024))
tag_name = Column(String(255)) # Tag assigned during upload version = Column(String(255)) # Version assigned during upload
user_agent = Column(String(512)) # Client identification user_agent = Column(String(512)) # Client identification
duration_ms = Column(Integer) # Upload timing in milliseconds duration_ms = Column(Integer) # Upload timing in milliseconds
deduplicated = Column(Boolean, default=False) # Whether artifact was deduplicated deduplicated = Column(Boolean, default=False) # Whether artifact was deduplicated
@@ -524,8 +463,8 @@ class PackageHistory(Base):
class ArtifactDependency(Base): class ArtifactDependency(Base):
"""Dependency declared by an artifact on another package. """Dependency declared by an artifact on another package.
Each artifact can declare dependencies on other packages, specifying either Each artifact can declare dependencies on other packages, specifying a version.
an exact version or a tag. This enables recursive dependency resolution. This enables recursive dependency resolution.
""" """
__tablename__ = "artifact_dependencies" __tablename__ = "artifact_dependencies"
@@ -538,20 +477,13 @@ class ArtifactDependency(Base):
) )
dependency_project = Column(String(255), nullable=False) dependency_project = Column(String(255), nullable=False)
dependency_package = Column(String(255), nullable=False) dependency_package = Column(String(255), nullable=False)
version_constraint = Column(String(255), nullable=True) version_constraint = Column(String(255), nullable=False)
tag_constraint = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow) created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
# Relationship to the artifact that declares this dependency # Relationship to the artifact that declares this dependency
artifact = relationship("Artifact", back_populates="dependencies") artifact = relationship("Artifact", back_populates="dependencies")
__table_args__ = ( __table_args__ = (
# Exactly one of version_constraint or tag_constraint must be set
CheckConstraint(
"(version_constraint IS NOT NULL AND tag_constraint IS NULL) OR "
"(version_constraint IS NULL AND tag_constraint IS NOT NULL)",
name="check_constraint_type",
),
# Each artifact can only depend on a specific project/package once # Each artifact can only depend on a specific project/package once
Index( Index(
"idx_artifact_dependencies_artifact_id", "idx_artifact_dependencies_artifact_id",

View File

@@ -12,7 +12,6 @@ from .models import (
Project, Project,
Package, Package,
Artifact, Artifact,
Tag,
Upload, Upload,
PackageVersion, PackageVersion,
ArtifactDependency, ArtifactDependency,
@@ -60,7 +59,6 @@ def purge_seed_data(db: Session) -> dict:
results = { results = {
"dependencies_deleted": 0, "dependencies_deleted": 0,
"tags_deleted": 0,
"versions_deleted": 0, "versions_deleted": 0,
"uploads_deleted": 0, "uploads_deleted": 0,
"artifacts_deleted": 0, "artifacts_deleted": 0,
@@ -103,15 +101,7 @@ def purge_seed_data(db: Session) -> dict:
results["dependencies_deleted"] = count results["dependencies_deleted"] = count
logger.info(f"Deleted {count} artifact dependencies") logger.info(f"Deleted {count} artifact dependencies")
# 2. Delete tags # 2. Delete package versions
if seed_package_ids:
count = db.query(Tag).filter(Tag.package_id.in_(seed_package_ids)).delete(
synchronize_session=False
)
results["tags_deleted"] = count
logger.info(f"Deleted {count} tags")
# 3. Delete package versions
if seed_package_ids: if seed_package_ids:
count = db.query(PackageVersion).filter( count = db.query(PackageVersion).filter(
PackageVersion.package_id.in_(seed_package_ids) PackageVersion.package_id.in_(seed_package_ids)
@@ -119,7 +109,7 @@ def purge_seed_data(db: Session) -> dict:
results["versions_deleted"] = count results["versions_deleted"] = count
logger.info(f"Deleted {count} package versions") logger.info(f"Deleted {count} package versions")
# 4. Delete uploads # 3. Delete uploads
if seed_package_ids: if seed_package_ids:
count = db.query(Upload).filter(Upload.package_id.in_(seed_package_ids)).delete( count = db.query(Upload).filter(Upload.package_id.in_(seed_package_ids)).delete(
synchronize_session=False synchronize_session=False
@@ -127,7 +117,7 @@ def purge_seed_data(db: Session) -> dict:
results["uploads_deleted"] = count results["uploads_deleted"] = count
logger.info(f"Deleted {count} uploads") logger.info(f"Deleted {count} uploads")
# 5. Delete S3 objects for seed artifacts # 4. Delete S3 objects for seed artifacts
if seed_artifact_ids: if seed_artifact_ids:
seed_artifacts = db.query(Artifact).filter(Artifact.id.in_(seed_artifact_ids)).all() seed_artifacts = db.query(Artifact).filter(Artifact.id.in_(seed_artifact_ids)).all()
for artifact in seed_artifacts: for artifact in seed_artifacts:
@@ -139,8 +129,8 @@ def purge_seed_data(db: Session) -> dict:
logger.warning(f"Failed to delete S3 object {artifact.s3_key}: {e}") logger.warning(f"Failed to delete S3 object {artifact.s3_key}: {e}")
logger.info(f"Deleted {results['s3_objects_deleted']} S3 objects") logger.info(f"Deleted {results['s3_objects_deleted']} S3 objects")
# 6. Delete artifacts (only those with ref_count that would be 0 after our deletions) # 5. Delete artifacts (only those with ref_count that would be 0 after our deletions)
# Since we deleted all tags/versions pointing to these artifacts, we can delete them # Since we deleted all versions pointing to these artifacts, we can delete them
if seed_artifact_ids: if seed_artifact_ids:
count = db.query(Artifact).filter(Artifact.id.in_(seed_artifact_ids)).delete( count = db.query(Artifact).filter(Artifact.id.in_(seed_artifact_ids)).delete(
synchronize_session=False synchronize_session=False
@@ -148,7 +138,7 @@ def purge_seed_data(db: Session) -> dict:
results["artifacts_deleted"] = count results["artifacts_deleted"] = count
logger.info(f"Deleted {count} artifacts") logger.info(f"Deleted {count} artifacts")
# 7. Delete packages # 6. Delete packages
if seed_package_ids: if seed_package_ids:
count = db.query(Package).filter(Package.id.in_(seed_package_ids)).delete( count = db.query(Package).filter(Package.id.in_(seed_package_ids)).delete(
synchronize_session=False synchronize_session=False
@@ -156,7 +146,7 @@ def purge_seed_data(db: Session) -> dict:
results["packages_deleted"] = count results["packages_deleted"] = count
logger.info(f"Deleted {count} packages") logger.info(f"Deleted {count} packages")
# 8. Delete access permissions for seed projects # 7. Delete access permissions for seed projects
if seed_project_ids: if seed_project_ids:
count = db.query(AccessPermission).filter( count = db.query(AccessPermission).filter(
AccessPermission.project_id.in_(seed_project_ids) AccessPermission.project_id.in_(seed_project_ids)
@@ -164,14 +154,14 @@ def purge_seed_data(db: Session) -> dict:
results["permissions_deleted"] = count results["permissions_deleted"] = count
logger.info(f"Deleted {count} access permissions") logger.info(f"Deleted {count} access permissions")
# 9. Delete seed projects # 8. Delete seed projects
count = db.query(Project).filter(Project.name.in_(SEED_PROJECT_NAMES)).delete( count = db.query(Project).filter(Project.name.in_(SEED_PROJECT_NAMES)).delete(
synchronize_session=False synchronize_session=False
) )
results["projects_deleted"] = count results["projects_deleted"] = count
logger.info(f"Deleted {count} projects") logger.info(f"Deleted {count} projects")
# 10. Find and delete seed team # 9. Find and delete seed team
seed_team = db.query(Team).filter(Team.slug == SEED_TEAM_SLUG).first() seed_team = db.query(Team).filter(Team.slug == SEED_TEAM_SLUG).first()
if seed_team: if seed_team:
# Delete team memberships first # Delete team memberships first
@@ -186,7 +176,7 @@ def purge_seed_data(db: Session) -> dict:
results["teams_deleted"] = 1 results["teams_deleted"] = 1
logger.info(f"Deleted team: {SEED_TEAM_SLUG}") logger.info(f"Deleted team: {SEED_TEAM_SLUG}")
# 11. Delete seed users (but NOT admin) # 10. Delete seed users (but NOT admin)
seed_users = db.query(User).filter(User.username.in_(SEED_USERNAMES)).all() seed_users = db.query(User).filter(User.username.in_(SEED_USERNAMES)).all()
for user in seed_users: for user in seed_users:
# Delete any remaining team memberships for this user # Delete any remaining team memberships for this user

View File

@@ -20,7 +20,7 @@ from fastapi.responses import StreamingResponse, HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .database import get_db from .database import get_db
from .models import UpstreamSource, CachedUrl, Artifact, Project, Package, Tag, PackageVersion from .models import UpstreamSource, CachedUrl, Artifact, Project, Package, PackageVersion
from .storage import S3Storage, get_storage from .storage import S3Storage, get_storage
from .config import get_env_upstream_sources from .config import get_env_upstream_sources
@@ -646,20 +646,6 @@ async def pypi_download_file(
db.add(package) db.add(package)
db.flush() db.flush()
# Create tag with filename
existing_tag = db.query(Tag).filter(
Tag.package_id == package.id,
Tag.name == filename,
).first()
if not existing_tag:
tag = Tag(
package_id=package.id,
name=filename,
artifact_id=sha256,
created_by="pypi-proxy",
)
db.add(tag)
# Extract and create version # Extract and create version
# Only create version for actual package files, not .metadata files # Only create version for actual package files, not .metadata files
version = _extract_pypi_version(filename) version = _extract_pypi_version(filename)

View File

@@ -9,7 +9,6 @@ from .base import BaseRepository
from .project import ProjectRepository from .project import ProjectRepository
from .package import PackageRepository from .package import PackageRepository
from .artifact import ArtifactRepository from .artifact import ArtifactRepository
from .tag import TagRepository
from .upload import UploadRepository from .upload import UploadRepository
__all__ = [ __all__ = [
@@ -17,6 +16,5 @@ __all__ = [
"ProjectRepository", "ProjectRepository",
"PackageRepository", "PackageRepository",
"ArtifactRepository", "ArtifactRepository",
"TagRepository",
"UploadRepository", "UploadRepository",
] ]

View File

@@ -8,7 +8,7 @@ from sqlalchemy import func, or_
from uuid import UUID from uuid import UUID
from .base import BaseRepository from .base import BaseRepository
from ..models import Artifact, Tag, Upload, Package, Project from ..models import Artifact, PackageVersion, Upload, Package, Project
class ArtifactRepository(BaseRepository[Artifact]): class ArtifactRepository(BaseRepository[Artifact]):
@@ -77,14 +77,14 @@ class ArtifactRepository(BaseRepository[Artifact]):
.all() .all()
) )
def get_artifacts_without_tags(self, limit: int = 100) -> List[Artifact]: def get_artifacts_without_versions(self, limit: int = 100) -> List[Artifact]:
"""Get artifacts that have no tags pointing to them.""" """Get artifacts that have no versions pointing to them."""
# Subquery to find artifact IDs that have tags # Subquery to find artifact IDs that have versions
tagged_artifacts = self.db.query(Tag.artifact_id).distinct().subquery() versioned_artifacts = self.db.query(PackageVersion.artifact_id).distinct().subquery()
return ( return (
self.db.query(Artifact) self.db.query(Artifact)
.filter(~Artifact.id.in_(tagged_artifacts)) .filter(~Artifact.id.in_(versioned_artifacts))
.limit(limit) .limit(limit)
.all() .all()
) )
@@ -115,34 +115,34 @@ class ArtifactRepository(BaseRepository[Artifact]):
return artifacts, total return artifacts, total
def get_referencing_tags(self, artifact_id: str) -> List[Tuple[Tag, Package, Project]]: def get_referencing_versions(self, artifact_id: str) -> List[Tuple[PackageVersion, Package, Project]]:
"""Get all tags referencing this artifact with package and project info.""" """Get all versions referencing this artifact with package and project info."""
return ( return (
self.db.query(Tag, Package, Project) self.db.query(PackageVersion, Package, Project)
.join(Package, Tag.package_id == Package.id) .join(Package, PackageVersion.package_id == Package.id)
.join(Project, Package.project_id == Project.id) .join(Project, Package.project_id == Project.id)
.filter(Tag.artifact_id == artifact_id) .filter(PackageVersion.artifact_id == artifact_id)
.all() .all()
) )
def search(self, query_str: str, limit: int = 10) -> List[Tuple[Tag, Artifact, str, str]]: def search(self, query_str: str, limit: int = 10) -> List[Tuple[PackageVersion, Artifact, str, str]]:
""" """
Search artifacts by tag name or original filename. Search artifacts by version or original filename.
Returns (tag, artifact, package_name, project_name) tuples. Returns (version, artifact, package_name, project_name) tuples.
""" """
search_lower = query_str.lower() search_lower = query_str.lower()
return ( return (
self.db.query(Tag, Artifact, Package.name, Project.name) self.db.query(PackageVersion, Artifact, Package.name, Project.name)
.join(Artifact, Tag.artifact_id == Artifact.id) .join(Artifact, PackageVersion.artifact_id == Artifact.id)
.join(Package, Tag.package_id == Package.id) .join(Package, PackageVersion.package_id == Package.id)
.join(Project, Package.project_id == Project.id) .join(Project, Package.project_id == Project.id)
.filter( .filter(
or_( or_(
func.lower(Tag.name).contains(search_lower), func.lower(PackageVersion.version).contains(search_lower),
func.lower(Artifact.original_name).contains(search_lower) func.lower(Artifact.original_name).contains(search_lower)
) )
) )
.order_by(Tag.name) .order_by(PackageVersion.version)
.limit(limit) .limit(limit)
.all() .all()
) )

View File

@@ -8,7 +8,7 @@ from sqlalchemy import func, or_, asc, desc
from uuid import UUID from uuid import UUID
from .base import BaseRepository from .base import BaseRepository
from ..models import Package, Project, Tag, Upload, Artifact from ..models import Package, Project, PackageVersion, Upload, Artifact
class PackageRepository(BaseRepository[Package]): class PackageRepository(BaseRepository[Package]):
@@ -136,10 +136,10 @@ class PackageRepository(BaseRepository[Package]):
return self.update(package, **updates) return self.update(package, **updates)
def get_stats(self, package_id: UUID) -> dict: def get_stats(self, package_id: UUID) -> dict:
"""Get package statistics (tag count, artifact count, total size).""" """Get package statistics (version count, artifact count, total size)."""
tag_count = ( version_count = (
self.db.query(func.count(Tag.id)) self.db.query(func.count(PackageVersion.id))
.filter(Tag.package_id == package_id) .filter(PackageVersion.package_id == package_id)
.scalar() or 0 .scalar() or 0
) )
@@ -154,7 +154,7 @@ class PackageRepository(BaseRepository[Package]):
) )
return { return {
"tag_count": tag_count, "version_count": version_count,
"artifact_count": artifact_stats[0] if artifact_stats else 0, "artifact_count": artifact_stats[0] if artifact_stats else 0,
"total_size": artifact_stats[1] if artifact_stats else 0, "total_size": artifact_stats[1] if artifact_stats else 0,
} }

View File

@@ -1,168 +0,0 @@
"""
Tag repository for data access operations.
"""
from typing import Optional, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import func, or_, asc, desc
from uuid import UUID
from .base import BaseRepository
from ..models import Tag, TagHistory, Artifact, Package, Project
class TagRepository(BaseRepository[Tag]):
"""Repository for Tag entity operations."""
model = Tag
def get_by_name(self, package_id: UUID, name: str) -> Optional[Tag]:
"""Get tag by name within a package."""
return (
self.db.query(Tag)
.filter(Tag.package_id == package_id, Tag.name == name)
.first()
)
def get_with_artifact(self, package_id: UUID, name: str) -> Optional[Tuple[Tag, Artifact]]:
"""Get tag with its artifact."""
return (
self.db.query(Tag, Artifact)
.join(Artifact, Tag.artifact_id == Artifact.id)
.filter(Tag.package_id == package_id, Tag.name == name)
.first()
)
def exists_by_name(self, package_id: UUID, name: str) -> bool:
"""Check if tag with name exists in package."""
return self.db.query(
self.db.query(Tag)
.filter(Tag.package_id == package_id, Tag.name == name)
.exists()
).scalar()
def list_by_package(
self,
package_id: UUID,
page: int = 1,
limit: int = 20,
search: Optional[str] = None,
sort: str = "name",
order: str = "asc",
) -> Tuple[List[Tuple[Tag, Artifact]], int]:
"""
List tags in a package with artifact metadata.
Returns tuple of ((tag, artifact) tuples, total_count).
"""
query = (
self.db.query(Tag, Artifact)
.join(Artifact, Tag.artifact_id == Artifact.id)
.filter(Tag.package_id == package_id)
)
# Apply search filter (tag name or artifact original filename)
if search:
search_lower = search.lower()
query = query.filter(
or_(
func.lower(Tag.name).contains(search_lower),
func.lower(Artifact.original_name).contains(search_lower)
)
)
# Get total count
total = query.count()
# Apply sorting
sort_columns = {
"name": Tag.name,
"created_at": Tag.created_at,
}
sort_column = sort_columns.get(sort, Tag.name)
if order == "desc":
query = query.order_by(desc(sort_column))
else:
query = query.order_by(asc(sort_column))
# Apply pagination
offset = (page - 1) * limit
results = query.offset(offset).limit(limit).all()
return results, total
def create_tag(
self,
package_id: UUID,
name: str,
artifact_id: str,
created_by: str,
) -> Tag:
"""Create a new tag."""
return self.create(
package_id=package_id,
name=name,
artifact_id=artifact_id,
created_by=created_by,
)
def update_artifact(
self,
tag: Tag,
new_artifact_id: str,
changed_by: str,
record_history: bool = True,
) -> Tag:
"""
Update tag to point to a different artifact.
Optionally records change in tag history.
"""
old_artifact_id = tag.artifact_id
if record_history and old_artifact_id != new_artifact_id:
history = TagHistory(
tag_id=tag.id,
old_artifact_id=old_artifact_id,
new_artifact_id=new_artifact_id,
changed_by=changed_by,
)
self.db.add(history)
tag.artifact_id = new_artifact_id
tag.created_by = changed_by
self.db.flush()
return tag
def get_history(self, tag_id: UUID) -> List[TagHistory]:
"""Get tag change history."""
return (
self.db.query(TagHistory)
.filter(TagHistory.tag_id == tag_id)
.order_by(TagHistory.changed_at.desc())
.all()
)
def get_latest_in_package(self, package_id: UUID) -> Optional[Tag]:
"""Get the most recently created/updated tag in a package."""
return (
self.db.query(Tag)
.filter(Tag.package_id == package_id)
.order_by(Tag.created_at.desc())
.first()
)
def get_by_artifact(self, artifact_id: str) -> List[Tag]:
"""Get all tags pointing to an artifact."""
return (
self.db.query(Tag)
.filter(Tag.artifact_id == artifact_id)
.all()
)
def count_by_artifact(self, artifact_id: str) -> int:
"""Count tags pointing to an artifact."""
return (
self.db.query(func.count(Tag.id))
.filter(Tag.artifact_id == artifact_id)
.scalar() or 0
)

File diff suppressed because it is too large Load Diff

View File

@@ -114,14 +114,6 @@ class PackageUpdate(BaseModel):
platform: Optional[str] = None platform: Optional[str] = None
class TagSummary(BaseModel):
"""Lightweight tag info for embedding in package responses"""
name: str
artifact_id: str
created_at: datetime
class PackageDetailResponse(BaseModel): class PackageDetailResponse(BaseModel):
"""Package with aggregated metadata""" """Package with aggregated metadata"""
@@ -134,13 +126,9 @@ class PackageDetailResponse(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
# Aggregated fields # Aggregated fields
tag_count: int = 0
artifact_count: int = 0 artifact_count: int = 0
total_size: int = 0 total_size: int = 0
latest_tag: Optional[str] = None
latest_upload_at: Optional[datetime] = None latest_upload_at: Optional[datetime] = None
# Recent tags (limit 5)
recent_tags: List[TagSummary] = []
class Config: class Config:
from_attributes = True from_attributes = True
@@ -165,79 +153,6 @@ class ArtifactResponse(BaseModel):
from_attributes = True from_attributes = True
# Tag schemas
class TagCreate(BaseModel):
name: str
artifact_id: str
class TagResponse(BaseModel):
id: UUID
package_id: UUID
name: str
artifact_id: str
created_at: datetime
created_by: str
version: Optional[str] = None # Version of the artifact this tag points to
class Config:
from_attributes = True
class TagDetailResponse(BaseModel):
"""Tag with embedded artifact metadata"""
id: UUID
package_id: UUID
name: str
artifact_id: str
created_at: datetime
created_by: str
version: Optional[str] = None # Version of the artifact this tag points to
# Artifact metadata
artifact_size: int
artifact_content_type: Optional[str]
artifact_original_name: Optional[str]
artifact_created_at: datetime
artifact_format_metadata: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
class TagHistoryResponse(BaseModel):
"""History entry for tag changes"""
id: UUID
tag_id: UUID
old_artifact_id: Optional[str]
new_artifact_id: str
changed_at: datetime
changed_by: str
class Config:
from_attributes = True
class TagHistoryDetailResponse(BaseModel):
"""Tag history with artifact metadata for each version"""
id: UUID
tag_id: UUID
tag_name: str
old_artifact_id: Optional[str]
new_artifact_id: str
changed_at: datetime
changed_by: str
# Artifact metadata for new artifact
artifact_size: int
artifact_original_name: Optional[str]
artifact_content_type: Optional[str]
class Config:
from_attributes = True
# Audit log schemas # Audit log schemas
class AuditLogResponse(BaseModel): class AuditLogResponse(BaseModel):
"""Audit log entry response""" """Audit log entry response"""
@@ -264,7 +179,7 @@ class UploadHistoryResponse(BaseModel):
package_name: str package_name: str
project_name: str project_name: str
original_name: Optional[str] original_name: Optional[str]
tag_name: Optional[str] version: Optional[str]
uploaded_at: datetime uploaded_at: datetime
uploaded_by: str uploaded_by: str
source_ip: Optional[str] source_ip: Optional[str]
@@ -306,18 +221,8 @@ class ArtifactProvenanceResponse(BaseModel):
from_attributes = True from_attributes = True
class ArtifactTagInfo(BaseModel):
"""Tag info for embedding in artifact responses"""
id: UUID
name: str
package_id: UUID
package_name: str
project_name: str
class ArtifactDetailResponse(BaseModel): class ArtifactDetailResponse(BaseModel):
"""Artifact with list of tags/packages referencing it""" """Artifact with metadata"""
id: str id: str
sha256: str # Explicit SHA256 field (same as id) sha256: str # Explicit SHA256 field (same as id)
@@ -331,14 +236,13 @@ class ArtifactDetailResponse(BaseModel):
created_by: str created_by: str
ref_count: int ref_count: int
format_metadata: Optional[Dict[str, Any]] = None format_metadata: Optional[Dict[str, Any]] = None
tags: List[ArtifactTagInfo] = []
class Config: class Config:
from_attributes = True from_attributes = True
class PackageArtifactResponse(BaseModel): class PackageArtifactResponse(BaseModel):
"""Artifact with tags for package artifact listing""" """Artifact for package artifact listing"""
id: str id: str
sha256: str # Explicit SHA256 field (same as id) sha256: str # Explicit SHA256 field (same as id)
@@ -351,7 +255,6 @@ class PackageArtifactResponse(BaseModel):
created_at: datetime created_at: datetime
created_by: str created_by: str
format_metadata: Optional[Dict[str, Any]] = None format_metadata: Optional[Dict[str, Any]] = None
tags: List[str] = [] # Tag names pointing to this artifact
class Config: class Config:
from_attributes = True from_attributes = True
@@ -369,28 +272,9 @@ class GlobalArtifactResponse(BaseModel):
created_by: str created_by: str
format_metadata: Optional[Dict[str, Any]] = None format_metadata: Optional[Dict[str, Any]] = None
ref_count: int = 0 ref_count: int = 0
# Context from tags/packages # Context from versions/packages
projects: List[str] = [] # List of project names containing this artifact projects: List[str] = [] # List of project names containing this artifact
packages: List[str] = [] # List of "project/package" paths packages: List[str] = [] # List of "project/package" paths
tags: List[str] = [] # List of "project/package:tag" references
class Config:
from_attributes = True
class GlobalTagResponse(BaseModel):
"""Tag with project/package context for global listing"""
id: UUID
name: str
artifact_id: str
created_at: datetime
created_by: str
project_name: str
package_name: str
artifact_size: Optional[int] = None
artifact_content_type: Optional[str] = None
version: Optional[str] = None # Version of the artifact this tag points to
class Config: class Config:
from_attributes = True from_attributes = True
@@ -403,7 +287,6 @@ class UploadResponse(BaseModel):
size: int size: int
project: str project: str
package: str package: str
tag: Optional[str]
version: Optional[str] = None # Version assigned to this artifact version: Optional[str] = None # Version assigned to this artifact
version_source: Optional[str] = None # How version was determined: 'explicit', 'filename', 'metadata' version_source: Optional[str] = None # How version was determined: 'explicit', 'filename', 'metadata'
checksum_md5: Optional[str] = None checksum_md5: Optional[str] = None
@@ -430,7 +313,6 @@ class ResumableUploadInitRequest(BaseModel):
filename: str filename: str
content_type: Optional[str] = None content_type: Optional[str] = None
size: int size: int
tag: Optional[str] = None
version: Optional[str] = None # Explicit version (auto-detected if not provided) version: Optional[str] = None # Explicit version (auto-detected if not provided)
@field_validator("expected_hash") @field_validator("expected_hash")
@@ -465,7 +347,7 @@ class ResumableUploadPartResponse(BaseModel):
class ResumableUploadCompleteRequest(BaseModel): class ResumableUploadCompleteRequest(BaseModel):
"""Request to complete a resumable upload""" """Request to complete a resumable upload"""
tag: Optional[str] = None pass
class ResumableUploadCompleteResponse(BaseModel): class ResumableUploadCompleteResponse(BaseModel):
@@ -475,7 +357,6 @@ class ResumableUploadCompleteResponse(BaseModel):
size: int size: int
project: str project: str
package: str package: str
tag: Optional[str]
class ResumableUploadStatusResponse(BaseModel): class ResumableUploadStatusResponse(BaseModel):
@@ -528,7 +409,6 @@ class PackageVersionResponse(BaseModel):
size: Optional[int] = None size: Optional[int] = None
content_type: Optional[str] = None content_type: Optional[str] = None
original_name: Optional[str] = None original_name: Optional[str] = None
tags: List[str] = [] # Tag names pointing to this artifact
class Config: class Config:
from_attributes = True from_attributes = True
@@ -570,11 +450,10 @@ class SearchResultPackage(BaseModel):
class SearchResultArtifact(BaseModel): class SearchResultArtifact(BaseModel):
"""Artifact/tag result for global search""" """Artifact result for global search"""
tag_id: UUID
tag_name: str
artifact_id: str artifact_id: str
version: Optional[str]
package_id: UUID package_id: UUID
package_name: str package_name: str
project_name: str project_name: str
@@ -687,7 +566,6 @@ class ProjectStatsResponse(BaseModel):
project_id: str project_id: str
project_name: str project_name: str
package_count: int package_count: int
tag_count: int
artifact_count: int artifact_count: int
total_size_bytes: int total_size_bytes: int
upload_count: int upload_count: int
@@ -702,7 +580,6 @@ class PackageStatsResponse(BaseModel):
package_id: str package_id: str
package_name: str package_name: str
project_name: str project_name: str
tag_count: int
artifact_count: int artifact_count: int
total_size_bytes: int total_size_bytes: int
upload_count: int upload_count: int
@@ -719,7 +596,6 @@ class ArtifactStatsResponse(BaseModel):
size: int size: int
ref_count: int ref_count: int
storage_savings: int # (ref_count - 1) * size storage_savings: int # (ref_count - 1) * size
tags: List[Dict[str, Any]] # Tags referencing this artifact
projects: List[str] # Projects using this artifact projects: List[str] # Projects using this artifact
packages: List[str] # Packages using this artifact packages: List[str] # Packages using this artifact
first_uploaded: Optional[datetime] = None first_uploaded: Optional[datetime] = None
@@ -930,20 +806,7 @@ class DependencyCreate(BaseModel):
"""Schema for creating a dependency""" """Schema for creating a dependency"""
project: str project: str
package: str package: str
version: Optional[str] = None version: str
tag: Optional[str] = None
@field_validator('version', 'tag')
@classmethod
def validate_constraint(cls, v, info):
return v
def model_post_init(self, __context):
"""Validate that exactly one of version or tag is set"""
if self.version is None and self.tag is None:
raise ValueError("Either 'version' or 'tag' must be specified")
if self.version is not None and self.tag is not None:
raise ValueError("Cannot specify both 'version' and 'tag'")
class DependencyResponse(BaseModel): class DependencyResponse(BaseModel):
@@ -952,8 +815,7 @@ class DependencyResponse(BaseModel):
artifact_id: str artifact_id: str
project: str project: str
package: str package: str
version: Optional[str] = None version: str
tag: Optional[str] = None
created_at: datetime created_at: datetime
class Config: class Config:
@@ -968,7 +830,6 @@ class DependencyResponse(BaseModel):
project=dep.dependency_project, project=dep.dependency_project,
package=dep.dependency_package, package=dep.dependency_package,
version=dep.version_constraint, version=dep.version_constraint,
tag=dep.tag_constraint,
created_at=dep.created_at, created_at=dep.created_at,
) )
@@ -985,7 +846,6 @@ class DependentInfo(BaseModel):
project: str project: str
package: str package: str
version: Optional[str] = None version: Optional[str] = None
constraint_type: str # 'version' or 'tag'
constraint_value: str constraint_value: str
@@ -1001,20 +861,7 @@ class EnsureFileDependency(BaseModel):
"""Dependency entry from orchard.ensure file""" """Dependency entry from orchard.ensure file"""
project: str project: str
package: str package: str
version: Optional[str] = None version: str
tag: Optional[str] = None
@field_validator('version', 'tag')
@classmethod
def validate_constraint(cls, v, info):
return v
def model_post_init(self, __context):
"""Validate that exactly one of version or tag is set"""
if self.version is None and self.tag is None:
raise ValueError("Either 'version' or 'tag' must be specified")
if self.version is not None and self.tag is not None:
raise ValueError("Cannot specify both 'version' and 'tag'")
class EnsureFileContent(BaseModel): class EnsureFileContent(BaseModel):
@@ -1028,7 +875,6 @@ class ResolvedArtifact(BaseModel):
project: str project: str
package: str package: str
version: Optional[str] = None version: Optional[str] = None
tag: Optional[str] = None
size: int size: int
download_url: str download_url: str
@@ -1054,7 +900,7 @@ class DependencyConflict(BaseModel):
"""Details about a dependency conflict""" """Details about a dependency conflict"""
project: str project: str
package: str package: str
requirements: List[Dict[str, Any]] # version/tag and required_by info requirements: List[Dict[str, Any]] # version and required_by info
class DependencyConflictError(BaseModel): class DependencyConflictError(BaseModel):
@@ -1388,10 +1234,10 @@ class CacheRequest(BaseModel):
url: str url: str
source_type: str source_type: str
package_name: Optional[str] = None # Auto-derived from URL if not provided package_name: Optional[str] = None # Auto-derived from URL if not provided
tag: Optional[str] = None # Auto-derived from URL if not provided version: Optional[str] = None # Auto-derived from URL if not provided
user_project: Optional[str] = None # Cross-reference to user project user_project: Optional[str] = None # Cross-reference to user project
user_package: Optional[str] = None user_package: Optional[str] = None
user_tag: Optional[str] = None user_version: Optional[str] = None
expected_hash: Optional[str] = None # Verify downloaded content expected_hash: Optional[str] = None # Verify downloaded content
@field_validator('url') @field_validator('url')
@@ -1438,8 +1284,8 @@ class CacheResponse(BaseModel):
source_name: Optional[str] source_name: Optional[str]
system_project: str system_project: str
system_package: str system_package: str
system_tag: Optional[str] system_version: Optional[str]
user_reference: Optional[str] = None # e.g., "my-app/npm-deps:lodash-4.17.21" user_reference: Optional[str] = None # e.g., "my-app/npm-deps/+/4.17.21"
class CacheResolveRequest(BaseModel): class CacheResolveRequest(BaseModel):
@@ -1453,7 +1299,7 @@ class CacheResolveRequest(BaseModel):
version: str version: str
user_project: Optional[str] = None user_project: Optional[str] = None
user_package: Optional[str] = None user_package: Optional[str] = None
user_tag: Optional[str] = None user_version: Optional[str] = None
@field_validator('source_type') @field_validator('source_type')
@classmethod @classmethod

View File

@@ -5,7 +5,7 @@ import hashlib
import logging import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User from .models import Project, Package, Artifact, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User
from .storage import get_storage from .storage import get_storage
from .auth import hash_password from .auth import hash_password
@@ -125,14 +125,14 @@ TEST_ARTIFACTS = [
] ]
# Dependencies to create (source artifact -> dependency) # Dependencies to create (source artifact -> dependency)
# Format: (source_project, source_package, source_version, dep_project, dep_package, version_constraint, tag_constraint) # Format: (source_project, source_package, source_version, dep_project, dep_package, version_constraint)
TEST_DEPENDENCIES = [ TEST_DEPENDENCIES = [
# ui-components v1.1.0 depends on design-tokens v1.0.0 # ui-components v1.1.0 depends on design-tokens v1.0.0
("frontend-libs", "ui-components", "1.1.0", "frontend-libs", "design-tokens", "1.0.0", None), ("frontend-libs", "ui-components", "1.1.0", "frontend-libs", "design-tokens", "1.0.0"),
# auth-lib v1.0.0 depends on common-utils v2.0.0 # auth-lib v1.0.0 depends on common-utils v2.0.0
("backend-services", "auth-lib", "1.0.0", "backend-services", "common-utils", "2.0.0", None), ("backend-services", "auth-lib", "1.0.0", "backend-services", "common-utils", "2.0.0"),
# auth-lib v1.0.0 also depends on design-tokens (stable tag) # auth-lib v1.0.0 also depends on design-tokens v1.0.0
("backend-services", "auth-lib", "1.0.0", "frontend-libs", "design-tokens", None, "latest"), ("backend-services", "auth-lib", "1.0.0", "frontend-libs", "design-tokens", "1.0.0"),
] ]
@@ -252,9 +252,8 @@ def seed_database(db: Session) -> None:
logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages (assigned to {demo_team.slug})") logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages (assigned to {demo_team.slug})")
# Create artifacts, tags, and versions # Create artifacts and versions
artifact_count = 0 artifact_count = 0
tag_count = 0
version_count = 0 version_count = 0
for artifact_data in TEST_ARTIFACTS: for artifact_data in TEST_ARTIFACTS:
@@ -316,23 +315,12 @@ def seed_database(db: Session) -> None:
db.add(version) db.add(version)
version_count += 1 version_count += 1
# Create tags
for tag_name in artifact_data["tags"]:
tag = Tag(
package_id=package.id,
name=tag_name,
artifact_id=sha256_hash,
created_by=team_owner_username,
)
db.add(tag)
tag_count += 1
db.flush() db.flush()
# Create dependencies # Create dependencies
dependency_count = 0 dependency_count = 0
for dep_data in TEST_DEPENDENCIES: for dep_data in TEST_DEPENDENCIES:
src_project, src_package, src_version, dep_project, dep_package, version_constraint, tag_constraint = dep_data src_project, src_package, src_version, dep_project, dep_package, version_constraint = dep_data
# Find the source artifact by looking up its version # Find the source artifact by looking up its version
src_pkg = package_map.get((src_project, src_package)) src_pkg = package_map.get((src_project, src_package))
@@ -356,11 +344,10 @@ def seed_database(db: Session) -> None:
dependency_project=dep_project, dependency_project=dep_project,
dependency_package=dep_package, dependency_package=dep_package,
version_constraint=version_constraint, version_constraint=version_constraint,
tag_constraint=tag_constraint,
) )
db.add(dependency) db.add(dependency)
dependency_count += 1 dependency_count += 1
db.commit() db.commit()
logger.info(f"Created {artifact_count} artifacts, {tag_count} tags, {version_count} versions, and {dependency_count} dependencies") logger.info(f"Created {artifact_count} artifacts, {version_count} versions, and {dependency_count} dependencies")
logger.info("Database seeding complete") logger.info("Database seeding complete")

View File

@@ -6,9 +6,8 @@ from typing import List, Optional, Tuple
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import logging import logging
from ..models import Artifact, Tag from ..models import Artifact, PackageVersion
from ..repositories.artifact import ArtifactRepository from ..repositories.artifact import ArtifactRepository
from ..repositories.tag import TagRepository
from ..storage import S3Storage from ..storage import S3Storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,8 +20,8 @@ class ArtifactCleanupService:
Reference counting rules: Reference counting rules:
- ref_count starts at 1 when artifact is first uploaded - ref_count starts at 1 when artifact is first uploaded
- ref_count increments when the same artifact is uploaded again (deduplication) - ref_count increments when the same artifact is uploaded again (deduplication)
- ref_count decrements when a tag is deleted or updated to point elsewhere - ref_count decrements when a version is deleted or updated to point elsewhere
- ref_count decrements when a package is deleted (for each tag pointing to artifact) - ref_count decrements when a package is deleted (for each version pointing to artifact)
- When ref_count reaches 0, artifact is a candidate for deletion from S3 - When ref_count reaches 0, artifact is a candidate for deletion from S3
""" """
@@ -30,12 +29,11 @@ class ArtifactCleanupService:
self.db = db self.db = db
self.storage = storage self.storage = storage
self.artifact_repo = ArtifactRepository(db) self.artifact_repo = ArtifactRepository(db)
self.tag_repo = TagRepository(db)
def on_tag_deleted(self, artifact_id: str) -> Artifact: def on_version_deleted(self, artifact_id: str) -> Artifact:
""" """
Called when a tag is deleted. Called when a version is deleted.
Decrements ref_count for the artifact the tag was pointing to. Decrements ref_count for the artifact the version was pointing to.
""" """
artifact = self.artifact_repo.get_by_sha256(artifact_id) artifact = self.artifact_repo.get_by_sha256(artifact_id)
if artifact: if artifact:
@@ -45,11 +43,11 @@ class ArtifactCleanupService:
) )
return artifact return artifact
def on_tag_updated( def on_version_updated(
self, old_artifact_id: str, new_artifact_id: str self, old_artifact_id: str, new_artifact_id: str
) -> Tuple[Optional[Artifact], Optional[Artifact]]: ) -> Tuple[Optional[Artifact], Optional[Artifact]]:
""" """
Called when a tag is updated to point to a different artifact. Called when a version is updated to point to a different artifact.
Decrements ref_count for old artifact, increments for new (if different). Decrements ref_count for old artifact, increments for new (if different).
Returns (old_artifact, new_artifact) tuple. Returns (old_artifact, new_artifact) tuple.
@@ -79,21 +77,21 @@ class ArtifactCleanupService:
def on_package_deleted(self, package_id) -> List[str]: def on_package_deleted(self, package_id) -> List[str]:
""" """
Called when a package is deleted. Called when a package is deleted.
Decrements ref_count for all artifacts that had tags in the package. Decrements ref_count for all artifacts that had versions in the package.
Returns list of artifact IDs that were affected. Returns list of artifact IDs that were affected.
""" """
# Get all tags in the package before deletion # Get all versions in the package before deletion
tags = self.db.query(Tag).filter(Tag.package_id == package_id).all() versions = self.db.query(PackageVersion).filter(PackageVersion.package_id == package_id).all()
affected_artifacts = [] affected_artifacts = []
for tag in tags: for version in versions:
artifact = self.artifact_repo.get_by_sha256(tag.artifact_id) artifact = self.artifact_repo.get_by_sha256(version.artifact_id)
if artifact: if artifact:
self.artifact_repo.decrement_ref_count(artifact) self.artifact_repo.decrement_ref_count(artifact)
affected_artifacts.append(tag.artifact_id) affected_artifacts.append(version.artifact_id)
logger.info( logger.info(
f"Decremented ref_count for artifact {tag.artifact_id} (package delete)" f"Decremented ref_count for artifact {version.artifact_id} (package delete)"
) )
return affected_artifacts return affected_artifacts
@@ -152,7 +150,7 @@ class ArtifactCleanupService:
def verify_ref_counts(self, fix: bool = False) -> List[dict]: def verify_ref_counts(self, fix: bool = False) -> List[dict]:
""" """
Verify that ref_counts match actual tag references. Verify that ref_counts match actual version references.
Args: Args:
fix: If True, fix any mismatched ref_counts fix: If True, fix any mismatched ref_counts
@@ -162,28 +160,28 @@ class ArtifactCleanupService:
""" """
from sqlalchemy import func from sqlalchemy import func
# Get actual tag counts per artifact # Get actual version counts per artifact
tag_counts = ( version_counts = (
self.db.query(Tag.artifact_id, func.count(Tag.id).label("tag_count")) self.db.query(PackageVersion.artifact_id, func.count(PackageVersion.id).label("version_count"))
.group_by(Tag.artifact_id) .group_by(PackageVersion.artifact_id)
.all() .all()
) )
tag_count_map = {artifact_id: count for artifact_id, count in tag_counts} version_count_map = {artifact_id: count for artifact_id, count in version_counts}
# Check all artifacts # Check all artifacts
artifacts = self.db.query(Artifact).all() artifacts = self.db.query(Artifact).all()
mismatches = [] mismatches = []
for artifact in artifacts: for artifact in artifacts:
actual_count = tag_count_map.get(artifact.id, 0) actual_count = version_count_map.get(artifact.id, 0)
# ref_count should be at least 1 (initial upload) + additional uploads # ref_count should be at least 1 (initial upload) + additional uploads
# But tags are the primary reference, so we check against tag count # But versions are the primary reference, so we check against version count
if artifact.ref_count < actual_count: if artifact.ref_count < actual_count:
mismatch = { mismatch = {
"artifact_id": artifact.id, "artifact_id": artifact.id,
"stored_ref_count": artifact.ref_count, "stored_ref_count": artifact.ref_count,
"actual_tag_count": actual_count, "actual_version_count": actual_count,
} }
mismatches.append(mismatch) mismatches.append(mismatch)

View File

@@ -1,403 +0,0 @@
"""
Integration tests for tag API endpoints.
Tests cover:
- Tag CRUD operations
- Tag listing with pagination and search
- Tag history tracking
- ref_count behavior with tag operations
"""
import pytest
from tests.factories import compute_sha256, upload_test_file
class TestTagCRUD:
"""Tests for tag create, read, delete operations."""
@pytest.mark.integration
def test_create_tag_via_upload(self, integration_client, test_package):
"""Test creating a tag via upload endpoint."""
project_name, package_name = test_package
result = upload_test_file(
integration_client,
project_name,
package_name,
b"tag create test",
tag="v1.0.0",
)
assert result["tag"] == "v1.0.0"
assert result["artifact_id"]
@pytest.mark.integration
def test_create_tag_via_post(
self, integration_client, test_package, unique_test_id
):
"""Test creating a tag via POST /tags endpoint."""
project_name, package_name = test_package
# First upload an artifact
result = upload_test_file(
integration_client,
project_name,
package_name,
b"artifact for tag",
)
artifact_id = result["artifact_id"]
# Create tag via POST
tag_name = f"post-tag-{unique_test_id}"
response = integration_client.post(
f"/api/v1/project/{project_name}/{package_name}/tags",
json={"name": tag_name, "artifact_id": artifact_id},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == tag_name
assert data["artifact_id"] == artifact_id
@pytest.mark.integration
def test_get_tag(self, integration_client, test_package):
"""Test getting a tag by name."""
project_name, package_name = test_package
upload_test_file(
integration_client,
project_name,
package_name,
b"get tag test",
tag="get-tag",
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/tags/get-tag"
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-tag"
assert "artifact_id" in data
assert "artifact_size" in data
assert "artifact_content_type" in data
@pytest.mark.integration
def test_list_tags(self, integration_client, test_package):
"""Test listing tags for a package."""
project_name, package_name = test_package
# Create some tags
upload_test_file(
integration_client,
project_name,
package_name,
b"list tags test",
tag="list-v1",
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/tags"
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "pagination" in data
tag_names = [t["name"] for t in data["items"]]
assert "list-v1" in tag_names
@pytest.mark.integration
def test_delete_tag(self, integration_client, test_package):
"""Test deleting a tag."""
project_name, package_name = test_package
upload_test_file(
integration_client,
project_name,
package_name,
b"delete tag test",
tag="to-delete",
)
# Delete tag
response = integration_client.delete(
f"/api/v1/project/{project_name}/{package_name}/tags/to-delete"
)
assert response.status_code == 204
# Verify deleted
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/tags/to-delete"
)
assert response.status_code == 404
class TestTagListingFilters:
"""Tests for tag listing with filters and search."""
@pytest.mark.integration
def test_tags_pagination(self, integration_client, test_package):
"""Test tag listing respects pagination."""
project_name, package_name = test_package
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/tags?limit=5"
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) <= 5
assert data["pagination"]["limit"] == 5
@pytest.mark.integration
def test_tags_search(self, integration_client, test_package, unique_test_id):
"""Test tag search by name."""
project_name, package_name = test_package
tag_name = f"searchable-{unique_test_id}"
upload_test_file(
integration_client,
project_name,
package_name,
b"search test",
tag=tag_name,
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/tags?search=searchable"
)
assert response.status_code == 200
data = response.json()
tag_names = [t["name"] for t in data["items"]]
assert tag_name in tag_names
class TestTagHistory:
"""Tests for tag history tracking."""
@pytest.mark.integration
def test_tag_history_on_create(self, integration_client, test_package):
"""Test tag history is created when tag is created."""
project_name, package_name = test_package
upload_test_file(
integration_client,
project_name,
package_name,
b"history create test",
tag="history-create",
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/tags/history-create/history"
)
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
@pytest.mark.integration
def test_tag_history_on_update(
self, integration_client, test_package, unique_test_id
):
"""Test tag history is created when tag is updated."""
project_name, package_name = test_package
tag_name = f"history-update-{unique_test_id}"
# Create tag with first artifact
upload_test_file(
integration_client,
project_name,
package_name,
b"first content",
tag=tag_name,
)
# Update tag with second artifact
upload_test_file(
integration_client,
project_name,
package_name,
b"second content",
tag=tag_name,
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/tags/{tag_name}/history"
)
assert response.status_code == 200
data = response.json()
# Should have at least 2 history entries (create + update)
assert len(data) >= 2
class TestTagRefCount:
"""Tests for ref_count behavior with tag operations."""
@pytest.mark.integration
def test_ref_count_decrements_on_tag_delete(self, integration_client, test_package):
"""Test ref_count decrements when a tag is deleted."""
project_name, package_name = test_package
content = b"ref count delete test"
expected_hash = compute_sha256(content)
# Upload with two tags
upload_test_file(
integration_client, project_name, package_name, content, tag="rc-v1"
)
upload_test_file(
integration_client, project_name, package_name, content, tag="rc-v2"
)
# Verify ref_count is 2
response = integration_client.get(f"/api/v1/artifact/{expected_hash}")
assert response.json()["ref_count"] == 2
# Delete one tag
delete_response = integration_client.delete(
f"/api/v1/project/{project_name}/{package_name}/tags/rc-v1"
)
assert delete_response.status_code == 204
# Verify ref_count is now 1
response = integration_client.get(f"/api/v1/artifact/{expected_hash}")
assert response.json()["ref_count"] == 1
@pytest.mark.integration
def test_ref_count_zero_after_all_tags_deleted(
self, integration_client, test_package
):
"""Test ref_count goes to 0 when all tags are deleted."""
project_name, package_name = test_package
content = b"orphan test content"
expected_hash = compute_sha256(content)
# Upload with one tag
upload_test_file(
integration_client, project_name, package_name, content, tag="only-tag"
)
# Delete the tag
integration_client.delete(
f"/api/v1/project/{project_name}/{package_name}/tags/only-tag"
)
# Verify ref_count is 0
response = integration_client.get(f"/api/v1/artifact/{expected_hash}")
assert response.json()["ref_count"] == 0
@pytest.mark.integration
def test_ref_count_adjusts_on_tag_update(
self, integration_client, test_package, unique_test_id
):
"""Test ref_count adjusts when a tag is updated to point to different artifact."""
project_name, package_name = test_package
# Upload two different artifacts
content1 = f"artifact one {unique_test_id}".encode()
content2 = f"artifact two {unique_test_id}".encode()
hash1 = compute_sha256(content1)
hash2 = compute_sha256(content2)
# Upload first artifact with tag "latest"
upload_test_file(
integration_client, project_name, package_name, content1, tag="latest"
)
# Verify first artifact has ref_count 1
response = integration_client.get(f"/api/v1/artifact/{hash1}")
assert response.json()["ref_count"] == 1
# Upload second artifact with different tag
upload_test_file(
integration_client, project_name, package_name, content2, tag="stable"
)
# Now update "latest" tag to point to second artifact
upload_test_file(
integration_client, project_name, package_name, content2, tag="latest"
)
# Verify first artifact ref_count decreased to 0
response = integration_client.get(f"/api/v1/artifact/{hash1}")
assert response.json()["ref_count"] == 0
# Verify second artifact ref_count increased to 2
response = integration_client.get(f"/api/v1/artifact/{hash2}")
assert response.json()["ref_count"] == 2
@pytest.mark.integration
def test_ref_count_unchanged_when_tag_same_artifact(
self, integration_client, test_package, unique_test_id
):
"""Test ref_count doesn't change when tag is 'updated' to same artifact."""
project_name, package_name = test_package
content = f"same artifact {unique_test_id}".encode()
expected_hash = compute_sha256(content)
# Upload with tag
upload_test_file(
integration_client, project_name, package_name, content, tag="same-v1"
)
# Verify ref_count is 1
response = integration_client.get(f"/api/v1/artifact/{expected_hash}")
assert response.json()["ref_count"] == 1
# Upload same content with same tag (no-op)
upload_test_file(
integration_client, project_name, package_name, content, tag="same-v1"
)
# Verify ref_count is still 1
response = integration_client.get(f"/api/v1/artifact/{expected_hash}")
assert response.json()["ref_count"] == 1
@pytest.mark.integration
def test_tag_via_post_endpoint_increments_ref_count(
self, integration_client, test_package, unique_test_id
):
"""Test creating tag via POST /tags endpoint increments ref_count."""
project_name, package_name = test_package
content = f"tag endpoint test {unique_test_id}".encode()
expected_hash = compute_sha256(content)
# Upload artifact without tag
result = upload_test_file(
integration_client, project_name, package_name, content, filename="test.bin"
)
artifact_id = result["artifact_id"]
# Verify ref_count is 0 (no tags yet)
response = integration_client.get(f"/api/v1/artifact/{expected_hash}")
assert response.json()["ref_count"] == 0
# Create tag via POST endpoint
tag_response = integration_client.post(
f"/api/v1/project/{project_name}/{package_name}/tags",
json={"name": "post-v1", "artifact_id": artifact_id},
)
assert tag_response.status_code == 200
# Verify ref_count is now 1
response = integration_client.get(f"/api/v1/artifact/{expected_hash}")
assert response.json()["ref_count"] == 1
# Create another tag via POST endpoint
tag_response = integration_client.post(
f"/api/v1/project/{project_name}/{package_name}/tags",
json={"name": "post-latest", "artifact_id": artifact_id},
)
assert tag_response.status_code == 200
# Verify ref_count is now 2
response = integration_client.get(f"/api/v1/artifact/{expected_hash}")
assert response.json()["ref_count"] == 2

View File

@@ -1,14 +1,11 @@
import { import {
Project, Project,
Package, Package,
Tag,
TagDetail,
ArtifactDetail, ArtifactDetail,
PackageArtifact, PackageArtifact,
UploadResponse, UploadResponse,
PaginatedResponse, PaginatedResponse,
ListParams, ListParams,
TagListParams,
PackageListParams, PackageListParams,
ArtifactListParams, ArtifactListParams,
ProjectListParams, ProjectListParams,
@@ -240,32 +237,6 @@ export async function createPackage(projectName: string, data: { name: string; d
return handleResponse<Package>(response); return handleResponse<Package>(response);
} }
// Tag API
export async function listTags(projectName: string, packageName: string, params: TagListParams = {}): Promise<PaginatedResponse<TagDetail>> {
const query = buildQueryString(params as Record<string, unknown>);
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/tags${query}`);
return handleResponse<PaginatedResponse<TagDetail>>(response);
}
export async function listTagsSimple(projectName: string, packageName: string, params: TagListParams = {}): Promise<TagDetail[]> {
const data = await listTags(projectName, packageName, params);
return data.items;
}
export async function getTag(projectName: string, packageName: string, tagName: string): Promise<TagDetail> {
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/tags/${tagName}`);
return handleResponse<TagDetail>(response);
}
export async function createTag(projectName: string, packageName: string, data: { name: string; artifact_id: string }): Promise<Tag> {
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return handleResponse<Tag>(response);
}
// Artifact API // Artifact API
export async function getArtifact(artifactId: string): Promise<ArtifactDetail> { export async function getArtifact(artifactId: string): Promise<ArtifactDetail> {
const response = await fetch(`${API_BASE}/artifact/${artifactId}`); const response = await fetch(`${API_BASE}/artifact/${artifactId}`);
@@ -287,14 +258,10 @@ export async function uploadArtifact(
projectName: string, projectName: string,
packageName: string, packageName: string,
file: File, file: File,
tag?: string,
version?: string version?: string
): Promise<UploadResponse> { ): Promise<UploadResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (tag) {
formData.append('tag', tag);
}
if (version) { if (version) {
formData.append('version', version); formData.append('version', version);
} }

View File

@@ -170,7 +170,7 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
label: `${artifact.project}/${artifact.package}`, label: `${artifact.project}/${artifact.package}`,
project: artifact.project, project: artifact.project,
package: artifact.package, package: artifact.package,
version: artifact.version || artifact.tag, version: artifact.version,
size: artifact.size, size: artifact.size,
isRoot, isRoot,
onNavigate, onNavigate,

View File

@@ -524,7 +524,7 @@ describe('DragDropUpload', () => {
} }
vi.stubGlobal('XMLHttpRequest', MockXHR); vi.stubGlobal('XMLHttpRequest', MockXHR);
render(<DragDropUpload {...defaultProps} tag="v1.0.0" />); render(<DragDropUpload {...defaultProps} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement; const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = createMockFile('test.txt', 100, 'text/plain'); const file = createMockFile('test.txt', 100, 'text/plain');

View File

@@ -13,7 +13,6 @@ interface StoredUploadState {
completedParts: number[]; completedParts: number[];
project: string; project: string;
package: string; package: string;
tag?: string;
createdAt: number; createdAt: number;
} }
@@ -87,7 +86,6 @@ export interface DragDropUploadProps {
maxFileSize?: number; // in bytes maxFileSize?: number; // in bytes
maxConcurrentUploads?: number; maxConcurrentUploads?: number;
maxRetries?: number; maxRetries?: number;
tag?: string;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
disabledReason?: string; disabledReason?: string;
@@ -230,7 +228,6 @@ export function DragDropUpload({
maxFileSize, maxFileSize,
maxConcurrentUploads = 3, maxConcurrentUploads = 3,
maxRetries = 3, maxRetries = 3,
tag,
className = '', className = '',
disabled = false, disabled = false,
disabledReason, disabledReason,
@@ -368,7 +365,6 @@ export function DragDropUpload({
expected_hash: fileHash, expected_hash: fileHash,
filename: item.file.name, filename: item.file.name,
size: item.file.size, size: item.file.size,
tag: tag || undefined,
}), }),
} }
); );
@@ -392,7 +388,6 @@ export function DragDropUpload({
completedParts: [], completedParts: [],
project: projectName, project: projectName,
package: packageName, package: packageName,
tag: tag || undefined,
createdAt: Date.now(), createdAt: Date.now(),
}); });
@@ -438,7 +433,6 @@ export function DragDropUpload({
completedParts, completedParts,
project: projectName, project: projectName,
package: packageName, package: packageName,
tag: tag || undefined,
createdAt: Date.now(), createdAt: Date.now(),
}); });
@@ -459,7 +453,7 @@ export function DragDropUpload({
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag: tag || undefined }), body: JSON.stringify({}),
} }
); );
@@ -475,7 +469,7 @@ export function DragDropUpload({
size: completeData.size, size: completeData.size,
deduplicated: false, deduplicated: false,
}; };
}, [projectName, packageName, tag, isOnline]); }, [projectName, packageName, isOnline]);
const uploadFileSimple = useCallback((item: UploadItem): Promise<UploadResult> => { const uploadFileSimple = useCallback((item: UploadItem): Promise<UploadResult> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -484,9 +478,6 @@ export function DragDropUpload({
const formData = new FormData(); const formData = new FormData();
formData.append('file', item.file); formData.append('file', item.file);
if (tag) {
formData.append('tag', tag);
}
let lastLoaded = 0; let lastLoaded = 0;
let lastTime = Date.now(); let lastTime = Date.now();
@@ -555,7 +546,7 @@ export function DragDropUpload({
: u : u
)); ));
}); });
}, [projectName, packageName, tag]); }, [projectName, packageName]);
const uploadFile = useCallback((item: UploadItem): Promise<UploadResult> => { const uploadFile = useCallback((item: UploadItem): Promise<UploadResult> => {
if (item.file.size >= CHUNKED_UPLOAD_THRESHOLD) { if (item.file.size >= CHUNKED_UPLOAD_THRESHOLD) {

View File

@@ -233,7 +233,7 @@ export function GlobalSearch() {
const flatIndex = results.projects.length + results.packages.length + index; const flatIndex = results.projects.length + results.packages.length + index;
return ( return (
<button <button
key={artifact.tag_id} key={artifact.artifact_id}
className={`global-search__result ${selectedIndex === flatIndex ? 'selected' : ''}`} className={`global-search__result ${selectedIndex === flatIndex ? 'selected' : ''}`}
onClick={() => navigateToResult({ type: 'artifact', item: artifact })} onClick={() => navigateToResult({ type: 'artifact', item: artifact })}
onMouseEnter={() => setSelectedIndex(flatIndex)} onMouseEnter={() => setSelectedIndex(flatIndex)}
@@ -243,7 +243,7 @@ export function GlobalSearch() {
<line x1="7" y1="7" x2="7.01" y2="7" /> <line x1="7" y1="7" x2="7.01" y2="7" />
</svg> </svg>
<div className="global-search__result-content"> <div className="global-search__result-content">
<span className="global-search__result-name">{artifact.tag_name}</span> <span className="global-search__result-name">{artifact.version}</span>
<span className="global-search__result-path"> <span className="global-search__result-path">
{artifact.project_name} / {artifact.package_name} {artifact.project_name} / {artifact.package_name}
</span> </span>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate, useLocation, Link } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate, useLocation, Link } from 'react-router-dom';
import { PackageArtifact, Package, PaginatedResponse, AccessLevel, Dependency, DependentInfo } from '../types'; import { PackageArtifact, Package, PaginatedResponse, AccessLevel, Dependency, DependentInfo } from '../types';
import { listPackageArtifacts, getDownloadUrl, getPackage, getMyProjectAccess, createTag, getArtifactDependencies, getReverseDependencies, getEnsureFile, UnauthorizedError, ForbiddenError } from '../api'; import { listPackageArtifacts, getDownloadUrl, getPackage, getMyProjectAccess, getArtifactDependencies, getReverseDependencies, getEnsureFile, UnauthorizedError, ForbiddenError } from '../api';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput'; import { SearchInput } from '../components/SearchInput';
@@ -61,16 +61,11 @@ function PackagePage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false); const [accessDenied, setAccessDenied] = useState(false);
const [uploadTag, setUploadTag] = useState('');
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null); const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null); const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
const [createTagName, setCreateTagName] = useState('');
const [createTagArtifactId, setCreateTagArtifactId] = useState('');
const [createTagLoading, setCreateTagLoading] = useState(false);
// UI state // UI state
const [showUploadModal, setShowUploadModal] = useState(false); const [showUploadModal, setShowUploadModal] = useState(false);
const [showCreateTagModal, setShowCreateTagModal] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null); const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
@@ -226,15 +221,15 @@ function PackagePage() {
} }
}, [projectName, packageName, loading, fetchReverseDeps]); }, [projectName, packageName, loading, fetchReverseDeps]);
// Fetch ensure file for a specific tag // Fetch ensure file for a specific version or artifact
const fetchEnsureFileForTag = useCallback(async (tagName: string) => { const fetchEnsureFileForRef = useCallback(async (ref: string) => {
if (!projectName || !packageName) return; if (!projectName || !packageName) return;
setEnsureFileTagName(tagName); setEnsureFileTagName(ref);
setEnsureFileLoading(true); setEnsureFileLoading(true);
setEnsureFileError(null); setEnsureFileError(null);
try { try {
const content = await getEnsureFile(projectName, packageName, tagName); const content = await getEnsureFile(projectName, packageName, ref);
setEnsureFileContent(content); setEnsureFileContent(content);
setShowEnsureFile(true); setShowEnsureFile(true);
} catch (err) { } catch (err) {
@@ -245,11 +240,13 @@ function PackagePage() {
} }
}, [projectName, packageName]); }, [projectName, packageName]);
// Fetch ensure file for selected artifact (if it has tags) // Fetch ensure file for selected artifact
const fetchEnsureFile = useCallback(async () => { const fetchEnsureFile = useCallback(async () => {
if (!selectedArtifact || selectedArtifact.tags.length === 0) return; if (!selectedArtifact) return;
fetchEnsureFileForTag(selectedArtifact.tags[0]); const version = getArtifactVersion(selectedArtifact);
}, [selectedArtifact, fetchEnsureFileForTag]); const ref = version || `artifact:${selectedArtifact.id}`;
fetchEnsureFileForRef(ref);
}, [selectedArtifact, fetchEnsureFileForRef]);
// Keyboard navigation - go back with backspace // Keyboard navigation - go back with backspace
useEffect(() => { useEffect(() => {
@@ -269,7 +266,6 @@ function PackagePage() {
? `Uploaded successfully! Artifact ID: ${results[0].artifact_id}` ? `Uploaded successfully! Artifact ID: ${results[0].artifact_id}`
: `${count} files uploaded successfully!`; : `${count} files uploaded successfully!`;
setUploadSuccess(message); setUploadSuccess(message);
setUploadTag('');
loadData(); loadData();
// Auto-dismiss success message after 5 seconds // Auto-dismiss success message after 5 seconds
@@ -280,30 +276,6 @@ function PackagePage() {
setError(errorMsg); setError(errorMsg);
}, []); }, []);
const handleCreateTag = async (e: React.FormEvent) => {
e.preventDefault();
if (!createTagName.trim() || createTagArtifactId.length !== 64) return;
setCreateTagLoading(true);
setError(null);
try {
await createTag(projectName!, packageName!, {
name: createTagName.trim(),
artifact_id: createTagArtifactId,
});
setUploadSuccess(`Tag "${createTagName}" created successfully!`);
setCreateTagName('');
setCreateTagArtifactId('');
loadData();
setTimeout(() => setUploadSuccess(null), 5000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create tag');
} finally {
setCreateTagLoading(false);
}
};
const handleSearchChange = (value: string) => { const handleSearchChange = (value: string) => {
updateParams({ search: value, page: '1' }); updateParams({ search: value, page: '1' });
}; };
@@ -346,9 +318,10 @@ function PackagePage() {
return (a.format_metadata?.version as string) || null; return (a.format_metadata?.version as string) || null;
}; };
// Helper to get download ref - prefer first tag, fallback to artifact ID // Helper to get download ref - prefer version, fallback to artifact ID
const getDownloadRef = (a: PackageArtifact): string => { const getDownloadRef = (a: PackageArtifact): string => {
return a.tags.length > 0 ? a.tags[0] : `artifact:${a.id}`; const version = getArtifactVersion(a);
return version || `artifact:${a.id}`;
}; };
// System projects show Version first, regular projects show Tag first // System projects show Version first, regular projects show Tag first
@@ -365,7 +338,7 @@ function PackagePage() {
onClick={() => handleArtifactSelect(a)} onClick={() => handleArtifactSelect(a)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<span className="version-badge">{getArtifactVersion(a) || a.tags[0] || a.id.slice(0, 12)}</span> <span className="version-badge">{getArtifactVersion(a) || a.id.slice(0, 12)}</span>
</strong> </strong>
), ),
}, },
@@ -425,30 +398,22 @@ function PackagePage() {
}, },
] ]
: [ : [
// Regular project columns: Tag, Version, Filename, Size, Created // Regular project columns: Version, Filename, Size, Created
// Valid sort fields: created_at, size, original_name // Valid sort fields: created_at, size, original_name
{ {
key: 'tags', key: 'version',
header: 'Tag', header: 'Version',
// tags is not a sortable DB field // version is from format_metadata, not a sortable DB field
render: (a: PackageArtifact) => ( render: (a: PackageArtifact) => (
<strong <strong
className={`tag-name-link ${selectedArtifact?.id === a.id ? 'selected' : ''}`} className={`tag-name-link ${selectedArtifact?.id === a.id ? 'selected' : ''}`}
onClick={() => handleArtifactSelect(a)} onClick={() => handleArtifactSelect(a)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
{a.tags.length > 0 ? a.tags[0] : '—'} <span className="version-badge">{getArtifactVersion(a) || a.id.slice(0, 12)}</span>
</strong> </strong>
), ),
}, },
{
key: 'version',
header: 'Version',
// version is from format_metadata, not a sortable DB field
render: (a: PackageArtifact) => (
<span className="version-badge">{getArtifactVersion(a) || '—'}</span>
),
},
{ {
key: 'original_name', key: 'original_name',
header: 'Filename', header: 'Filename',
@@ -536,16 +501,9 @@ function PackagePage() {
<button onClick={() => { navigator.clipboard.writeText(a.id); setOpenMenuId(null); setMenuPosition(null); }}> <button onClick={() => { navigator.clipboard.writeText(a.id); setOpenMenuId(null); setMenuPosition(null); }}>
Copy Artifact ID Copy Artifact ID
</button> </button>
{a.tags.length > 0 && ( <button onClick={() => { const version = getArtifactVersion(a); const ref = version || `artifact:${a.id}`; fetchEnsureFileForRef(ref); setOpenMenuId(null); setMenuPosition(null); }}>
<button onClick={() => { fetchEnsureFileForTag(a.tags[0]); setOpenMenuId(null); setMenuPosition(null); }}> View Ensure File
View Ensure File </button>
</button>
)}
{canWrite && !isSystemProject && (
<button onClick={() => { setCreateTagArtifactId(a.id); setShowCreateTagModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
Create/Update Tag
</button>
)}
<button onClick={() => { handleArtifactSelect(a); setShowDepsModal(true); setOpenMenuId(null); setMenuPosition(null); }}> <button onClick={() => { handleArtifactSelect(a); setShowDepsModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
View Dependencies View Dependencies
</button> </button>
@@ -623,13 +581,8 @@ function PackagePage() {
</> </>
)} )}
</div> </div>
{pkg && (pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && ( {pkg && pkg.artifact_count !== undefined && (
<div className="package-header-stats"> <div className="package-header-stats">
{!isSystemProject && pkg.tag_count !== undefined && (
<span className="stat-item">
<strong>{pkg.tag_count}</strong> tags
</span>
)}
{pkg.artifact_count !== undefined && ( {pkg.artifact_count !== undefined && (
<span className="stat-item"> <span className="stat-item">
<strong>{pkg.artifact_count}</strong> {isSystemProject ? 'versions' : 'artifacts'} <strong>{pkg.artifact_count}</strong> {isSystemProject ? 'versions' : 'artifacts'}
@@ -640,11 +593,6 @@ function PackagePage() {
<strong>{formatBytes(pkg.total_size)}</strong> total <strong>{formatBytes(pkg.total_size)}</strong> total
</span> </span>
)} )}
{!isSystemProject && pkg.latest_tag && (
<span className="stat-item">
Latest: <strong className="accent">{pkg.latest_tag}</strong>
</span>
)}
</div> </div>
)} )}
</div> </div>
@@ -655,7 +603,7 @@ function PackagePage() {
<div className="section-header"> <div className="section-header">
<h2>{isSystemProject ? 'Versions' : 'Tags / Versions'}</h2> <h2>{isSystemProject ? 'Versions' : 'Artifacts'}</h2>
</div> </div>
<div className="list-controls"> <div className="list-controls">
@@ -754,9 +702,9 @@ function PackagePage() {
<pre> <pre>
<code>curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/latest</code> <code>curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/latest</code>
</pre> </pre>
<p>Or with a specific tag:</p> <p>Or with a specific version:</p>
<pre> <pre>
<code>curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/v1.0.0</code> <code>curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/1.0.0</code>
</pre> </pre>
</div> </div>
@@ -765,7 +713,7 @@ function PackagePage() {
<DependencyGraph <DependencyGraph
projectName={projectName!} projectName={projectName!}
packageName={packageName!} packageName={packageName!}
tagName={selectedArtifact.tags.length > 0 ? selectedArtifact.tags[0] : `artifact:${selectedArtifact.id}`} tagName={getArtifactVersion(selectedArtifact) || `artifact:${selectedArtifact.id}`}
onClose={() => setShowGraph(false)} onClose={() => setShowGraph(false)}
/> />
)} )}
@@ -788,24 +736,12 @@ function PackagePage() {
</button> </button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="form-group">
<label htmlFor="upload-tag">Tag (optional)</label>
<input
id="upload-tag"
type="text"
value={uploadTag}
onChange={(e) => setUploadTag(e.target.value)}
placeholder="v1.0.0, latest, stable..."
/>
</div>
<DragDropUpload <DragDropUpload
projectName={projectName!} projectName={projectName!}
packageName={packageName!} packageName={packageName!}
tag={uploadTag || undefined}
onUploadComplete={(result) => { onUploadComplete={(result) => {
handleUploadComplete(result); handleUploadComplete(result);
setShowUploadModal(false); setShowUploadModal(false);
setUploadTag('');
}} }}
onUploadError={handleUploadError} onUploadError={handleUploadError}
/> />
@@ -814,74 +750,6 @@ function PackagePage() {
</div> </div>
)} )}
{/* Create/Update Tag Modal */}
{showCreateTagModal && (
<div className="modal-overlay" onClick={() => setShowCreateTagModal(false)}>
<div className="create-tag-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Create / Update Tag</h3>
<button
className="modal-close"
onClick={() => { setShowCreateTagModal(false); setCreateTagName(''); setCreateTagArtifactId(''); }}
title="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="modal-body">
<p className="modal-description">Point a tag at an artifact by its ID</p>
<form onSubmit={(e) => { handleCreateTag(e); setShowCreateTagModal(false); }}>
<div className="form-group">
<label htmlFor="modal-tag-name">Tag Name</label>
<input
id="modal-tag-name"
type="text"
value={createTagName}
onChange={(e) => setCreateTagName(e.target.value)}
placeholder="latest, stable, v1.0.0..."
disabled={createTagLoading}
/>
</div>
<div className="form-group">
<label htmlFor="modal-artifact-id">Artifact ID</label>
<input
id="modal-artifact-id"
type="text"
value={createTagArtifactId}
onChange={(e) => setCreateTagArtifactId(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
placeholder="SHA256 hash (64 hex characters)"
className="artifact-id-input"
disabled={createTagLoading}
/>
{createTagArtifactId.length > 0 && createTagArtifactId.length !== 64 && (
<p className="validation-hint">{createTagArtifactId.length}/64 characters</p>
)}
</div>
<div className="modal-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => { setShowCreateTagModal(false); setCreateTagName(''); setCreateTagArtifactId(''); }}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={createTagLoading || !createTagName.trim() || createTagArtifactId.length !== 64}
>
{createTagLoading ? 'Creating...' : 'Create Tag'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Ensure File Modal */} {/* Ensure File Modal */}
{showEnsureFile && ( {showEnsureFile && (
<div className="modal-overlay" onClick={() => setShowEnsureFile(false)}> <div className="modal-overlay" onClick={() => setShowEnsureFile(false)}>
@@ -943,15 +811,13 @@ function PackagePage() {
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="deps-modal-controls"> <div className="deps-modal-controls">
{selectedArtifact?.tags && selectedArtifact.tags.length > 0 && ( <button
<button className="btn btn-secondary btn-small"
className="btn btn-secondary btn-small" onClick={fetchEnsureFile}
onClick={fetchEnsureFile} disabled={ensureFileLoading}
disabled={ensureFileLoading} >
> View Ensure File
View Ensure File </button>
</button>
)}
<button <button
className="btn btn-secondary btn-small" className="btn btn-secondary btn-small"
onClick={() => { setShowDepsModal(false); setShowGraph(true); }} onClick={() => { setShowDepsModal(false); setShowGraph(true); }}
@@ -981,7 +847,7 @@ function PackagePage() {
{dep.project}/{dep.package} {dep.project}/{dep.package}
</Link> </Link>
<span className="dep-constraint"> <span className="dep-constraint">
@ {dep.version || dep.tag} @ {dep.version}
</span> </span>
<span className="dep-status dep-status--ok" title="Package exists"> <span className="dep-status dep-status--ok" title="Package exists">
&#10003; &#10003;

View File

@@ -349,9 +349,9 @@ function ProjectPage() {
render: (pkg: Package) => <Badge variant="default">{pkg.format}</Badge>, render: (pkg: Package) => <Badge variant="default">{pkg.format}</Badge>,
}] : []), }] : []),
...(!project?.is_system ? [{ ...(!project?.is_system ? [{
key: 'tag_count', key: 'version_count',
header: 'Tags', header: 'Versions',
render: (pkg: Package) => pkg.tag_count ?? '—', render: (pkg: Package) => pkg.version_count ?? '—',
}] : []), }] : []),
{ {
key: 'artifact_count', key: 'artifact_count',
@@ -365,10 +365,10 @@ function ProjectPage() {
pkg.total_size !== undefined && pkg.total_size > 0 ? formatBytes(pkg.total_size) : '—', pkg.total_size !== undefined && pkg.total_size > 0 ? formatBytes(pkg.total_size) : '—',
}, },
...(!project?.is_system ? [{ ...(!project?.is_system ? [{
key: 'latest_tag', key: 'latest_version',
header: 'Latest', header: 'Latest',
render: (pkg: Package) => render: (pkg: Package) =>
pkg.latest_tag ? <strong style={{ color: 'var(--accent-primary)' }}>{pkg.latest_tag}</strong> : '—', pkg.latest_version ? <strong style={{ color: 'var(--accent-primary)' }}>{pkg.latest_version}</strong> : '—',
}] : []), }] : []),
{ {
key: 'created_at', key: 'created_at',

View File

@@ -19,12 +19,6 @@ export interface Project {
team_name?: string | null; team_name?: string | null;
} }
export interface TagSummary {
name: string;
artifact_id: string;
created_at: string;
}
export interface Package { export interface Package {
id: string; id: string;
project_id: string; project_id: string;
@@ -35,12 +29,11 @@ export interface Package {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
// Aggregated fields (from PackageDetailResponse) // Aggregated fields (from PackageDetailResponse)
tag_count?: number;
artifact_count?: number; artifact_count?: number;
version_count?: number;
total_size?: number; total_size?: number;
latest_tag?: string | null;
latest_upload_at?: string | null; latest_upload_at?: string | null;
recent_tags?: TagSummary[]; latest_version?: string | null;
} }
export interface Artifact { export interface Artifact {
@@ -65,25 +58,6 @@ export interface PackageArtifact {
created_at: string; created_at: string;
created_by: string; created_by: string;
format_metadata?: Record<string, unknown> | null; format_metadata?: Record<string, unknown> | null;
tags: string[];
}
export interface Tag {
id: string;
package_id: string;
name: string;
artifact_id: string;
created_at: string;
created_by: string;
}
export interface TagDetail extends Tag {
artifact_size: number;
artifact_content_type: string | null;
artifact_original_name: string | null;
artifact_created_at: string;
artifact_format_metadata: Record<string, unknown> | null;
version: string | null;
} }
export interface PackageVersion { export interface PackageVersion {
@@ -98,20 +72,9 @@ export interface PackageVersion {
size?: number; size?: number;
content_type?: string | null; content_type?: string | null;
original_name?: string | null; original_name?: string | null;
tags?: string[];
} }
export interface ArtifactTagInfo { export interface ArtifactDetail extends Artifact {}
id: string;
name: string;
package_id: string;
package_name: string;
project_name: string;
}
export interface ArtifactDetail extends Artifact {
tags: ArtifactTagInfo[];
}
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
items: T[]; items: T[];
@@ -131,8 +94,6 @@ export interface ListParams {
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
} }
export interface TagListParams extends ListParams {}
export interface PackageListParams extends ListParams { export interface PackageListParams extends ListParams {
format?: string; format?: string;
platform?: string; platform?: string;
@@ -157,7 +118,6 @@ export interface UploadResponse {
size: number; size: number;
project: string; project: string;
package: string; package: string;
tag: string | null;
version: string | null; version: string | null;
version_source: string | null; version_source: string | null;
} }
@@ -180,9 +140,8 @@ export interface SearchResultPackage {
} }
export interface SearchResultArtifact { export interface SearchResultArtifact {
tag_id: string;
tag_name: string;
artifact_id: string; artifact_id: string;
version: string | null;
package_id: string; package_id: string;
package_name: string; package_name: string;
project_name: string; project_name: string;
@@ -405,8 +364,7 @@ export interface Dependency {
artifact_id: string; artifact_id: string;
project: string; project: string;
package: string; package: string;
version: string | null; version: string;
tag: string | null;
created_at: string; created_at: string;
} }
@@ -420,7 +378,6 @@ export interface DependentInfo {
project: string; project: string;
package: string; package: string;
version: string | null; version: string | null;
constraint_type: 'version' | 'tag';
constraint_value: string; constraint_value: string;
} }
@@ -443,7 +400,6 @@ export interface ResolvedArtifact {
project: string; project: string;
package: string; package: string;
version: string | null; version: string | null;
tag: string | null;
size: number; size: number;
download_url: string; download_url: string;
} }

View File

@@ -0,0 +1,33 @@
-- Migration: Remove tag system
-- Date: 2026-02-03
-- Description: Remove tags table and related objects, keeping only versions for artifact references
-- Drop triggers on tags table
DROP TRIGGER IF EXISTS tags_ref_count_insert_trigger ON tags;
DROP TRIGGER IF EXISTS tags_ref_count_delete_trigger ON tags;
DROP TRIGGER IF EXISTS tags_ref_count_update_trigger ON tags;
DROP TRIGGER IF EXISTS tags_updated_at_trigger ON tags;
DROP TRIGGER IF EXISTS tag_changes_trigger ON tags;
-- Drop the tag change tracking function
DROP FUNCTION IF EXISTS track_tag_changes();
-- Remove tag_constraint from artifact_dependencies
-- First drop the constraint that requires either version or tag
ALTER TABLE artifact_dependencies DROP CONSTRAINT IF EXISTS check_constraint_type;
-- Remove the tag_constraint column
ALTER TABLE artifact_dependencies DROP COLUMN IF EXISTS tag_constraint;
-- Make version_constraint NOT NULL (now the only option)
UPDATE artifact_dependencies SET version_constraint = '*' WHERE version_constraint IS NULL;
ALTER TABLE artifact_dependencies ALTER COLUMN version_constraint SET NOT NULL;
-- Drop tag_history table first (depends on tags)
DROP TABLE IF EXISTS tag_history;
-- Drop tags table
DROP TABLE IF EXISTS tags;
-- Rename uploads.tag_name to uploads.version (historical data field)
ALTER TABLE uploads RENAME COLUMN tag_name TO version;