A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
73
fork

Configure Feed

Select the types of activity you want to include in your feed.

Annotations Table Refactoring#

Overview#

Refactor manifest annotations from individual columns (title, description, source_url, etc.) to a normalized key-value table. This enables flexible annotation storage without schema changes for new OCI annotations.

Motivation#

Current Problems:

  • Each new annotation (e.g., org.opencontainers.image.version) requires schema change
  • Many NULL columns in manifests table
  • Rigid schema doesn't match OCI's flexible annotation model

Benefits:

  • ✅ Add any annotation without code/schema changes
  • ✅ Normalized database design
  • ✅ Easy to query "all repos with annotation X"
  • ✅ Simple queries (no joins needed for repository pages)

Database Schema Changes#

1. New Table: repository_annotations#

CREATE TABLE IF NOT EXISTS repository_annotations (
    did TEXT NOT NULL,
    repository TEXT NOT NULL,
    key TEXT NOT NULL,
    value TEXT NOT NULL,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY(did, repository, key),
    FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository);
CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key);

Key Design Decisions:

  • Primary key: (did, repository, key) - one value per annotation per repository
  • No manifest_id foreign key - annotations are repository-level, not manifest-level
  • updated_at - track when annotation was last updated (from most recent manifest)
  • Stored at repository level because that's where they're displayed

2. Drop Columns from manifests Table#

Remove these columns (migration will preserve data by copying to annotations table):

  • title
  • description
  • source_url
  • documentation_url
  • licenses
  • icon_url
  • readme_url
  • version

Keep only core manifest metadata:

  • id, did, repository, digest
  • hold_endpoint, schema_version, media_type
  • config_digest, config_size
  • created_at

Migration Strategy#

There is no need to migrate data to this new table via sql. on startup, backfill will re-populate the new table with existing annotations.

Code Changes#

1. Database Helper Functions#

New file: pkg/appview/db/annotations.go

package db

import (
    "database/sql"
    "time"
)

// GetRepositoryAnnotations retrieves all annotations for a repository
func GetRepositoryAnnotations(db *sql.DB, did, repository string) (map[string]string, error) {
    rows, err := db.Query(`
        SELECT key, value
        FROM repository_annotations
        WHERE did = ? AND repository = ?
    `, did, repository)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    annotations := make(map[string]string)
    for rows.Next() {
        var key, value string
        if err := rows.Scan(&key, &value); err != nil {
            return nil, err
        }
        annotations[key] = value
    }

    return annotations, rows.Err()
}

// UpsertRepositoryAnnotations replaces all annotations for a repository
// Only called when manifest has at least one non-empty annotation
func UpsertRepositoryAnnotations(db *sql.DB, did, repository string, annotations map[string]string) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // Delete existing annotations
    _, err = tx.Exec(`
        DELETE FROM repository_annotations
        WHERE did = ? AND repository = ?
    `, did, repository)
    if err != nil {
        return err
    }

    // Insert new annotations
    stmt, err := tx.Prepare(`
        INSERT INTO repository_annotations (did, repository, key, value, updated_at)
        VALUES (?, ?, ?, ?, ?)
    `)
    if err != nil {
        return err
    }
    defer stmt.Close()

    now := time.Now()
    for key, value := range annotations {
        _, err = stmt.Exec(did, repository, key, value, now)
        if err != nil {
            return err
        }
    }

    return tx.Commit()
}

// DeleteRepositoryAnnotations removes all annotations for a repository
func DeleteRepositoryAnnotations(db *sql.DB, did, repository string) error {
    _, err := db.Exec(`
        DELETE FROM repository_annotations
        WHERE did = ? AND repository = ?
    `, did, repository)
    return err
}

2. Update Backfill Worker#

File: pkg/appview/jetstream/backfill.go

In processManifestRecord() function, after extracting annotations:

// Extract OCI annotations from manifest
var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string
if manifestRecord.Annotations != nil {
    title = manifestRecord.Annotations["org.opencontainers.image.title"]
    description = manifestRecord.Annotations["org.opencontainers.image.description"]
    sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"]
    documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"]
    licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"]
    iconURL = manifestRecord.Annotations["io.atcr.icon"]
    readmeURL = manifestRecord.Annotations["io.atcr.readme"]
}

// Prepare manifest for insertion (WITHOUT annotation fields)
manifest := &db.Manifest{
    DID:           did,
    Repository:    manifestRecord.Repository,
    Digest:        manifestRecord.Digest,
    MediaType:     manifestRecord.MediaType,
    SchemaVersion: manifestRecord.SchemaVersion,
    HoldEndpoint:  manifestRecord.HoldEndpoint,
    CreatedAt:     manifestRecord.CreatedAt,
    // NO annotation fields
}

// Set config fields only for image manifests (not manifest lists)
if !isManifestList && manifestRecord.Config != nil {
    manifest.ConfigDigest = manifestRecord.Config.Digest
    manifest.ConfigSize = manifestRecord.Config.Size
}

// Insert manifest
manifestID, err := db.InsertManifest(b.db, manifest)
if err != nil {
    return fmt.Errorf("failed to insert manifest: %w", err)
}

// Update repository annotations ONLY if manifest has at least one non-empty annotation
if manifestRecord.Annotations != nil {
    hasData := false
    for _, value := range manifestRecord.Annotations {
        if value != "" {
            hasData = true
            break
        }
    }

    if hasData {
        // Replace all annotations for this repository
        err = db.UpsertRepositoryAnnotations(b.db, did, manifestRecord.Repository, manifestRecord.Annotations)
        if err != nil {
            return fmt.Errorf("failed to upsert annotations: %w", err)
        }
    }
}

3. Update Jetstream Worker#

File: pkg/appview/jetstream/worker.go

Same changes as backfill - in processManifestCommit() function:

// Extract OCI annotations from manifest
var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string
if manifestRecord.Annotations != nil {
    title = manifestRecord.Annotations["org.opencontainers.image.title"]
    description = manifestRecord.Annotations["org.opencontainers.image.description"]
    sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"]
    documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"]
    licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"]
    iconURL = manifestRecord.Annotations["io.atcr.icon"]
    readmeURL = manifestRecord.Annotations["io.atcr.readme"]
}

// Prepare manifest for insertion (WITHOUT annotation fields)
manifest := &db.Manifest{
    DID:              commit.DID,
    Repository:       manifestRecord.Repository,
    Digest:           manifestRecord.Digest,
    MediaType:        manifestRecord.MediaType,
    SchemaVersion:    manifestRecord.SchemaVersion,
    HoldEndpoint:     manifestRecord.HoldEndpoint,
    CreatedAt:        manifestRecord.CreatedAt,
    // NO annotation fields
}

// Set config fields only for image manifests (not manifest lists)
if !isManifestList && manifestRecord.Config != nil {
    manifest.ConfigDigest = manifestRecord.Config.Digest
    manifest.ConfigSize = manifestRecord.Config.Size
}

// Insert manifest
manifestID, err := db.InsertManifest(w.db, manifest)
if err != nil {
    return fmt.Errorf("failed to insert manifest: %w", err)
}

// Update repository annotations ONLY if manifest has at least one non-empty annotation
if manifestRecord.Annotations != nil {
    hasData := false
    for _, value := range manifestRecord.Annotations {
        if value != "" {
            hasData = true
            break
        }
    }

    if hasData {
        // Replace all annotations for this repository
        err = db.UpsertRepositoryAnnotations(w.db, commit.DID, manifestRecord.Repository, manifestRecord.Annotations)
        if err != nil {
            return fmt.Errorf("failed to upsert annotations: %w", err)
        }
    }
}

4. Update Database Queries#

File: pkg/appview/db/queries.go

Replace GetRepositoryMetadata() function:

// GetRepositoryMetadata retrieves metadata for a repository from annotations table
func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, version string, err error) {
    annotations, err := GetRepositoryAnnotations(db, did, repository)
    if err != nil {
        return "", "", "", "", "", "", "", "", err
    }

    title = annotations["org.opencontainers.image.title"]
    description = annotations["org.opencontainers.image.description"]
    sourceURL = annotations["org.opencontainers.image.source"]
    documentationURL = annotations["org.opencontainers.image.documentation"]
    licenses = annotations["org.opencontainers.image.licenses"]
    iconURL = annotations["io.atcr.icon"]
    readmeURL = annotations["io.atcr.readme"]
    version = annotations["org.opencontainers.image.version"]

    return title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, version, nil
}

Update InsertManifest() to remove annotation columns:

func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) {
    _, err := db.Exec(`
        INSERT INTO manifests
        (did, repository, digest, hold_endpoint, schema_version, media_type,
         config_digest, config_size, created_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(did, repository, digest) DO UPDATE SET
            hold_endpoint = excluded.hold_endpoint,
            schema_version = excluded.schema_version,
            media_type = excluded.media_type,
            config_digest = excluded.config_digest,
            config_size = excluded.config_size
    `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
        manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
        manifest.ConfigSize, manifest.CreatedAt)

    if err != nil {
        return 0, err
    }

    // Query for the ID (works for both insert and update)
    var id int64
    err = db.QueryRow(`
        SELECT id FROM manifests
        WHERE did = ? AND repository = ? AND digest = ?
    `, manifest.DID, manifest.Repository, manifest.Digest).Scan(&id)

    if err != nil {
        return 0, fmt.Errorf("failed to get manifest ID after upsert: %w", err)
    }

    return id, nil
}

Similar updates needed for:

  • GetUserRepositories() - fetch annotations separately and populate Repository struct
  • GetRecentPushes() - join with annotations or fetch separately
  • SearchPushes() - can now search annotations table directly

5. Update Models#

File: pkg/appview/db/models.go

Remove annotation fields from Manifest struct:

type Manifest struct {
    ID                   int64
    DID                  string
    Repository           string
    Digest               string
    HoldEndpoint         string
    SchemaVersion        int
    MediaType            string
    ConfigDigest         string
    ConfigSize           int64
    CreatedAt            time.Time
    // Removed: Title, Description, SourceURL, DocumentationURL, Licenses, IconURL, ReadmeURL
}

Keep annotation fields on Repository struct (populated from annotations table):

type Repository struct {
    Name             string
    TagCount         int
    ManifestCount    int
    LastPush         time.Time
    Tags             []Tag
    Manifests        []Manifest
    Title            string
    Description      string
    SourceURL        string
    DocumentationURL string
    Licenses         string
    IconURL          string
    ReadmeURL        string
    Version          string  // NEW
}

6. Update Schema.sql#

File: pkg/appview/db/schema.sql

Add new table:

CREATE TABLE IF NOT EXISTS repository_annotations (
    did TEXT NOT NULL,
    repository TEXT NOT NULL,
    key TEXT NOT NULL,
    value TEXT NOT NULL,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY(did, repository, key),
    FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository);
CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key);

Update manifests table (remove annotation columns):

CREATE TABLE IF NOT EXISTS manifests (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    did TEXT NOT NULL,
    repository TEXT NOT NULL,
    digest TEXT NOT NULL,
    hold_endpoint TEXT NOT NULL,
    schema_version INTEGER NOT NULL,
    media_type TEXT NOT NULL,
    config_digest TEXT,
    config_size INTEGER,
    created_at TIMESTAMP NOT NULL,
    UNIQUE(did, repository, digest),
    FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
);

Update Logic Summary#

Key Decision: Only update annotations when manifest has data

For each manifest processed (backfill or jetstream):
  1. Parse manifest.Annotations map
  2. Check if ANY annotation has non-empty value
  3. IF hasData:
       DELETE all annotations for (did, repository)
       INSERT all annotations from manifest (including empty ones)
     ELSE:
       SKIP (don't touch existing annotations)

Why this works:

  • Manifest lists have no annotations or all empty → skip, preserve existing
  • Platform manifests have real data → replace everything
  • Removing annotation from Dockerfile → it's gone (not in new INSERT)
  • Can't accidentally clear data (need at least one non-empty value)

UI/Template Changes#

Handler Updates#

File: pkg/appview/handlers/repository.go

Update the handler to include version:

// Fetch repository metadata from annotations
title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, version, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository)
if err != nil {
    log.Printf("Failed to fetch repository metadata: %v", err)
    // Continue without metadata on error
} else {
    repo.Title = title
    repo.Description = description
    repo.SourceURL = sourceURL
    repo.DocumentationURL = documentationURL
    repo.Licenses = licenses
    repo.IconURL = iconURL
    repo.ReadmeURL = readmeURL
    repo.Version = version  // NEW
}

Template Updates#

File: pkg/appview/templates/pages/repository.html

Update the metadata section condition to include version:

<!-- Metadata Section -->
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}
<div class="repo-metadata">
    <!-- Version Badge (if present) -->
    {{ if .Repository.Version }}
        <span class="metadata-badge version-badge" title="Version">
            {{ .Repository.Version }}
        </span>
    {{ end }}

    <!-- License Badges -->
    {{ if .Repository.Licenses }}
        {{ range parseLicenses .Repository.Licenses }}
            {{ if .IsValid }}
                <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">
                    {{ .SPDXID }}
                </a>
            {{ else }}
                <span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">
                    {{ .Name }}
                </span>
            {{ end }}
        {{ end }}
    {{ end }}

    <!-- Source Link -->
    {{ if .Repository.SourceURL }}
        <a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link">
            Source
        </a>
    {{ end }}

    <!-- Documentation Link -->
    {{ if .Repository.DocumentationURL }}
        <a href="{{ .Repository.DocumentationURL }}" target="_blank" class="metadata-link">
            Documentation
        </a>
    {{ end }}
</div>
{{ end }}

CSS Updates#

File: pkg/appview/static/css/style.css

Add styling for version badge (different color from license badge):

.version-badge {
    background: #0969da;  /* GitHub blue */
    color: white;
    padding: 0.25rem 0.5rem;
    border-radius: 0.25rem;
    font-size: 0.875rem;
    font-weight: 500;
    display: inline-block;
}

Data Flow Summary#

Before refactor:

DB columns → GetRepositoryMetadata() → Handler assigns to Repository struct → Template displays

After refactor:

annotations table → GetRepositoryAnnotations() → GetRepositoryMetadata() extracts known fields →
Handler assigns to Repository struct → Template displays (same as before)

Key point: Templates still access .Repository.Title, .Repository.Version, etc. - the source just changed from DB columns to annotations table. The abstraction layer hides this complexity.

Benefits Recap#

  1. Flexible: Support any OCI annotation without code changes
  2. Clean: No NULL columns in manifests table
  3. Simple queries: SELECT * FROM repository_annotations WHERE did=? AND repo=?
  4. Safe updates: Only update when manifest has data
  5. Natural deletion: Remove annotation from Dockerfile → it's deleted on next push
  6. Extensible: Future features (annotation search, filtering) are trivial

Testing Checklist#

After migration:

  • Verify existing repositories show annotations correctly
  • Push new manifest with annotations → updates correctly
  • Push manifest list → doesn't clear annotations
  • Remove annotation from Dockerfile and push → annotation deleted
  • Backfill re-run → annotations repopulated correctly
  • Search still works (if implemented)