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

@@ -1,14 +1,11 @@
import {
Project,
Package,
Tag,
TagDetail,
ArtifactDetail,
PackageArtifact,
UploadResponse,
PaginatedResponse,
ListParams,
TagListParams,
PackageListParams,
ArtifactListParams,
ProjectListParams,
@@ -240,32 +237,6 @@ export async function createPackage(projectName: string, data: { name: string; d
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
export async function getArtifact(artifactId: string): Promise<ArtifactDetail> {
const response = await fetch(`${API_BASE}/artifact/${artifactId}`);
@@ -287,14 +258,10 @@ export async function uploadArtifact(
projectName: string,
packageName: string,
file: File,
tag?: string,
version?: string
): Promise<UploadResponse> {
const formData = new FormData();
formData.append('file', file);
if (tag) {
formData.append('tag', tag);
}
if (version) {
formData.append('version', version);
}

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ interface StoredUploadState {
completedParts: number[];
project: string;
package: string;
tag?: string;
createdAt: number;
}
@@ -87,7 +86,6 @@ export interface DragDropUploadProps {
maxFileSize?: number; // in bytes
maxConcurrentUploads?: number;
maxRetries?: number;
tag?: string;
className?: string;
disabled?: boolean;
disabledReason?: string;
@@ -230,7 +228,6 @@ export function DragDropUpload({
maxFileSize,
maxConcurrentUploads = 3,
maxRetries = 3,
tag,
className = '',
disabled = false,
disabledReason,
@@ -368,7 +365,6 @@ export function DragDropUpload({
expected_hash: fileHash,
filename: item.file.name,
size: item.file.size,
tag: tag || undefined,
}),
}
);
@@ -392,7 +388,6 @@ export function DragDropUpload({
completedParts: [],
project: projectName,
package: packageName,
tag: tag || undefined,
createdAt: Date.now(),
});
@@ -438,7 +433,6 @@ export function DragDropUpload({
completedParts,
project: projectName,
package: packageName,
tag: tag || undefined,
createdAt: Date.now(),
});
@@ -459,7 +453,7 @@ export function DragDropUpload({
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag: tag || undefined }),
body: JSON.stringify({}),
}
);
@@ -475,18 +469,15 @@ export function DragDropUpload({
size: completeData.size,
deduplicated: false,
};
}, [projectName, packageName, tag, isOnline]);
}, [projectName, packageName, isOnline]);
const uploadFileSimple = useCallback((item: UploadItem): Promise<UploadResult> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhrMapRef.current.set(item.id, xhr);
const formData = new FormData();
formData.append('file', item.file);
if (tag) {
formData.append('tag', tag);
}
let lastLoaded = 0;
let lastTime = Date.now();
@@ -549,13 +540,13 @@ export function DragDropUpload({
xhr.timeout = 300000;
xhr.send(formData);
setUploadQueue(prev => prev.map(u =>
u.id === item.id
setUploadQueue(prev => prev.map(u =>
u.id === item.id
? { ...u, status: 'uploading' as UploadStatus, startTime: Date.now() }
: u
));
});
}, [projectName, packageName, tag]);
}, [projectName, packageName]);
const uploadFile = useCallback((item: UploadItem): Promise<UploadResult> => {
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;
return (
<button
key={artifact.tag_id}
key={artifact.artifact_id}
className={`global-search__result ${selectedIndex === flatIndex ? 'selected' : ''}`}
onClick={() => navigateToResult({ type: 'artifact', item: artifact })}
onMouseEnter={() => setSelectedIndex(flatIndex)}
@@ -243,7 +243,7 @@ export function GlobalSearch() {
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
<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">
{artifact.project_name} / {artifact.package_name}
</span>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate, useLocation, Link } from 'react-router-dom';
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 { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
@@ -61,16 +61,11 @@ function PackagePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false);
const [uploadTag, setUploadTag] = useState('');
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
const [createTagName, setCreateTagName] = useState('');
const [createTagArtifactId, setCreateTagArtifactId] = useState('');
const [createTagLoading, setCreateTagLoading] = useState(false);
// UI state
const [showUploadModal, setShowUploadModal] = useState(false);
const [showCreateTagModal, setShowCreateTagModal] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
@@ -226,15 +221,15 @@ function PackagePage() {
}
}, [projectName, packageName, loading, fetchReverseDeps]);
// Fetch ensure file for a specific tag
const fetchEnsureFileForTag = useCallback(async (tagName: string) => {
// Fetch ensure file for a specific version or artifact
const fetchEnsureFileForRef = useCallback(async (ref: string) => {
if (!projectName || !packageName) return;
setEnsureFileTagName(tagName);
setEnsureFileTagName(ref);
setEnsureFileLoading(true);
setEnsureFileError(null);
try {
const content = await getEnsureFile(projectName, packageName, tagName);
const content = await getEnsureFile(projectName, packageName, ref);
setEnsureFileContent(content);
setShowEnsureFile(true);
} catch (err) {
@@ -245,11 +240,13 @@ function PackagePage() {
}
}, [projectName, packageName]);
// Fetch ensure file for selected artifact (if it has tags)
// Fetch ensure file for selected artifact
const fetchEnsureFile = useCallback(async () => {
if (!selectedArtifact || selectedArtifact.tags.length === 0) return;
fetchEnsureFileForTag(selectedArtifact.tags[0]);
}, [selectedArtifact, fetchEnsureFileForTag]);
if (!selectedArtifact) return;
const version = getArtifactVersion(selectedArtifact);
const ref = version || `artifact:${selectedArtifact.id}`;
fetchEnsureFileForRef(ref);
}, [selectedArtifact, fetchEnsureFileForRef]);
// Keyboard navigation - go back with backspace
useEffect(() => {
@@ -265,11 +262,10 @@ function PackagePage() {
const handleUploadComplete = useCallback((results: UploadResult[]) => {
const count = results.length;
const message = count === 1
const message = count === 1
? `Uploaded successfully! Artifact ID: ${results[0].artifact_id}`
: `${count} files uploaded successfully!`;
setUploadSuccess(message);
setUploadTag('');
loadData();
// Auto-dismiss success message after 5 seconds
@@ -280,30 +276,6 @@ function PackagePage() {
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) => {
updateParams({ search: value, page: '1' });
};
@@ -346,9 +318,10 @@ function PackagePage() {
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 => {
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
@@ -365,7 +338,7 @@ function PackagePage() {
onClick={() => handleArtifactSelect(a)}
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>
),
},
@@ -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
{
key: 'tags',
header: 'Tag',
// tags is not a sortable DB field
key: 'version',
header: 'Version',
// version is from format_metadata, not a sortable DB field
render: (a: PackageArtifact) => (
<strong
className={`tag-name-link ${selectedArtifact?.id === a.id ? 'selected' : ''}`}
onClick={() => handleArtifactSelect(a)}
style={{ cursor: 'pointer' }}
>
{a.tags.length > 0 ? a.tags[0] : '—'}
<span className="version-badge">{getArtifactVersion(a) || a.id.slice(0, 12)}</span>
</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',
header: 'Filename',
@@ -536,16 +501,9 @@ function PackagePage() {
<button onClick={() => { navigator.clipboard.writeText(a.id); setOpenMenuId(null); setMenuPosition(null); }}>
Copy Artifact ID
</button>
{a.tags.length > 0 && (
<button onClick={() => { fetchEnsureFileForTag(a.tags[0]); setOpenMenuId(null); setMenuPosition(null); }}>
View Ensure File
</button>
)}
{canWrite && !isSystemProject && (
<button onClick={() => { setCreateTagArtifactId(a.id); setShowCreateTagModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
Create/Update Tag
</button>
)}
<button onClick={() => { const version = getArtifactVersion(a); const ref = version || `artifact:${a.id}`; fetchEnsureFileForRef(ref); setOpenMenuId(null); setMenuPosition(null); }}>
View Ensure File
</button>
<button onClick={() => { handleArtifactSelect(a); setShowDepsModal(true); setOpenMenuId(null); setMenuPosition(null); }}>
View Dependencies
</button>
@@ -623,13 +581,8 @@ function PackagePage() {
</>
)}
</div>
{pkg && (pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
{pkg && pkg.artifact_count !== undefined && (
<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 && (
<span className="stat-item">
<strong>{pkg.artifact_count}</strong> {isSystemProject ? 'versions' : 'artifacts'}
@@ -640,11 +593,6 @@ function PackagePage() {
<strong>{formatBytes(pkg.total_size)}</strong> total
</span>
)}
{!isSystemProject && pkg.latest_tag && (
<span className="stat-item">
Latest: <strong className="accent">{pkg.latest_tag}</strong>
</span>
)}
</div>
)}
</div>
@@ -655,7 +603,7 @@ function PackagePage() {
<div className="section-header">
<h2>{isSystemProject ? 'Versions' : 'Tags / Versions'}</h2>
<h2>{isSystemProject ? 'Versions' : 'Artifacts'}</h2>
</div>
<div className="list-controls">
@@ -754,9 +702,9 @@ function PackagePage() {
<pre>
<code>curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/latest</code>
</pre>
<p>Or with a specific tag:</p>
<p>Or with a specific version:</p>
<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>
</div>
@@ -765,7 +713,7 @@ function PackagePage() {
<DependencyGraph
projectName={projectName!}
packageName={packageName!}
tagName={selectedArtifact.tags.length > 0 ? selectedArtifact.tags[0] : `artifact:${selectedArtifact.id}`}
tagName={getArtifactVersion(selectedArtifact) || `artifact:${selectedArtifact.id}`}
onClose={() => setShowGraph(false)}
/>
)}
@@ -788,24 +736,12 @@ function PackagePage() {
</button>
</div>
<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
projectName={projectName!}
packageName={packageName!}
tag={uploadTag || undefined}
onUploadComplete={(result) => {
handleUploadComplete(result);
setShowUploadModal(false);
setUploadTag('');
}}
onUploadError={handleUploadError}
/>
@@ -814,74 +750,6 @@ function PackagePage() {
</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 */}
{showEnsureFile && (
<div className="modal-overlay" onClick={() => setShowEnsureFile(false)}>
@@ -943,15 +811,13 @@ function PackagePage() {
</div>
<div className="modal-body">
<div className="deps-modal-controls">
{selectedArtifact?.tags && selectedArtifact.tags.length > 0 && (
<button
className="btn btn-secondary btn-small"
onClick={fetchEnsureFile}
disabled={ensureFileLoading}
>
View Ensure File
</button>
)}
<button
className="btn btn-secondary btn-small"
onClick={fetchEnsureFile}
disabled={ensureFileLoading}
>
View Ensure File
</button>
<button
className="btn btn-secondary btn-small"
onClick={() => { setShowDepsModal(false); setShowGraph(true); }}
@@ -981,7 +847,7 @@ function PackagePage() {
{dep.project}/{dep.package}
</Link>
<span className="dep-constraint">
@ {dep.version || dep.tag}
@ {dep.version}
</span>
<span className="dep-status dep-status--ok" title="Package exists">
&#10003;

View File

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

View File

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