···1010 nixpkgs:
1111 - gcc
1212 - go
1313+ - curl
13141415steps:
1616+ - name: Download and Generate
1717+ environment:
1818+ CGO_ENABLED: 1
1919+ command: |
2020+ go mod download
2121+ go generate ./...
2222+1523 - name: Run Tests
1624 environment:
1725 CGO_ENABLED: 1
+21-17
pkg/appview/db/models.go
···13131414// Manifest represents an OCI manifest stored in the cache
1515type Manifest struct {
1616- ID int64
1717- DID string
1818- Repository string
1919- Digest string
2020- HoldEndpoint string
2121- SchemaVersion int
2222- MediaType string
2323- ConfigDigest string
2424- ConfigSize int64
2525- CreatedAt time.Time
2626- Title string
2727- Description string
2828- SourceURL string
2929- DocumentationURL string
3030- Licenses string
3131- IconURL string
3232- ReadmeURL string
1616+ ID int64
1717+ DID string
1818+ Repository string
1919+ Digest string
2020+ HoldEndpoint string
2121+ SchemaVersion int
2222+ MediaType string
2323+ ConfigDigest string
2424+ ConfigSize int64
2525+ CreatedAt time.Time
2626+ Title string
2727+ Description string
2828+ SourceURL string
2929+ DocumentationURL string
3030+ Licenses string
3131+ IconURL string
3232+ ReadmeURL string
3333+ PlatformOS string // UNUSED: Reserved for future use, always NULL
3434+ PlatformArchitecture string // UNUSED: Reserved for future use, always NULL
3535+ PlatformVariant string // UNUSED: Reserved for future use, always NULL
3636+ PlatformOSVersion string // UNUSED: Reserved for future use, always NULL
3337}
34383539// Layer represents a layer in a manifest
+66-5
pkg/appview/db/queries.go
···534534535535// InsertManifest inserts or updates a manifest record
536536// Uses UPSERT to update labels/annotations if manifest already exists
537537+// Returns the manifest ID (works correctly for both insert and update)
537538func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) {
538538- result, err := db.Exec(`
539539+ _, err := db.Exec(`
539540 INSERT INTO manifests
540541 (did, repository, digest, hold_endpoint, schema_version, media_type,
541542 config_digest, config_size, created_at,
···564565 return 0, err
565566 }
566567567567- return result.LastInsertId()
568568+ // Query for the ID (works for both insert and update)
569569+ var id int64
570570+ err = db.QueryRow(`
571571+ SELECT id FROM manifests
572572+ WHERE did = ? AND repository = ? AND digest = ?
573573+ `, manifest.DID, manifest.Repository, manifest.Digest).Scan(&id)
574574+575575+ if err != nil {
576576+ return 0, fmt.Errorf("failed to get manifest ID after upsert: %w", err)
577577+ }
578578+579579+ return id, nil
568580}
569581570582// InsertLayer inserts a new layer record
···597609}
598610599611// GetTagsWithPlatforms returns all tags for a repository with platform information
600600-// For multi-arch tags, includes all platforms from manifest_references
601601-// For single-arch tags, includes the platform info
612612+// Only multi-arch tags (manifest lists) have platform info in manifest_references
613613+// Single-arch tags will have empty Platforms slice (platform is obvious for single-arch)
602614func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatforms, error) {
603615 rows, err := db.Query(`
604616 SELECT
···648660 tagOrder = append(tagOrder, tagKey)
649661 }
650662651651- // Add platform info if present
663663+ // Add platform info if present (only for multi-arch manifest lists)
652664 if platformOS != "" || platformArch != "" {
653665 tagMap[tagKey].Platforms = append(tagMap[tagKey].Platforms, PlatformInfo{
654666 OS: platformOS,
···906918 m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list")
907919908920 manifests = append(manifests, m)
921921+ }
922922+923923+ // Fetch platform details for multi-arch manifests AFTER closing the main query
924924+ for i := range manifests {
925925+ if manifests[i].IsManifestList {
926926+ platformRows, err := db.Query(`
927927+ SELECT
928928+ mr.platform_os,
929929+ mr.platform_architecture,
930930+ mr.platform_variant,
931931+ mr.platform_os_version
932932+ FROM manifest_references mr
933933+ WHERE mr.manifest_id = ?
934934+ ORDER BY mr.reference_index
935935+ `, manifests[i].ID)
936936+937937+ if err != nil {
938938+ return nil, err
939939+ }
940940+941941+ manifests[i].Platforms = []PlatformInfo{}
942942+ for platformRows.Next() {
943943+ var p PlatformInfo
944944+ var os, arch, variant, osVersion sql.NullString
945945+946946+ if err := platformRows.Scan(&os, &arch, &variant, &osVersion); err != nil {
947947+ platformRows.Close()
948948+ return nil, err
949949+ }
950950+951951+ if os.Valid {
952952+ p.OS = os.String
953953+ }
954954+ if arch.Valid {
955955+ p.Architecture = arch.String
956956+ }
957957+ if variant.Valid {
958958+ p.Variant = variant.String
959959+ }
960960+ if osVersion.Valid {
961961+ p.OSVersion = osVersion.String
962962+ }
963963+964964+ manifests[i].Platforms = append(manifests[i].Platforms, p)
965965+ }
966966+ platformRows.Close()
967967+968968+ manifests[i].PlatformCount = len(manifests[i].Platforms)
969969+ }
909970 }
910971911972 return manifests, nil
+171
pkg/appview/db/tag_delete_test.go
···11+package db
22+33+import (
44+ "testing"
55+ "time"
66+77+ "atcr.io/pkg/atproto"
88+)
99+1010+// TestTagDeleteRoundTrip tests the full flow of creating and deleting tags
1111+// This simulates what Jetstream does: encode repo/tag to rkey, then decode and delete
1212+func TestTagDeleteRoundTrip(t *testing.T) {
1313+ // Create in-memory test database
1414+ db, err := InitDB(":memory:")
1515+ if err != nil {
1616+ t.Fatalf("Failed to init database: %v", err)
1717+ }
1818+ defer db.Close()
1919+2020+ // Insert test user
2121+ testUser := &User{
2222+ DID: "did:plc:test123",
2323+ Handle: "testuser.bsky.social",
2424+ PDSEndpoint: "https://test.pds.example.com",
2525+ Avatar: "",
2626+ LastSeen: time.Now(),
2727+ }
2828+ if err := UpsertUser(db, testUser); err != nil {
2929+ t.Fatalf("Failed to insert user: %v", err)
3030+ }
3131+3232+ // Test cases covering different tag patterns
3333+ testCases := []struct {
3434+ name string
3535+ repository string
3636+ tag string
3737+ expectRoundTrip bool // Some cases can't round-trip due to encoding limitations
3838+ }{
3939+ {
4040+ name: "simple tag",
4141+ repository: "test-image",
4242+ tag: "latest",
4343+ expectRoundTrip: true,
4444+ },
4545+ {
4646+ name: "tag with hyphen (like latest-amd64)",
4747+ repository: "test-image",
4848+ tag: "latest-amd64",
4949+ expectRoundTrip: true,
5050+ },
5151+ {
5252+ name: "tag with hyphen (like latest-arm64)",
5353+ repository: "test-image",
5454+ tag: "latest-arm64",
5555+ expectRoundTrip: true,
5656+ },
5757+ {
5858+ name: "tag with version",
5959+ repository: "myapp",
6060+ tag: "v1.0.0",
6161+ expectRoundTrip: true,
6262+ },
6363+ {
6464+ name: "repository with underscore",
6565+ repository: "my_repo",
6666+ tag: "latest",
6767+ expectRoundTrip: true,
6868+ },
6969+ {
7070+ name: "both with underscores (known limitation)",
7171+ repository: "my_repo",
7272+ tag: "my_tag",
7373+ expectRoundTrip: false, // Cannot round-trip: underscore is the separator
7474+ },
7575+ {
7676+ name: "repository with multiple hyphens",
7777+ repository: "multi-part-name",
7878+ tag: "test-build",
7979+ expectRoundTrip: true,
8080+ },
8181+ }
8282+8383+ for _, tc := range testCases {
8484+ t.Run(tc.name, func(t *testing.T) {
8585+ // Step 1: Insert tag using UpsertTag (simulates tag creation)
8686+ tag := &Tag{
8787+ DID: testUser.DID,
8888+ Repository: tc.repository,
8989+ Tag: tc.tag,
9090+ Digest: "sha256:abc123def456",
9191+ CreatedAt: time.Now(),
9292+ }
9393+ if err := UpsertTag(db, tag); err != nil {
9494+ t.Fatalf("Failed to upsert tag: %v", err)
9595+ }
9696+9797+ // Step 2: Verify tag was created
9898+ var count int
9999+ err := db.QueryRow(`
100100+ SELECT COUNT(*) FROM tags
101101+ WHERE did = ? AND repository = ? AND tag = ?
102102+ `, testUser.DID, tc.repository, tc.tag).Scan(&count)
103103+ if err != nil {
104104+ t.Fatalf("Failed to count tags: %v", err)
105105+ }
106106+ if count != 1 {
107107+ t.Fatalf("Expected 1 tag after insert, got %d", count)
108108+ }
109109+110110+ // Step 3: Simulate Jetstream delete flow
111111+ // This is what happens in processTag when operation == "delete"
112112+ // The rkey comes from ATProto, we need to parse it back to repo/tag
113113+114114+ // First, let's see what the rkey would be (this is how tags are stored in ATProto)
115115+ rkey := atproto.RepositoryTagToRKey(tc.repository, tc.tag)
116116+ t.Logf("RKey for %s:%s = %s", tc.repository, tc.tag, rkey)
117117+118118+ // Then parse it back (this is what Jetstream does)
119119+ parsedRepo, parsedTag := atproto.RKeyToRepositoryTag(rkey)
120120+ t.Logf("Parsed back: repository=%s, tag=%s", parsedRepo, parsedTag)
121121+122122+ // Verify round-trip (skip for known limitations)
123123+ if tc.expectRoundTrip {
124124+ if parsedRepo != tc.repository {
125125+ t.Errorf("Repository round-trip failed: stored=%s, parsed=%s", tc.repository, parsedRepo)
126126+ }
127127+ if parsedTag != tc.tag {
128128+ t.Errorf("Tag round-trip failed: stored=%s, parsed=%s", tc.tag, parsedTag)
129129+ }
130130+131131+ // Step 4: Delete using parsed values (like Jetstream does)
132132+ if err := DeleteTag(db, testUser.DID, parsedRepo, parsedTag); err != nil {
133133+ t.Fatalf("Failed to delete tag: %v", err)
134134+ }
135135+136136+ // Step 5: Verify tag was deleted
137137+ err = db.QueryRow(`
138138+ SELECT COUNT(*) FROM tags
139139+ WHERE did = ? AND repository = ? AND tag = ?
140140+ `, testUser.DID, tc.repository, tc.tag).Scan(&count)
141141+ if err != nil {
142142+ t.Fatalf("Failed to count tags after delete: %v", err)
143143+ }
144144+ if count != 0 {
145145+ // This is the bug! Tag wasn't deleted
146146+ t.Errorf("Expected 0 tags after delete, got %d (tag still exists!)", count)
147147+148148+ // Debug: show what's actually in the database
149149+ rows, err := db.Query(`
150150+ SELECT repository, tag FROM tags WHERE did = ?
151151+ `, testUser.DID)
152152+ if err != nil {
153153+ t.Logf("Failed to query remaining tags: %v", err)
154154+ } else {
155155+ t.Logf("Remaining tags in database:")
156156+ for rows.Next() {
157157+ var repo, tag string
158158+ rows.Scan(&repo, &tag)
159159+ t.Logf(" - repository=%s, tag=%s", repo, tag)
160160+ }
161161+ rows.Close()
162162+ }
163163+ }
164164+ } else {
165165+ // Known limitation: skip delete test for non-round-trippable cases
166166+ t.Logf("Skipping delete test - known limitation: %s != %s or %s != %s",
167167+ tc.repository, parsedRepo, tc.tag, parsedTag)
168168+ }
169169+ })
170170+ }
171171+}
+18-4
pkg/appview/jetstream/backfill.go
···336336 manifest.ConfigSize = manifestRecord.Config.Size
337337 }
338338339339- // Insert manifest
339339+ // Platform info is only stored for multi-arch images in manifest_references table
340340+ // Single-arch images don't need platform display (it's obvious)
341341+342342+ // Insert manifest (or get existing ID if already exists)
340343 manifestID, err := db.InsertManifest(b.db, manifest)
341344 if err != nil {
342342- // Skip if already exists
345345+ // If manifest already exists, get its ID so we can still insert references/layers
343346 if strings.Contains(err.Error(), "UNIQUE constraint failed") {
344344- return nil
347347+ // Query for existing manifest ID
348348+ var existingID int64
349349+ err := b.db.QueryRow(`
350350+ SELECT id FROM manifests
351351+ WHERE did = ? AND repository = ? AND digest = ?
352352+ `, manifest.DID, manifest.Repository, manifest.Digest).Scan(&existingID)
353353+354354+ if err != nil {
355355+ return fmt.Errorf("failed to get existing manifest ID: %w", err)
356356+ }
357357+ manifestID = existingID
358358+ } else {
359359+ return fmt.Errorf("failed to insert manifest: %w", err)
345360 }
346346- return fmt.Errorf("failed to insert manifest: %w", err)
347361 }
348362349363 if isManifestList {
+9-1
pkg/appview/jetstream/worker.go
···545545 if commit.Operation == "delete" {
546546 // Delete tag - decode rkey back to repository and tag
547547 repo, tag := atproto.RKeyToRepositoryTag(commit.RKey)
548548- return db.DeleteTag(w.db, commit.DID, repo, tag)
548548+ fmt.Printf("Jetstream: Deleting tag: did=%s, repository=%s, tag=%s (from rkey=%s)\n",
549549+ commit.DID, repo, tag, commit.RKey)
550550+ if err := db.DeleteTag(w.db, commit.DID, repo, tag); err != nil {
551551+ fmt.Printf("Jetstream: ERROR deleting tag: %v\n", err)
552552+ return err
553553+ }
554554+ fmt.Printf("Jetstream: Successfully deleted tag: did=%s, repository=%s, tag=%s\n",
555555+ commit.DID, repo, tag)
556556+ return nil
549557 }
550558551559 // Parse tag record
+10-9
pkg/atproto/manifest_store.go
···185185 if tagOpt, ok := option.(distribution.WithTagOption); ok {
186186 tag := tagOpt.Tag
187187 tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, dgst.String())
188188- tagRKey := repositoryTagToRKey(s.repository, tag)
188188+ tagRKey := RepositoryTagToRKey(s.repository, tag)
189189 _, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord)
190190 if err != nil {
191191 return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
···209209 return dgst.Encoded()
210210}
211211212212-// repositoryTagToRKey converts a repository and tag to an ATProto record key
212212+// RepositoryTagToRKey converts a repository and tag to an ATProto record key
213213// ATProto record keys must match: ^[a-zA-Z0-9._~-]{1,512}$
214214-func repositoryTagToRKey(repository, tag string) string {
214214+func RepositoryTagToRKey(repository, tag string) string {
215215 // Combine repository and tag to create a unique key
216216- // Replace invalid characters: slashes become dashes
216216+ // Replace invalid characters: slashes become tildes (~)
217217+ // We use tilde instead of dash to avoid ambiguity with repository names that contain hyphens
217218 key := fmt.Sprintf("%s_%s", repository, tag)
218219219219- // Replace / with - (slash not allowed in rkeys)
220220- key = strings.ReplaceAll(key, "/", "-")
220220+ // Replace / with ~ (slash not allowed in rkeys, tilde is allowed and unlikely in repo names)
221221+ key = strings.ReplaceAll(key, "/", "~")
221222222223 return key
223224}
224225225226// RKeyToRepositoryTag converts an ATProto record key back to repository and tag
226226-// This is the inverse of repositoryTagToRKey
227227+// This is the inverse of RepositoryTagToRKey
227228// Note: If the tag contains underscores, this will split on the LAST underscore
228229func RKeyToRepositoryTag(rkey string) (repository, tag string) {
229230 // Find the last underscore to split repository and tag
···236237 repository = rkey[:lastUnderscore]
237238 tag = rkey[lastUnderscore+1:]
238239239239- // Convert dashes back to slashes in repository
240240- repository = strings.ReplaceAll(repository, "-", "/")
240240+ // Convert tildes back to slashes in repository (tilde was used to encode slashes)
241241+ repository = strings.ReplaceAll(repository, "~", "/")
241242242243 return repository, tag
243244}