···11+# Annotations Table Refactoring
22+33+## Overview
44+55+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.
66+77+## Motivation
88+99+**Current Problems:**
1010+- Each new annotation (e.g., `org.opencontainers.image.version`) requires schema change
1111+- Many NULL columns in manifests table
1212+- Rigid schema doesn't match OCI's flexible annotation model
1313+1414+**Benefits:**
1515+- ✅ Add any annotation without code/schema changes
1616+- ✅ Normalized database design
1717+- ✅ Easy to query "all repos with annotation X"
1818+- ✅ Simple queries (no joins needed for repository pages)
1919+2020+## Database Schema Changes
2121+2222+### 1. New Table: `repository_annotations`
2323+2424+```sql
2525+CREATE TABLE IF NOT EXISTS repository_annotations (
2626+ did TEXT NOT NULL,
2727+ repository TEXT NOT NULL,
2828+ key TEXT NOT NULL,
2929+ value TEXT NOT NULL,
3030+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
3131+ PRIMARY KEY(did, repository, key),
3232+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
3333+);
3434+CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository);
3535+CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key);
3636+```
3737+3838+**Key Design Decisions:**
3939+- Primary key: `(did, repository, key)` - one value per annotation per repository
4040+- No `manifest_id` foreign key - annotations are repository-level, not manifest-level
4141+- `updated_at` - track when annotation was last updated (from most recent manifest)
4242+- Stored at repository level because that's where they're displayed
4343+4444+### 2. Drop Columns from `manifests` Table
4545+4646+Remove these columns (migration will preserve data by copying to annotations table):
4747+- `title`
4848+- `description`
4949+- `source_url`
5050+- `documentation_url`
5151+- `licenses`
5252+- `icon_url`
5353+- `readme_url`
5454+- `version`
5555+5656+Keep only core manifest metadata:
5757+- `id`, `did`, `repository`, `digest`
5858+- `hold_endpoint`, `schema_version`, `media_type`
5959+- `config_digest`, `config_size`
6060+- `created_at`
6161+6262+## Migration Strategy
6363+6464+### Migration File: `0004_refactor_annotations_table.yaml`
6565+6666+```yaml
6767+description: Migrate manifest annotations to separate table
6868+query: |
6969+ -- Step 1: Create new annotations table
7070+ CREATE TABLE IF NOT EXISTS repository_annotations (
7171+ did TEXT NOT NULL,
7272+ repository TEXT NOT NULL,
7373+ key TEXT NOT NULL,
7474+ value TEXT NOT NULL,
7575+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
7676+ PRIMARY KEY(did, repository, key),
7777+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
7878+ );
7979+ CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository);
8080+ CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key);
8181+8282+ -- Step 2: Migrate existing data from manifests to annotations
8383+ -- For each repository, use the most recent manifest with non-empty data
8484+ INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
8585+ SELECT
8686+ m.did,
8787+ m.repository,
8888+ 'org.opencontainers.image.title' as key,
8989+ m.title as value,
9090+ m.created_at as updated_at
9191+ FROM manifests m
9292+ WHERE m.title IS NOT NULL AND m.title != ''
9393+ AND m.created_at = (
9494+ SELECT MAX(created_at) FROM manifests m2
9595+ WHERE m2.did = m.did AND m2.repository = m.repository
9696+ AND m2.title IS NOT NULL AND m2.title != ''
9797+ );
9898+9999+ INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
100100+ SELECT m.did, m.repository, 'org.opencontainers.image.description', m.description, m.created_at
101101+ FROM manifests m
102102+ WHERE m.description IS NOT NULL AND m.description != ''
103103+ AND m.created_at = (
104104+ SELECT MAX(created_at) FROM manifests m2
105105+ WHERE m2.did = m.did AND m2.repository = m.repository
106106+ AND m2.description IS NOT NULL AND m2.description != ''
107107+ );
108108+109109+ INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
110110+ SELECT m.did, m.repository, 'org.opencontainers.image.source', m.source_url, m.created_at
111111+ FROM manifests m
112112+ WHERE m.source_url IS NOT NULL AND m.source_url != ''
113113+ AND m.created_at = (
114114+ SELECT MAX(created_at) FROM manifests m2
115115+ WHERE m2.did = m.did AND m2.repository = m.repository
116116+ AND m2.source_url IS NOT NULL AND m2.source_url != ''
117117+ );
118118+119119+ INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
120120+ SELECT m.did, m.repository, 'org.opencontainers.image.documentation', m.documentation_url, m.created_at
121121+ FROM manifests m
122122+ WHERE m.documentation_url IS NOT NULL AND m.documentation_url != ''
123123+ AND m.created_at = (
124124+ SELECT MAX(created_at) FROM manifests m2
125125+ WHERE m2.did = m.did AND m2.repository = m.repository
126126+ AND m2.documentation_url IS NOT NULL AND m2.documentation_url != ''
127127+ );
128128+129129+ INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
130130+ SELECT m.did, m.repository, 'org.opencontainers.image.licenses', m.licenses, m.created_at
131131+ FROM manifests m
132132+ WHERE m.licenses IS NOT NULL AND m.licenses != ''
133133+ AND m.created_at = (
134134+ SELECT MAX(created_at) FROM manifests m2
135135+ WHERE m2.did = m.did AND m2.repository = m.repository
136136+ AND m2.licenses IS NOT NULL AND m2.licenses != ''
137137+ );
138138+139139+ INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
140140+ SELECT m.did, m.repository, 'io.atcr.icon', m.icon_url, m.created_at
141141+ FROM manifests m
142142+ WHERE m.icon_url IS NOT NULL AND m.icon_url != ''
143143+ AND m.created_at = (
144144+ SELECT MAX(created_at) FROM manifests m2
145145+ WHERE m2.did = m.did AND m2.repository = m.repository
146146+ AND m2.icon_url IS NOT NULL AND m2.icon_url != ''
147147+ );
148148+149149+ INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at)
150150+ SELECT m.did, m.repository, 'io.atcr.readme', m.readme_url, m.created_at
151151+ FROM manifests m
152152+ WHERE m.readme_url IS NOT NULL AND m.readme_url != ''
153153+ AND m.created_at = (
154154+ SELECT MAX(created_at) FROM manifests m2
155155+ WHERE m2.did = m.did AND m2.repository = m.repository
156156+ AND m2.readme_url IS NOT NULL AND m2.readme_url != ''
157157+ );
158158+159159+ -- Step 3: Drop old columns from manifests table
160160+ -- SQLite requires recreating table to drop columns
161161+ CREATE TABLE manifests_new (
162162+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163163+ did TEXT NOT NULL,
164164+ repository TEXT NOT NULL,
165165+ digest TEXT NOT NULL,
166166+ hold_endpoint TEXT NOT NULL,
167167+ schema_version INTEGER NOT NULL,
168168+ media_type TEXT NOT NULL,
169169+ config_digest TEXT,
170170+ config_size INTEGER,
171171+ created_at TIMESTAMP NOT NULL,
172172+ UNIQUE(did, repository, digest),
173173+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
174174+ );
175175+176176+ -- Copy data to new table
177177+ INSERT INTO manifests_new
178178+ SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type,
179179+ config_digest, config_size, created_at
180180+ FROM manifests;
181181+182182+ -- Replace old table
183183+ DROP TABLE manifests;
184184+ ALTER TABLE manifests_new RENAME TO manifests;
185185+186186+ -- Recreate indexes
187187+ CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
188188+ CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
189189+ CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
190190+```
191191+192192+## Code Changes
193193+194194+### 1. Database Helper Functions
195195+196196+**New file: `pkg/appview/db/annotations.go`**
197197+198198+```go
199199+package db
200200+201201+import (
202202+ "database/sql"
203203+ "time"
204204+)
205205+206206+// GetRepositoryAnnotations retrieves all annotations for a repository
207207+func GetRepositoryAnnotations(db *sql.DB, did, repository string) (map[string]string, error) {
208208+ rows, err := db.Query(`
209209+ SELECT key, value
210210+ FROM repository_annotations
211211+ WHERE did = ? AND repository = ?
212212+ `, did, repository)
213213+ if err != nil {
214214+ return nil, err
215215+ }
216216+ defer rows.Close()
217217+218218+ annotations := make(map[string]string)
219219+ for rows.Next() {
220220+ var key, value string
221221+ if err := rows.Scan(&key, &value); err != nil {
222222+ return nil, err
223223+ }
224224+ annotations[key] = value
225225+ }
226226+227227+ return annotations, rows.Err()
228228+}
229229+230230+// UpsertRepositoryAnnotations replaces all annotations for a repository
231231+// Only called when manifest has at least one non-empty annotation
232232+func UpsertRepositoryAnnotations(db *sql.DB, did, repository string, annotations map[string]string) error {
233233+ tx, err := db.Begin()
234234+ if err != nil {
235235+ return err
236236+ }
237237+ defer tx.Rollback()
238238+239239+ // Delete existing annotations
240240+ _, err = tx.Exec(`
241241+ DELETE FROM repository_annotations
242242+ WHERE did = ? AND repository = ?
243243+ `, did, repository)
244244+ if err != nil {
245245+ return err
246246+ }
247247+248248+ // Insert new annotations
249249+ stmt, err := tx.Prepare(`
250250+ INSERT INTO repository_annotations (did, repository, key, value, updated_at)
251251+ VALUES (?, ?, ?, ?, ?)
252252+ `)
253253+ if err != nil {
254254+ return err
255255+ }
256256+ defer stmt.Close()
257257+258258+ now := time.Now()
259259+ for key, value := range annotations {
260260+ _, err = stmt.Exec(did, repository, key, value, now)
261261+ if err != nil {
262262+ return err
263263+ }
264264+ }
265265+266266+ return tx.Commit()
267267+}
268268+269269+// DeleteRepositoryAnnotations removes all annotations for a repository
270270+func DeleteRepositoryAnnotations(db *sql.DB, did, repository string) error {
271271+ _, err := db.Exec(`
272272+ DELETE FROM repository_annotations
273273+ WHERE did = ? AND repository = ?
274274+ `, did, repository)
275275+ return err
276276+}
277277+```
278278+279279+### 2. Update Backfill Worker
280280+281281+**File: `pkg/appview/jetstream/backfill.go`**
282282+283283+In `processManifestRecord()` function, after extracting annotations:
284284+285285+```go
286286+// Extract OCI annotations from manifest
287287+var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string
288288+if manifestRecord.Annotations != nil {
289289+ title = manifestRecord.Annotations["org.opencontainers.image.title"]
290290+ description = manifestRecord.Annotations["org.opencontainers.image.description"]
291291+ sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"]
292292+ documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"]
293293+ licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"]
294294+ iconURL = manifestRecord.Annotations["io.atcr.icon"]
295295+ readmeURL = manifestRecord.Annotations["io.atcr.readme"]
296296+}
297297+298298+// Prepare manifest for insertion (WITHOUT annotation fields)
299299+manifest := &db.Manifest{
300300+ DID: did,
301301+ Repository: manifestRecord.Repository,
302302+ Digest: manifestRecord.Digest,
303303+ MediaType: manifestRecord.MediaType,
304304+ SchemaVersion: manifestRecord.SchemaVersion,
305305+ HoldEndpoint: manifestRecord.HoldEndpoint,
306306+ CreatedAt: manifestRecord.CreatedAt,
307307+ // NO annotation fields
308308+}
309309+310310+// Set config fields only for image manifests (not manifest lists)
311311+if !isManifestList && manifestRecord.Config != nil {
312312+ manifest.ConfigDigest = manifestRecord.Config.Digest
313313+ manifest.ConfigSize = manifestRecord.Config.Size
314314+}
315315+316316+// Insert manifest
317317+manifestID, err := db.InsertManifest(b.db, manifest)
318318+if err != nil {
319319+ return fmt.Errorf("failed to insert manifest: %w", err)
320320+}
321321+322322+// Update repository annotations ONLY if manifest has at least one non-empty annotation
323323+if manifestRecord.Annotations != nil {
324324+ hasData := false
325325+ for _, value := range manifestRecord.Annotations {
326326+ if value != "" {
327327+ hasData = true
328328+ break
329329+ }
330330+ }
331331+332332+ if hasData {
333333+ // Replace all annotations for this repository
334334+ err = db.UpsertRepositoryAnnotations(b.db, did, manifestRecord.Repository, manifestRecord.Annotations)
335335+ if err != nil {
336336+ return fmt.Errorf("failed to upsert annotations: %w", err)
337337+ }
338338+ }
339339+}
340340+```
341341+342342+### 3. Update Jetstream Worker
343343+344344+**File: `pkg/appview/jetstream/worker.go`**
345345+346346+Same changes as backfill - in `processManifestCommit()` function:
347347+348348+```go
349349+// Extract OCI annotations from manifest
350350+var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string
351351+if manifestRecord.Annotations != nil {
352352+ title = manifestRecord.Annotations["org.opencontainers.image.title"]
353353+ description = manifestRecord.Annotations["org.opencontainers.image.description"]
354354+ sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"]
355355+ documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"]
356356+ licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"]
357357+ iconURL = manifestRecord.Annotations["io.atcr.icon"]
358358+ readmeURL = manifestRecord.Annotations["io.atcr.readme"]
359359+}
360360+361361+// Prepare manifest for insertion (WITHOUT annotation fields)
362362+manifest := &db.Manifest{
363363+ DID: commit.DID,
364364+ Repository: manifestRecord.Repository,
365365+ Digest: manifestRecord.Digest,
366366+ MediaType: manifestRecord.MediaType,
367367+ SchemaVersion: manifestRecord.SchemaVersion,
368368+ HoldEndpoint: manifestRecord.HoldEndpoint,
369369+ CreatedAt: manifestRecord.CreatedAt,
370370+ // NO annotation fields
371371+}
372372+373373+// Set config fields only for image manifests (not manifest lists)
374374+if !isManifestList && manifestRecord.Config != nil {
375375+ manifest.ConfigDigest = manifestRecord.Config.Digest
376376+ manifest.ConfigSize = manifestRecord.Config.Size
377377+}
378378+379379+// Insert manifest
380380+manifestID, err := db.InsertManifest(w.db, manifest)
381381+if err != nil {
382382+ return fmt.Errorf("failed to insert manifest: %w", err)
383383+}
384384+385385+// Update repository annotations ONLY if manifest has at least one non-empty annotation
386386+if manifestRecord.Annotations != nil {
387387+ hasData := false
388388+ for _, value := range manifestRecord.Annotations {
389389+ if value != "" {
390390+ hasData = true
391391+ break
392392+ }
393393+ }
394394+395395+ if hasData {
396396+ // Replace all annotations for this repository
397397+ err = db.UpsertRepositoryAnnotations(w.db, commit.DID, manifestRecord.Repository, manifestRecord.Annotations)
398398+ if err != nil {
399399+ return fmt.Errorf("failed to upsert annotations: %w", err)
400400+ }
401401+ }
402402+}
403403+```
404404+405405+### 4. Update Database Queries
406406+407407+**File: `pkg/appview/db/queries.go`**
408408+409409+Replace `GetRepositoryMetadata()` function:
410410+411411+```go
412412+// GetRepositoryMetadata retrieves metadata for a repository from annotations table
413413+func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, version string, err error) {
414414+ annotations, err := GetRepositoryAnnotations(db, did, repository)
415415+ if err != nil {
416416+ return "", "", "", "", "", "", "", "", err
417417+ }
418418+419419+ title = annotations["org.opencontainers.image.title"]
420420+ description = annotations["org.opencontainers.image.description"]
421421+ sourceURL = annotations["org.opencontainers.image.source"]
422422+ documentationURL = annotations["org.opencontainers.image.documentation"]
423423+ licenses = annotations["org.opencontainers.image.licenses"]
424424+ iconURL = annotations["io.atcr.icon"]
425425+ readmeURL = annotations["io.atcr.readme"]
426426+ version = annotations["org.opencontainers.image.version"]
427427+428428+ return title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, version, nil
429429+}
430430+```
431431+432432+Update `InsertManifest()` to remove annotation columns:
433433+434434+```go
435435+func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) {
436436+ _, err := db.Exec(`
437437+ INSERT INTO manifests
438438+ (did, repository, digest, hold_endpoint, schema_version, media_type,
439439+ config_digest, config_size, created_at)
440440+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
441441+ ON CONFLICT(did, repository, digest) DO UPDATE SET
442442+ hold_endpoint = excluded.hold_endpoint,
443443+ schema_version = excluded.schema_version,
444444+ media_type = excluded.media_type,
445445+ config_digest = excluded.config_digest,
446446+ config_size = excluded.config_size
447447+ `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
448448+ manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
449449+ manifest.ConfigSize, manifest.CreatedAt)
450450+451451+ if err != nil {
452452+ return 0, err
453453+ }
454454+455455+ // Query for the ID (works for both insert and update)
456456+ var id int64
457457+ err = db.QueryRow(`
458458+ SELECT id FROM manifests
459459+ WHERE did = ? AND repository = ? AND digest = ?
460460+ `, manifest.DID, manifest.Repository, manifest.Digest).Scan(&id)
461461+462462+ if err != nil {
463463+ return 0, fmt.Errorf("failed to get manifest ID after upsert: %w", err)
464464+ }
465465+466466+ return id, nil
467467+}
468468+```
469469+470470+Similar updates needed for:
471471+- `GetUserRepositories()` - fetch annotations separately and populate Repository struct
472472+- `GetRecentPushes()` - join with annotations or fetch separately
473473+- `SearchPushes()` - can now search annotations table directly
474474+475475+### 5. Update Models
476476+477477+**File: `pkg/appview/db/models.go`**
478478+479479+Remove annotation fields from `Manifest` struct:
480480+481481+```go
482482+type Manifest struct {
483483+ ID int64
484484+ DID string
485485+ Repository string
486486+ Digest string
487487+ HoldEndpoint string
488488+ SchemaVersion int
489489+ MediaType string
490490+ ConfigDigest string
491491+ ConfigSize int64
492492+ CreatedAt time.Time
493493+ // Removed: Title, Description, SourceURL, DocumentationURL, Licenses, IconURL, ReadmeURL
494494+}
495495+```
496496+497497+Keep annotation fields on `Repository` struct (populated from annotations table):
498498+499499+```go
500500+type Repository struct {
501501+ Name string
502502+ TagCount int
503503+ ManifestCount int
504504+ LastPush time.Time
505505+ Tags []Tag
506506+ Manifests []Manifest
507507+ Title string
508508+ Description string
509509+ SourceURL string
510510+ DocumentationURL string
511511+ Licenses string
512512+ IconURL string
513513+ ReadmeURL string
514514+ Version string // NEW
515515+}
516516+```
517517+518518+### 6. Update Schema.sql
519519+520520+**File: `pkg/appview/db/schema.sql`**
521521+522522+Add new table:
523523+524524+```sql
525525+CREATE TABLE IF NOT EXISTS repository_annotations (
526526+ did TEXT NOT NULL,
527527+ repository TEXT NOT NULL,
528528+ key TEXT NOT NULL,
529529+ value TEXT NOT NULL,
530530+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
531531+ PRIMARY KEY(did, repository, key),
532532+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
533533+);
534534+CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository);
535535+CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key);
536536+```
537537+538538+Update manifests table (remove annotation columns):
539539+540540+```sql
541541+CREATE TABLE IF NOT EXISTS manifests (
542542+ id INTEGER PRIMARY KEY AUTOINCREMENT,
543543+ did TEXT NOT NULL,
544544+ repository TEXT NOT NULL,
545545+ digest TEXT NOT NULL,
546546+ hold_endpoint TEXT NOT NULL,
547547+ schema_version INTEGER NOT NULL,
548548+ media_type TEXT NOT NULL,
549549+ config_digest TEXT,
550550+ config_size INTEGER,
551551+ created_at TIMESTAMP NOT NULL,
552552+ UNIQUE(did, repository, digest),
553553+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
554554+);
555555+```
556556+557557+## Update Logic Summary
558558+559559+**Key Decision: Only update annotations when manifest has data**
560560+561561+```
562562+For each manifest processed (backfill or jetstream):
563563+ 1. Parse manifest.Annotations map
564564+ 2. Check if ANY annotation has non-empty value
565565+ 3. IF hasData:
566566+ DELETE all annotations for (did, repository)
567567+ INSERT all annotations from manifest (including empty ones)
568568+ ELSE:
569569+ SKIP (don't touch existing annotations)
570570+```
571571+572572+**Why this works:**
573573+- Manifest lists have no annotations or all empty → skip, preserve existing
574574+- Platform manifests have real data → replace everything
575575+- Removing annotation from Dockerfile → it's gone (not in new INSERT)
576576+- Can't accidentally clear data (need at least one non-empty value)
577577+578578+## UI/Template Changes
579579+580580+### Handler Updates
581581+582582+**File: `pkg/appview/handlers/repository.go`**
583583+584584+Update the handler to include version:
585585+586586+```go
587587+// Fetch repository metadata from annotations
588588+title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, version, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository)
589589+if err != nil {
590590+ log.Printf("Failed to fetch repository metadata: %v", err)
591591+ // Continue without metadata on error
592592+} else {
593593+ repo.Title = title
594594+ repo.Description = description
595595+ repo.SourceURL = sourceURL
596596+ repo.DocumentationURL = documentationURL
597597+ repo.Licenses = licenses
598598+ repo.IconURL = iconURL
599599+ repo.ReadmeURL = readmeURL
600600+ repo.Version = version // NEW
601601+}
602602+```
603603+604604+### Template Updates
605605+606606+**File: `pkg/appview/templates/pages/repository.html`**
607607+608608+Update the metadata section condition to include version:
609609+610610+```html
611611+<!-- Metadata Section -->
612612+{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}
613613+<div class="repo-metadata">
614614+ <!-- Version Badge (if present) -->
615615+ {{ if .Repository.Version }}
616616+ <span class="metadata-badge version-badge" title="Version">
617617+ {{ .Repository.Version }}
618618+ </span>
619619+ {{ end }}
620620+621621+ <!-- License Badges -->
622622+ {{ if .Repository.Licenses }}
623623+ {{ range parseLicenses .Repository.Licenses }}
624624+ {{ if .IsValid }}
625625+ <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">
626626+ {{ .SPDXID }}
627627+ </a>
628628+ {{ else }}
629629+ <span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">
630630+ {{ .Name }}
631631+ </span>
632632+ {{ end }}
633633+ {{ end }}
634634+ {{ end }}
635635+636636+ <!-- Source Link -->
637637+ {{ if .Repository.SourceURL }}
638638+ <a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link">
639639+ Source
640640+ </a>
641641+ {{ end }}
642642+643643+ <!-- Documentation Link -->
644644+ {{ if .Repository.DocumentationURL }}
645645+ <a href="{{ .Repository.DocumentationURL }}" target="_blank" class="metadata-link">
646646+ Documentation
647647+ </a>
648648+ {{ end }}
649649+</div>
650650+{{ end }}
651651+```
652652+653653+### CSS Updates
654654+655655+**File: `pkg/appview/static/css/style.css`**
656656+657657+Add styling for version badge (different color from license badge):
658658+659659+```css
660660+.version-badge {
661661+ background: #0969da; /* GitHub blue */
662662+ color: white;
663663+ padding: 0.25rem 0.5rem;
664664+ border-radius: 0.25rem;
665665+ font-size: 0.875rem;
666666+ font-weight: 500;
667667+ display: inline-block;
668668+}
669669+```
670670+671671+### Data Flow Summary
672672+673673+**Before refactor:**
674674+```
675675+DB columns → GetRepositoryMetadata() → Handler assigns to Repository struct → Template displays
676676+```
677677+678678+**After refactor:**
679679+```
680680+annotations table → GetRepositoryAnnotations() → GetRepositoryMetadata() extracts known fields →
681681+Handler assigns to Repository struct → Template displays (same as before)
682682+```
683683+684684+**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.
685685+686686+## Benefits Recap
687687+688688+1. **Flexible**: Support any OCI annotation without code changes
689689+2. **Clean**: No NULL columns in manifests table
690690+3. **Simple queries**: `SELECT * FROM repository_annotations WHERE did=? AND repo=?`
691691+4. **Safe updates**: Only update when manifest has data
692692+5. **Natural deletion**: Remove annotation from Dockerfile → it's deleted on next push
693693+6. **Extensible**: Future features (annotation search, filtering) are trivial
694694+695695+## Testing Checklist
696696+697697+After migration:
698698+- [ ] Verify existing repositories show annotations correctly
699699+- [ ] Push new manifest with annotations → updates correctly
700700+- [ ] Push manifest list → doesn't clear annotations
701701+- [ ] Remove annotation from Dockerfile and push → annotation deleted
702702+- [ ] Backfill re-run → annotations repopulated correctly
703703+- [ ] Search still works (if implemented)
+8-8
pkg/appview/db/hold_store.go
···8899// HoldCaptainRecord represents a cached captain record from a hold's PDS
1010type HoldCaptainRecord struct {
1111- HoldDID string
1212- OwnerDID string
1313- Public bool
1414- AllowAllCrew bool
1515- DeployedAt string
1616- Region string
1717- Provider string
1818- UpdatedAt time.Time
1111+ HoldDID string `json:"-"` // Set manually, not from JSON
1212+ OwnerDID string `json:"owner"`
1313+ Public bool `json:"public"`
1414+ AllowAllCrew bool `json:"allowAllCrew"`
1515+ DeployedAt string `json:"deployedAt"`
1616+ Region string `json:"region"`
1717+ Provider string `json:"provider"`
1818+ UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
1919}
20202121// GetCaptainRecord retrieves a captain record from the cache
+27-335
pkg/appview/jetstream/backfill.go
···88 "strings"
99 "time"
10101111- "github.com/bluesky-social/indigo/atproto/identity"
1211 "github.com/bluesky-social/indigo/atproto/syntax"
13121313+ "atcr.io/pkg/appview"
1414 "atcr.io/pkg/appview/db"
1515 "atcr.io/pkg/atproto"
1616)
···1919type BackfillWorker struct {
2020 db *sql.DB
2121 client *atproto.Client
2222- directory identity.Directory
2323- defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io")
2424- testMode bool // If true, suppress warnings for external holds
2222+ processor *Processor // Shared processor for DB operations
2323+ defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io")
2424+ testMode bool // If true, suppress warnings for external holds
2525}
26262727// BackfillState tracks backfill progress
···44444545 return &BackfillWorker{
4646 db: database,
4747- client: client, // This points to the relay
4848- directory: identity.DefaultDirectory(),
4747+ client: client, // This points to the relay
4848+ processor: NewProcessor(database, false), // No cache for batch processing
4949 defaultHoldDID: defaultHoldDID,
5050 testMode: testMode,
5151 }, nil
···132132// backfillRepo backfills all records for a single repo/DID
133133func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection string) (int, error) {
134134 // Ensure user exists in database and get their PDS endpoint
135135- if err := b.ensureUser(ctx, did); err != nil {
135135+ if err := b.processor.EnsureUser(ctx, did); err != nil {
136136 return 0, fmt.Errorf("failed to ensure user: %w", err)
137137 }
138138···142142 return 0, fmt.Errorf("invalid DID %s: %w", did, err)
143143 }
144144145145- ident, err := b.directory.LookupDID(ctx, didParsed)
145145+ ident, err := b.processor.directory.LookupDID(ctx, didParsed)
146146 if err != nil {
147147 return 0, fmt.Errorf("failed to resolve DID to PDS: %w", err)
148148 }
···173173 // Process each record
174174 for _, record := range records {
175175 // Track what we found for deletion reconciliation
176176- if collection == atproto.ManifestCollection {
176176+ switch collection {
177177+ case atproto.ManifestCollection:
177178 var manifestRecord atproto.ManifestRecord
178179 if err := json.Unmarshal(record.Value, &manifestRecord); err == nil {
179180 foundManifestDigests = append(foundManifestDigests, manifestRecord.Digest)
180181 }
181181- } else if collection == atproto.TagCollection {
182182+ case atproto.TagCollection:
182183 var tagRecord atproto.TagRecord
183184 if err := json.Unmarshal(record.Value, &tagRecord); err == nil {
184185 foundTags = append(foundTags, struct{ Repository, Tag string }{
···186187 Tag: tagRecord.Tag,
187188 })
188189 }
189189- } else if collection == atproto.StarCollection {
190190+ case atproto.StarCollection:
190191 var starRecord atproto.StarRecord
191192 if err := json.Unmarshal(record.Value, &starRecord); err == nil {
192193 key := fmt.Sprintf("%s/%s", starRecord.Subject.DID, starRecord.Subject.Repository)
···278279func (b *BackfillWorker) processRecord(ctx context.Context, did, collection string, record *atproto.Record) error {
279280 switch collection {
280281 case atproto.ManifestCollection:
281281- return b.processManifestRecord(did, record)
282282+ _, err := b.processor.ProcessManifest(context.Background(), did, record.Value)
283283+ return err
282284 case atproto.TagCollection:
283283- return b.processTagRecord(did, record)
285285+ return b.processor.ProcessTag(context.Background(), did, record.Value)
284286 case atproto.StarCollection:
285285- return b.processStarRecord(did, record)
287287+ return b.processor.ProcessStar(context.Background(), did, record.Value)
286288 case atproto.SailorProfileCollection:
287287- return b.processSailorProfileRecord(ctx, did, record)
289289+ return b.processor.ProcessSailorProfile(ctx, did, record.Value, b.queryCaptainRecordWrapper)
288290 default:
289291 return fmt.Errorf("unsupported collection: %s", collection)
290292 }
291293}
292294293293-// processManifestRecord processes a manifest record
294294-func (b *BackfillWorker) processManifestRecord(did string, record *atproto.Record) error {
295295- var manifestRecord atproto.ManifestRecord
296296- if err := json.Unmarshal(record.Value, &manifestRecord); err != nil {
297297- return fmt.Errorf("failed to unmarshal manifest: %w", err)
298298- }
299299-300300- // Extract OCI annotations from manifest
301301- var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string
302302- if manifestRecord.Annotations != nil {
303303- title = manifestRecord.Annotations["org.opencontainers.image.title"]
304304- description = manifestRecord.Annotations["org.opencontainers.image.description"]
305305- sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"]
306306- documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"]
307307- licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"]
308308- iconURL = manifestRecord.Annotations["io.atcr.icon"]
309309- readmeURL = manifestRecord.Annotations["io.atcr.readme"]
310310- }
311311-312312- // Detect manifest type
313313- isManifestList := len(manifestRecord.Manifests) > 0
314314-315315- // Prepare manifest for insertion
316316- manifest := &db.Manifest{
317317- DID: did,
318318- Repository: manifestRecord.Repository,
319319- Digest: manifestRecord.Digest,
320320- MediaType: manifestRecord.MediaType,
321321- SchemaVersion: manifestRecord.SchemaVersion,
322322- HoldEndpoint: manifestRecord.HoldEndpoint,
323323- CreatedAt: manifestRecord.CreatedAt,
324324- Title: title,
325325- Description: description,
326326- SourceURL: sourceURL,
327327- DocumentationURL: documentationURL,
328328- Licenses: licenses,
329329- IconURL: iconURL,
330330- ReadmeURL: readmeURL,
331331- }
332332-333333- // Set config fields only for image manifests (not manifest lists)
334334- if !isManifestList && manifestRecord.Config != nil {
335335- manifest.ConfigDigest = manifestRecord.Config.Digest
336336- manifest.ConfigSize = manifestRecord.Config.Size
337337- }
338338-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)
343343- manifestID, err := db.InsertManifest(b.db, manifest)
344344- if err != nil {
345345- // If manifest already exists, get its ID so we can still insert references/layers
346346- if strings.Contains(err.Error(), "UNIQUE constraint failed") {
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)
360360- }
361361- }
362362-363363- if isManifestList {
364364- // Insert manifest references (for manifest lists/indexes)
365365- for i, ref := range manifestRecord.Manifests {
366366- platformArch := ""
367367- platformOS := ""
368368- platformVariant := ""
369369- platformOSVersion := ""
370370-371371- if ref.Platform != nil {
372372- platformArch = ref.Platform.Architecture
373373- platformOS = ref.Platform.OS
374374- platformVariant = ref.Platform.Variant
375375- platformOSVersion = ref.Platform.OSVersion
376376- }
377377-378378- if err := db.InsertManifestReference(b.db, &db.ManifestReference{
379379- ManifestID: manifestID,
380380- Digest: ref.Digest,
381381- MediaType: ref.MediaType,
382382- Size: ref.Size,
383383- PlatformArchitecture: platformArch,
384384- PlatformOS: platformOS,
385385- PlatformVariant: platformVariant,
386386- PlatformOSVersion: platformOSVersion,
387387- ReferenceIndex: i,
388388- }); err != nil {
389389- // Continue on error - reference might already exist
390390- continue
391391- }
392392- }
393393- } else {
394394- // Insert layers (for image manifests)
395395- for i, layer := range manifestRecord.Layers {
396396- if err := db.InsertLayer(b.db, &db.Layer{
397397- ManifestID: manifestID,
398398- Digest: layer.Digest,
399399- MediaType: layer.MediaType,
400400- Size: layer.Size,
401401- LayerIndex: i,
402402- }); err != nil {
403403- // Continue on error - layer might already exist
404404- continue
405405- }
406406- }
407407- }
408408-409409- return nil
410410-}
411411-412412-// processTagRecord processes a tag record
413413-func (b *BackfillWorker) processTagRecord(did string, record *atproto.Record) error {
414414- var tagRecord atproto.TagRecord
415415- if err := json.Unmarshal(record.Value, &tagRecord); err != nil {
416416- return fmt.Errorf("failed to unmarshal tag: %w", err)
417417- }
418418-419419- // Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
420420- manifestDigest, err := tagRecord.GetManifestDigest()
421421- if err != nil {
422422- return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
423423- }
424424-425425- // Insert or update tag
426426- return db.UpsertTag(b.db, &db.Tag{
427427- DID: did,
428428- Repository: tagRecord.Repository,
429429- Tag: tagRecord.Tag,
430430- Digest: manifestDigest,
431431- CreatedAt: tagRecord.UpdatedAt,
432432- })
433433-}
434434-435435-// processStarRecord processes a star record
436436-func (b *BackfillWorker) processStarRecord(did string, record *atproto.Record) error {
437437- var starRecord atproto.StarRecord
438438- if err := json.Unmarshal(record.Value, &starRecord); err != nil {
439439- return fmt.Errorf("failed to unmarshal star: %w", err)
440440- }
441441-442442- // Upsert the star record (idempotent - won't duplicate)
443443- // The DID here is the starrer (user who starred)
444444- // The subject contains the owner DID and repository
445445- // Star count will be calculated on demand from the stars table
446446- return db.UpsertStar(b.db, did, starRecord.Subject.DID, starRecord.Subject.Repository, starRecord.CreatedAt)
447447-}
448448-449449-// processSailorProfileRecord processes a sailor profile record
450450-// Extracts defaultHold and queries the hold's captain record to cache it
451451-func (b *BackfillWorker) processSailorProfileRecord(ctx context.Context, did string, record *atproto.Record) error {
452452- var profileRecord atproto.SailorProfileRecord
453453- if err := json.Unmarshal(record.Value, &profileRecord); err != nil {
454454- return fmt.Errorf("failed to unmarshal sailor profile: %w", err)
455455- }
456456-457457- // Skip if no default hold set
458458- if profileRecord.DefaultHold == "" {
459459- return nil
460460- }
461461-462462- // Convert hold URL/DID to canonical DID
463463- holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold)
464464- if holdDID == "" {
465465- fmt.Printf("WARNING [backfill]: Invalid hold reference in profile for %s: %s\n", did, profileRecord.DefaultHold)
466466- return nil
467467- }
468468-469469- // Query and cache the captain record
295295+// queryCaptainRecordWrapper wraps queryCaptainRecord with backfill-specific logic
296296+func (b *BackfillWorker) queryCaptainRecordWrapper(ctx context.Context, holdDID string) error {
470297 if err := b.queryCaptainRecord(ctx, holdDID); err != nil {
471298 // In test mode, only warn about default hold (local hold)
472299 // External/production holds may not have captain records yet (dev ahead of prod)
···478305 // Don't fail the whole backfill - just skip this hold
479306 return nil
480307 }
481481-482308 return nil
483309}
484310···494320 }
495321496322 // Resolve hold DID to URL
497497- // For did:web, we need to fetch .well-known/did.json
498498- holdURL, err := resolveHoldDIDToURL(ctx, holdDID)
499499- if err != nil {
500500- return fmt.Errorf("failed to resolve hold DID to URL: %w", err)
501501- }
323323+ holdURL := appview.ResolveHoldURL(holdDID)
502324503325 // Create client for hold's PDS
504326 holdClient := atproto.NewClient(holdURL, holdDID, "")
···522344 return fmt.Errorf("failed to get captain record: %w", err)
523345 }
524346525525- // Parse captain record from the record's Value field
526526- var captainRecord struct {
527527- Owner string `json:"owner"`
528528- Public bool `json:"public"`
529529- AllowAllCrew bool `json:"allowAllCrew"`
530530- DeployedAt string `json:"deployedAt"`
531531- Region string `json:"region"`
532532- Provider string `json:"provider"`
533533- }
534534-347347+ // Parse captain record directly into db struct
348348+ var captainRecord db.HoldCaptainRecord
535349 if err := json.Unmarshal(record.Value, &captainRecord); err != nil {
536350 return fmt.Errorf("failed to parse captain record: %w", err)
537351 }
538352539539- // Cache in database
540540- dbRecord := &db.HoldCaptainRecord{
541541- HoldDID: holdDID,
542542- OwnerDID: captainRecord.Owner,
543543- Public: captainRecord.Public,
544544- AllowAllCrew: captainRecord.AllowAllCrew,
545545- DeployedAt: captainRecord.DeployedAt,
546546- Region: captainRecord.Region,
547547- Provider: captainRecord.Provider,
548548- UpdatedAt: time.Now(),
549549- }
353353+ // Set fields not from JSON
354354+ captainRecord.HoldDID = holdDID
355355+ captainRecord.UpdatedAt = time.Now()
550356551551- if err := db.UpsertCaptainRecord(b.db, dbRecord); err != nil {
357357+ if err := db.UpsertCaptainRecord(b.db, &captainRecord); err != nil {
552358 return fmt.Errorf("failed to cache captain record: %w", err)
553359 }
554360555555- fmt.Printf("Backfill: Cached captain record for hold %s (owner: %s)\n", holdDID, captainRecord.Owner)
361361+ fmt.Printf("Backfill: Cached captain record for hold %s (owner: %s)\n", holdDID, captainRecord.OwnerDID)
556362 return nil
557363}
558558-559559-// resolveHoldDIDToURL resolves a hold DID to its service endpoint URL
560560-// Fetches the DID document and returns both the canonical DID and service endpoint
561561-func resolveHoldDIDToURL(ctx context.Context, inputDID string) (string, error) {
562562- // For did:web, construct the .well-known URL
563563- if !strings.HasPrefix(inputDID, "did:web:") {
564564- return "", fmt.Errorf("only did:web is supported, got: %s", inputDID)
565565- }
566566-567567- // Extract hostname from did:web:hostname[:port]
568568- hostname := strings.TrimPrefix(inputDID, "did:web:")
569569-570570- // Try HTTP first (for local Docker), then HTTPS
571571- var serviceEndpoint string
572572- for _, scheme := range []string{"http", "https"} {
573573- testURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, hostname)
574574-575575- // Fetch DID document (use NewClient to initialize httpClient)
576576- client := atproto.NewClient("", "", "")
577577- didDoc, err := client.FetchDIDDocument(ctx, testURL)
578578- if err == nil && didDoc != nil {
579579- // Extract service endpoint from DID document
580580- for _, service := range didDoc.Service {
581581- if service.Type == "AtprotoPersonalDataServer" || service.Type == "AtcrHoldService" {
582582- serviceEndpoint = service.ServiceEndpoint
583583- break
584584- }
585585- }
586586-587587- if serviceEndpoint != "" {
588588- fmt.Printf("DEBUG [backfill]: Resolved %s → canonical DID: %s, endpoint: %s\n",
589589- inputDID, didDoc.ID, serviceEndpoint)
590590- return serviceEndpoint, nil
591591- }
592592- }
593593- }
594594-595595- // Fallback: assume the hold service is at the root of the hostname
596596- // Try HTTP first for local development
597597- url := fmt.Sprintf("http://%s", hostname)
598598- fmt.Printf("WARNING [backfill]: Failed to fetch DID document for %s, using fallback URL: %s\n", inputDID, url)
599599- return url, nil
600600-}
601601-602602-// ensureUser resolves and upserts a user by DID
603603-func (b *BackfillWorker) ensureUser(ctx context.Context, did string) error {
604604- // Check if user already exists
605605- existingUser, err := db.GetUserByDID(b.db, did)
606606- if err == nil && existingUser != nil {
607607- // Update last seen
608608- existingUser.LastSeen = time.Now()
609609- return db.UpsertUser(b.db, existingUser)
610610- }
611611-612612- // Resolve DID to get handle and PDS endpoint
613613- didParsed, err := syntax.ParseDID(did)
614614- if err != nil {
615615- // Fallback: use DID as handle
616616- user := &db.User{
617617- DID: did,
618618- Handle: did,
619619- PDSEndpoint: "https://bsky.social",
620620- LastSeen: time.Now(),
621621- }
622622- return db.UpsertUser(b.db, user)
623623- }
624624-625625- ident, err := b.directory.LookupDID(ctx, didParsed)
626626- if err != nil {
627627- // Fallback: use DID as handle
628628- user := &db.User{
629629- DID: did,
630630- Handle: did,
631631- PDSEndpoint: "https://bsky.social",
632632- LastSeen: time.Now(),
633633- }
634634- return db.UpsertUser(b.db, user)
635635- }
636636-637637- resolvedDID := ident.DID.String()
638638- handle := ident.Handle.String()
639639- pdsEndpoint := ident.PDSEndpoint()
640640-641641- // If handle is invalid or PDS is missing, use defaults
642642- if handle == "handle.invalid" || handle == "" {
643643- handle = resolvedDID
644644- }
645645- if pdsEndpoint == "" {
646646- pdsEndpoint = "https://bsky.social"
647647- }
648648-649649- // Fetch user's Bluesky profile (including avatar)
650650- // Use public Bluesky AppView API (doesn't require auth for public profiles)
651651- avatar := ""
652652- publicClient := atproto.NewClient("https://public.api.bsky.app", "", "")
653653- profile, err := publicClient.GetActorProfile(ctx, resolvedDID)
654654- if err != nil {
655655- fmt.Printf("WARNING [backfill]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err)
656656- // Continue without avatar
657657- } else {
658658- avatar = profile.Avatar
659659- }
660660-661661- // Upsert to database
662662- user := &db.User{
663663- DID: resolvedDID,
664664- Handle: handle,
665665- PDSEndpoint: pdsEndpoint,
666666- Avatar: avatar,
667667- LastSeen: time.Now(),
668668- }
669669-670670- return db.UpsertUser(b.db, user)
671671-}
+320
pkg/appview/jetstream/processor.go
···11+package jetstream
22+33+import (
44+ "context"
55+ "database/sql"
66+ "encoding/json"
77+ "fmt"
88+ "strings"
99+ "time"
1010+1111+ "github.com/bluesky-social/indigo/atproto/identity"
1212+ "github.com/bluesky-social/indigo/atproto/syntax"
1313+1414+ "atcr.io/pkg/appview/db"
1515+ "atcr.io/pkg/atproto"
1616+)
1717+1818+// Processor handles shared database operations for both Worker (live) and Backfill (sync)
1919+// This eliminates code duplication between the two data ingestion paths
2020+type Processor struct {
2121+ db *sql.DB
2222+ directory identity.Directory
2323+ userCache *UserCache // Optional - enabled for Worker, disabled for Backfill
2424+ useCache bool
2525+}
2626+2727+// NewProcessor creates a new shared processor
2828+// useCache: true for Worker (live streaming), false for Backfill (batch processing)
2929+func NewProcessor(database *sql.DB, useCache bool) *Processor {
3030+ p := &Processor{
3131+ db: database,
3232+ directory: identity.DefaultDirectory(),
3333+ useCache: useCache,
3434+ }
3535+3636+ if useCache {
3737+ p.userCache = &UserCache{
3838+ cache: make(map[string]*db.User),
3939+ }
4040+ }
4141+4242+ return p
4343+}
4444+4545+// EnsureUser resolves and upserts a user by DID
4646+// Uses cache if enabled (Worker), queries DB if cache disabled (Backfill)
4747+func (p *Processor) EnsureUser(ctx context.Context, did string) error {
4848+ // Check cache first (if enabled)
4949+ if p.useCache && p.userCache != nil {
5050+ if user, ok := p.userCache.cache[did]; ok {
5151+ // Update last seen
5252+ user.LastSeen = time.Now()
5353+ return db.UpsertUser(p.db, user)
5454+ }
5555+ } else if !p.useCache {
5656+ // No cache - check if user already exists in DB
5757+ existingUser, err := db.GetUserByDID(p.db, did)
5858+ if err == nil && existingUser != nil {
5959+ // Update last seen
6060+ existingUser.LastSeen = time.Now()
6161+ return db.UpsertUser(p.db, existingUser)
6262+ }
6363+ }
6464+6565+ // Resolve DID to get handle and PDS endpoint
6666+ didParsed, err := syntax.ParseDID(did)
6767+ if err != nil {
6868+ // Fallback: use DID as handle
6969+ user := &db.User{
7070+ DID: did,
7171+ Handle: did,
7272+ PDSEndpoint: "https://bsky.social",
7373+ LastSeen: time.Now(),
7474+ }
7575+ if p.useCache {
7676+ p.userCache.cache[did] = user
7777+ }
7878+ return db.UpsertUser(p.db, user)
7979+ }
8080+8181+ ident, err := p.directory.LookupDID(ctx, didParsed)
8282+ if err != nil {
8383+ // Fallback: use DID as handle
8484+ user := &db.User{
8585+ DID: did,
8686+ Handle: did,
8787+ PDSEndpoint: "https://bsky.social",
8888+ LastSeen: time.Now(),
8989+ }
9090+ if p.useCache {
9191+ p.userCache.cache[did] = user
9292+ }
9393+ return db.UpsertUser(p.db, user)
9494+ }
9595+9696+ resolvedDID := ident.DID.String()
9797+ handle := ident.Handle.String()
9898+ pdsEndpoint := ident.PDSEndpoint()
9999+100100+ // If handle is invalid or PDS is missing, use defaults
101101+ if handle == "handle.invalid" || handle == "" {
102102+ handle = resolvedDID
103103+ }
104104+ if pdsEndpoint == "" {
105105+ pdsEndpoint = "https://bsky.social"
106106+ }
107107+108108+ // Fetch user's Bluesky profile (including avatar)
109109+ // Use public Bluesky AppView API (doesn't require auth for public profiles)
110110+ avatar := ""
111111+ publicClient := atproto.NewClient("https://public.api.bsky.app", "", "")
112112+ profile, err := publicClient.GetActorProfile(ctx, resolvedDID)
113113+ if err != nil {
114114+ fmt.Printf("WARNING [processor]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err)
115115+ // Continue without avatar
116116+ } else {
117117+ avatar = profile.Avatar
118118+ }
119119+120120+ // Create user record
121121+ user := &db.User{
122122+ DID: resolvedDID,
123123+ Handle: handle,
124124+ PDSEndpoint: pdsEndpoint,
125125+ Avatar: avatar,
126126+ LastSeen: time.Now(),
127127+ }
128128+129129+ // Cache if enabled
130130+ if p.useCache {
131131+ p.userCache.cache[did] = user
132132+ }
133133+134134+ // Upsert to database
135135+ return db.UpsertUser(p.db, user)
136136+}
137137+138138+// ProcessManifest processes a manifest record and stores it in the database
139139+// Returns the manifest ID for further processing (layers/references)
140140+func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData []byte) (int64, error) {
141141+ // Unmarshal manifest record
142142+ var manifestRecord atproto.ManifestRecord
143143+ if err := json.Unmarshal(recordData, &manifestRecord); err != nil {
144144+ return 0, fmt.Errorf("failed to unmarshal manifest: %w", err)
145145+ }
146146+ // Extract OCI annotations from manifest
147147+ var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string
148148+ if manifestRecord.Annotations != nil {
149149+ title = manifestRecord.Annotations["org.opencontainers.image.title"]
150150+ description = manifestRecord.Annotations["org.opencontainers.image.description"]
151151+ sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"]
152152+ documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"]
153153+ licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"]
154154+ iconURL = manifestRecord.Annotations["io.atcr.icon"]
155155+ readmeURL = manifestRecord.Annotations["io.atcr.readme"]
156156+ }
157157+158158+ // Detect manifest type
159159+ isManifestList := len(manifestRecord.Manifests) > 0
160160+161161+ // Prepare manifest for insertion
162162+ manifest := &db.Manifest{
163163+ DID: did,
164164+ Repository: manifestRecord.Repository,
165165+ Digest: manifestRecord.Digest,
166166+ MediaType: manifestRecord.MediaType,
167167+ SchemaVersion: manifestRecord.SchemaVersion,
168168+ HoldEndpoint: manifestRecord.HoldEndpoint,
169169+ CreatedAt: manifestRecord.CreatedAt,
170170+ Title: title,
171171+ Description: description,
172172+ SourceURL: sourceURL,
173173+ DocumentationURL: documentationURL,
174174+ Licenses: licenses,
175175+ IconURL: iconURL,
176176+ ReadmeURL: readmeURL,
177177+ }
178178+179179+ // Set config fields only for image manifests (not manifest lists)
180180+ if !isManifestList && manifestRecord.Config != nil {
181181+ manifest.ConfigDigest = manifestRecord.Config.Digest
182182+ manifest.ConfigSize = manifestRecord.Config.Size
183183+ }
184184+185185+ // Insert manifest
186186+ manifestID, err := db.InsertManifest(p.db, manifest)
187187+ if err != nil {
188188+ // For backfill: if manifest already exists, get its ID
189189+ if strings.Contains(err.Error(), "UNIQUE constraint failed") {
190190+ var existingID int64
191191+ err := p.db.QueryRow(`
192192+ SELECT id FROM manifests
193193+ WHERE did = ? AND repository = ? AND digest = ?
194194+ `, manifest.DID, manifest.Repository, manifest.Digest).Scan(&existingID)
195195+196196+ if err != nil {
197197+ return 0, fmt.Errorf("failed to get existing manifest ID: %w", err)
198198+ }
199199+ manifestID = existingID
200200+ } else {
201201+ return 0, fmt.Errorf("failed to insert manifest: %w", err)
202202+ }
203203+ }
204204+205205+ // Insert manifest references or layers
206206+ if isManifestList {
207207+ // Insert manifest references (for manifest lists/indexes)
208208+ for i, ref := range manifestRecord.Manifests {
209209+ platformArch := ""
210210+ platformOS := ""
211211+ platformVariant := ""
212212+ platformOSVersion := ""
213213+214214+ if ref.Platform != nil {
215215+ platformArch = ref.Platform.Architecture
216216+ platformOS = ref.Platform.OS
217217+ platformVariant = ref.Platform.Variant
218218+ platformOSVersion = ref.Platform.OSVersion
219219+ }
220220+221221+ if err := db.InsertManifestReference(p.db, &db.ManifestReference{
222222+ ManifestID: manifestID,
223223+ Digest: ref.Digest,
224224+ MediaType: ref.MediaType,
225225+ Size: ref.Size,
226226+ PlatformArchitecture: platformArch,
227227+ PlatformOS: platformOS,
228228+ PlatformVariant: platformVariant,
229229+ PlatformOSVersion: platformOSVersion,
230230+ ReferenceIndex: i,
231231+ }); err != nil {
232232+ // Continue on error - reference might already exist
233233+ continue
234234+ }
235235+ }
236236+ } else {
237237+ // Insert layers (for image manifests)
238238+ for i, layer := range manifestRecord.Layers {
239239+ if err := db.InsertLayer(p.db, &db.Layer{
240240+ ManifestID: manifestID,
241241+ Digest: layer.Digest,
242242+ MediaType: layer.MediaType,
243243+ Size: layer.Size,
244244+ LayerIndex: i,
245245+ }); err != nil {
246246+ // Continue on error - layer might already exist
247247+ continue
248248+ }
249249+ }
250250+ }
251251+252252+ return manifestID, nil
253253+}
254254+255255+// ProcessTag processes a tag record and stores it in the database
256256+func (p *Processor) ProcessTag(ctx context.Context, did string, recordData []byte) error {
257257+ // Unmarshal tag record
258258+ var tagRecord atproto.TagRecord
259259+ if err := json.Unmarshal(recordData, &tagRecord); err != nil {
260260+ return fmt.Errorf("failed to unmarshal tag: %w", err)
261261+ }
262262+ // Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
263263+ manifestDigest, err := tagRecord.GetManifestDigest()
264264+ if err != nil {
265265+ return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
266266+ }
267267+268268+ // Insert or update tag
269269+ return db.UpsertTag(p.db, &db.Tag{
270270+ DID: did,
271271+ Repository: tagRecord.Repository,
272272+ Tag: tagRecord.Tag,
273273+ Digest: manifestDigest,
274274+ CreatedAt: tagRecord.UpdatedAt,
275275+ })
276276+}
277277+278278+// ProcessStar processes a star record and stores it in the database
279279+func (p *Processor) ProcessStar(ctx context.Context, did string, recordData []byte) error {
280280+ // Unmarshal star record
281281+ var starRecord atproto.StarRecord
282282+ if err := json.Unmarshal(recordData, &starRecord); err != nil {
283283+ return fmt.Errorf("failed to unmarshal star: %w", err)
284284+ }
285285+ // Upsert the star record (idempotent - won't duplicate)
286286+ // The DID here is the starrer (user who starred)
287287+ // The subject contains the owner DID and repository
288288+ // Star count will be calculated on demand from the stars table
289289+ return db.UpsertStar(p.db, did, starRecord.Subject.DID, starRecord.Subject.Repository, starRecord.CreatedAt)
290290+}
291291+292292+// ProcessSailorProfile processes a sailor profile record
293293+// This is primarily used by backfill to cache captain records for holds
294294+func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, recordData []byte, queryCaptainFn func(context.Context, string) error) error {
295295+ // Unmarshal sailor profile record
296296+ var profileRecord atproto.SailorProfileRecord
297297+ if err := json.Unmarshal(recordData, &profileRecord); err != nil {
298298+ return fmt.Errorf("failed to unmarshal sailor profile: %w", err)
299299+ }
300300+301301+ // Skip if no default hold set
302302+ if profileRecord.DefaultHold == "" {
303303+ return nil
304304+ }
305305+306306+ // Convert hold URL/DID to canonical DID
307307+ holdDID := atproto.ResolveHoldDIDFromURL(profileRecord.DefaultHold)
308308+ if holdDID == "" {
309309+ fmt.Printf("WARNING [processor]: Invalid hold reference in profile for %s: %s\n", did, profileRecord.DefaultHold)
310310+ return nil
311311+ }
312312+313313+ // Query and cache the captain record using provided function
314314+ // This allows backfill-specific logic (retries, test mode handling) without duplicating it here
315315+ if queryCaptainFn != nil {
316316+ return queryCaptainFn(ctx, holdDID)
317317+ }
318318+319319+ return nil
320320+}
+540
pkg/appview/jetstream/processor_test.go
···11+package jetstream
22+33+import (
44+ "context"
55+ "database/sql"
66+ "encoding/json"
77+ "testing"
88+ "time"
99+1010+ "atcr.io/pkg/atproto"
1111+ _ "github.com/mattn/go-sqlite3"
1212+)
1313+1414+// setupTestDB creates an in-memory SQLite database for testing
1515+func setupTestDB(t *testing.T) *sql.DB {
1616+ database, err := sql.Open("sqlite3", ":memory:")
1717+ if err != nil {
1818+ t.Fatalf("Failed to open test database: %v", err)
1919+ }
2020+2121+ // Create schema
2222+ schema := `
2323+ CREATE TABLE users (
2424+ did TEXT PRIMARY KEY,
2525+ handle TEXT NOT NULL,
2626+ pds_endpoint TEXT NOT NULL,
2727+ avatar TEXT,
2828+ last_seen TIMESTAMP NOT NULL
2929+ );
3030+3131+ CREATE TABLE manifests (
3232+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3333+ did TEXT NOT NULL,
3434+ repository TEXT NOT NULL,
3535+ digest TEXT NOT NULL,
3636+ hold_endpoint TEXT NOT NULL,
3737+ schema_version INTEGER NOT NULL,
3838+ media_type TEXT NOT NULL,
3939+ config_digest TEXT,
4040+ config_size INTEGER,
4141+ created_at TIMESTAMP NOT NULL,
4242+ title TEXT,
4343+ description TEXT,
4444+ source_url TEXT,
4545+ documentation_url TEXT,
4646+ licenses TEXT,
4747+ icon_url TEXT,
4848+ readme_url TEXT,
4949+ UNIQUE(did, repository, digest)
5050+ );
5151+5252+ CREATE TABLE layers (
5353+ manifest_id INTEGER NOT NULL,
5454+ digest TEXT NOT NULL,
5555+ size INTEGER NOT NULL,
5656+ media_type TEXT NOT NULL,
5757+ layer_index INTEGER NOT NULL,
5858+ PRIMARY KEY(manifest_id, layer_index)
5959+ );
6060+6161+ CREATE TABLE manifest_references (
6262+ manifest_id INTEGER NOT NULL,
6363+ digest TEXT NOT NULL,
6464+ media_type TEXT NOT NULL,
6565+ size INTEGER NOT NULL,
6666+ platform_architecture TEXT,
6767+ platform_os TEXT,
6868+ platform_variant TEXT,
6969+ platform_os_version TEXT,
7070+ reference_index INTEGER NOT NULL,
7171+ PRIMARY KEY(manifest_id, reference_index)
7272+ );
7373+7474+ CREATE TABLE tags (
7575+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7676+ did TEXT NOT NULL,
7777+ repository TEXT NOT NULL,
7878+ tag TEXT NOT NULL,
7979+ digest TEXT NOT NULL,
8080+ created_at TIMESTAMP NOT NULL,
8181+ UNIQUE(did, repository, tag)
8282+ );
8383+8484+ CREATE TABLE stars (
8585+ starrer_did TEXT NOT NULL,
8686+ owner_did TEXT NOT NULL,
8787+ repository TEXT NOT NULL,
8888+ created_at TIMESTAMP NOT NULL,
8989+ PRIMARY KEY(starrer_did, owner_did, repository)
9090+ );
9191+ `
9292+9393+ if _, err := database.Exec(schema); err != nil {
9494+ t.Fatalf("Failed to create schema: %v", err)
9595+ }
9696+9797+ return database
9898+}
9999+100100+func TestNewProcessor(t *testing.T) {
101101+ database := setupTestDB(t)
102102+ defer database.Close()
103103+104104+ tests := []struct {
105105+ name string
106106+ useCache bool
107107+ }{
108108+ {"with cache", true},
109109+ {"without cache", false},
110110+ }
111111+112112+ for _, tt := range tests {
113113+ t.Run(tt.name, func(t *testing.T) {
114114+ p := NewProcessor(database, tt.useCache)
115115+ if p == nil {
116116+ t.Fatal("NewProcessor returned nil")
117117+ }
118118+ if p.db != database {
119119+ t.Error("Processor database not set correctly")
120120+ }
121121+ if p.useCache != tt.useCache {
122122+ t.Errorf("useCache = %v, want %v", p.useCache, tt.useCache)
123123+ }
124124+ if tt.useCache && p.userCache == nil {
125125+ t.Error("Cache enabled but userCache is nil")
126126+ }
127127+ if !tt.useCache && p.userCache != nil {
128128+ t.Error("Cache disabled but userCache is not nil")
129129+ }
130130+ })
131131+ }
132132+}
133133+134134+func TestProcessManifest_ImageManifest(t *testing.T) {
135135+ database := setupTestDB(t)
136136+ defer database.Close()
137137+138138+ p := NewProcessor(database, false)
139139+ ctx := context.Background()
140140+141141+ // Create test manifest record
142142+ manifestRecord := &atproto.ManifestRecord{
143143+ Repository: "test-app",
144144+ Digest: "sha256:abc123",
145145+ MediaType: "application/vnd.oci.image.manifest.v1+json",
146146+ SchemaVersion: 2,
147147+ HoldEndpoint: "did:web:hold01.atcr.io",
148148+ CreatedAt: time.Now(),
149149+ Config: &atproto.BlobReference{
150150+ Digest: "sha256:config123",
151151+ Size: 1234,
152152+ },
153153+ Layers: []atproto.BlobReference{
154154+ {Digest: "sha256:layer1", Size: 5000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip"},
155155+ {Digest: "sha256:layer2", Size: 3000, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip"},
156156+ },
157157+ Annotations: map[string]string{
158158+ "org.opencontainers.image.title": "Test App",
159159+ "org.opencontainers.image.description": "A test application",
160160+ "org.opencontainers.image.source": "https://github.com/test/app",
161161+ "org.opencontainers.image.licenses": "MIT",
162162+ "io.atcr.icon": "https://example.com/icon.png",
163163+ },
164164+ }
165165+166166+ // Marshal to bytes for ProcessManifest
167167+ recordBytes, err := json.Marshal(manifestRecord)
168168+ if err != nil {
169169+ t.Fatalf("Failed to marshal manifest: %v", err)
170170+ }
171171+172172+ // Process manifest
173173+ manifestID, err := p.ProcessManifest(ctx, "did:plc:test123", recordBytes)
174174+ if err != nil {
175175+ t.Fatalf("ProcessManifest failed: %v", err)
176176+ }
177177+ if manifestID == 0 {
178178+ t.Error("Expected non-zero manifest ID")
179179+ }
180180+181181+ // Verify manifest was inserted
182182+ var count int
183183+ err = database.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ? AND repository = ? AND digest = ?",
184184+ "did:plc:test123", "test-app", "sha256:abc123").Scan(&count)
185185+ if err != nil {
186186+ t.Fatalf("Failed to query manifests: %v", err)
187187+ }
188188+ if count != 1 {
189189+ t.Errorf("Expected 1 manifest, got %d", count)
190190+ }
191191+192192+ // Verify annotations were stored
193193+ var title, source string
194194+ err = database.QueryRow("SELECT title, source_url FROM manifests WHERE id = ?", manifestID).Scan(&title, &source)
195195+ if err != nil {
196196+ t.Fatalf("Failed to query manifest fields: %v", err)
197197+ }
198198+ if title != "Test App" {
199199+ t.Errorf("title = %q, want %q", title, "Test App")
200200+ }
201201+ if source != "https://github.com/test/app" {
202202+ t.Errorf("source_url = %q, want %q", source, "https://github.com/test/app")
203203+ }
204204+205205+ // Verify layers were inserted
206206+ var layerCount int
207207+ err = database.QueryRow("SELECT COUNT(*) FROM layers WHERE manifest_id = ?", manifestID).Scan(&layerCount)
208208+ if err != nil {
209209+ t.Fatalf("Failed to query layers: %v", err)
210210+ }
211211+ if layerCount != 2 {
212212+ t.Errorf("Expected 2 layers, got %d", layerCount)
213213+ }
214214+215215+ // Verify no manifest references (this is an image, not a list)
216216+ var refCount int
217217+ err = database.QueryRow("SELECT COUNT(*) FROM manifest_references WHERE manifest_id = ?", manifestID).Scan(&refCount)
218218+ if err != nil {
219219+ t.Fatalf("Failed to query manifest_references: %v", err)
220220+ }
221221+ if refCount != 0 {
222222+ t.Errorf("Expected 0 manifest references, got %d", refCount)
223223+ }
224224+}
225225+226226+func TestProcessManifest_ManifestList(t *testing.T) {
227227+ database := setupTestDB(t)
228228+ defer database.Close()
229229+230230+ p := NewProcessor(database, false)
231231+ ctx := context.Background()
232232+233233+ // Create test manifest list record
234234+ manifestRecord := &atproto.ManifestRecord{
235235+ Repository: "test-app",
236236+ Digest: "sha256:list123",
237237+ MediaType: "application/vnd.oci.image.index.v1+json",
238238+ SchemaVersion: 2,
239239+ HoldEndpoint: "did:web:hold01.atcr.io",
240240+ CreatedAt: time.Now(),
241241+ Manifests: []atproto.ManifestReference{
242242+ {
243243+ Digest: "sha256:amd64manifest",
244244+ MediaType: "application/vnd.oci.image.manifest.v1+json",
245245+ Size: 1000,
246246+ Platform: &atproto.Platform{
247247+ Architecture: "amd64",
248248+ OS: "linux",
249249+ },
250250+ },
251251+ {
252252+ Digest: "sha256:arm64manifest",
253253+ MediaType: "application/vnd.oci.image.manifest.v1+json",
254254+ Size: 1100,
255255+ Platform: &atproto.Platform{
256256+ Architecture: "arm64",
257257+ OS: "linux",
258258+ Variant: "v8",
259259+ },
260260+ },
261261+ },
262262+ }
263263+264264+ // Marshal to bytes for ProcessManifest
265265+ recordBytes, err := json.Marshal(manifestRecord)
266266+ if err != nil {
267267+ t.Fatalf("Failed to marshal manifest: %v", err)
268268+ }
269269+270270+ // Process manifest list
271271+ manifestID, err := p.ProcessManifest(ctx, "did:plc:test123", recordBytes)
272272+ if err != nil {
273273+ t.Fatalf("ProcessManifest failed: %v", err)
274274+ }
275275+276276+ // Verify manifest references were inserted
277277+ var refCount int
278278+ err = database.QueryRow("SELECT COUNT(*) FROM manifest_references WHERE manifest_id = ?", manifestID).Scan(&refCount)
279279+ if err != nil {
280280+ t.Fatalf("Failed to query manifest_references: %v", err)
281281+ }
282282+ if refCount != 2 {
283283+ t.Errorf("Expected 2 manifest references, got %d", refCount)
284284+ }
285285+286286+ // Verify platform info was stored
287287+ var arch, os string
288288+ err = database.QueryRow("SELECT platform_architecture, platform_os FROM manifest_references WHERE manifest_id = ? AND reference_index = 0", manifestID).Scan(&arch, &os)
289289+ if err != nil {
290290+ t.Fatalf("Failed to query platform info: %v", err)
291291+ }
292292+ if arch != "amd64" {
293293+ t.Errorf("platform_architecture = %q, want %q", arch, "amd64")
294294+ }
295295+ if os != "linux" {
296296+ t.Errorf("platform_os = %q, want %q", os, "linux")
297297+ }
298298+299299+ // Verify no layers (this is a list, not an image)
300300+ var layerCount int
301301+ err = database.QueryRow("SELECT COUNT(*) FROM layers WHERE manifest_id = ?", manifestID).Scan(&layerCount)
302302+ if err != nil {
303303+ t.Fatalf("Failed to query layers: %v", err)
304304+ }
305305+ if layerCount != 0 {
306306+ t.Errorf("Expected 0 layers, got %d", layerCount)
307307+ }
308308+}
309309+310310+func TestProcessTag(t *testing.T) {
311311+ database := setupTestDB(t)
312312+ defer database.Close()
313313+314314+ p := NewProcessor(database, false)
315315+ ctx := context.Background()
316316+317317+ // Create test tag record (using ManifestDigest field for simplicity)
318318+ tagRecord := &atproto.TagRecord{
319319+ Repository: "test-app",
320320+ Tag: "latest",
321321+ ManifestDigest: "sha256:abc123",
322322+ UpdatedAt: time.Now(),
323323+ }
324324+325325+ // Marshal to bytes for ProcessTag
326326+ recordBytes, err := json.Marshal(tagRecord)
327327+ if err != nil {
328328+ t.Fatalf("Failed to marshal tag: %v", err)
329329+ }
330330+331331+ // Process tag
332332+ err = p.ProcessTag(ctx, "did:plc:test123", recordBytes)
333333+ if err != nil {
334334+ t.Fatalf("ProcessTag failed: %v", err)
335335+ }
336336+337337+ // Verify tag was inserted
338338+ var count int
339339+ err = database.QueryRow("SELECT COUNT(*) FROM tags WHERE did = ? AND repository = ? AND tag = ?",
340340+ "did:plc:test123", "test-app", "latest").Scan(&count)
341341+ if err != nil {
342342+ t.Fatalf("Failed to query tags: %v", err)
343343+ }
344344+ if count != 1 {
345345+ t.Errorf("Expected 1 tag, got %d", count)
346346+ }
347347+348348+ // Verify digest was stored
349349+ var digest string
350350+ err = database.QueryRow("SELECT digest FROM tags WHERE did = ? AND repository = ? AND tag = ?",
351351+ "did:plc:test123", "test-app", "latest").Scan(&digest)
352352+ if err != nil {
353353+ t.Fatalf("Failed to query tag digest: %v", err)
354354+ }
355355+ if digest != "sha256:abc123" {
356356+ t.Errorf("digest = %q, want %q", digest, "sha256:abc123")
357357+ }
358358+359359+ // Test upserting same tag with new digest
360360+ tagRecord.ManifestDigest = "sha256:newdigest"
361361+ recordBytes, err = json.Marshal(tagRecord)
362362+ if err != nil {
363363+ t.Fatalf("Failed to marshal tag: %v", err)
364364+ }
365365+ err = p.ProcessTag(ctx, "did:plc:test123", recordBytes)
366366+ if err != nil {
367367+ t.Fatalf("ProcessTag (upsert) failed: %v", err)
368368+ }
369369+370370+ // Verify tag was updated
371371+ err = database.QueryRow("SELECT digest FROM tags WHERE did = ? AND repository = ? AND tag = ?",
372372+ "did:plc:test123", "test-app", "latest").Scan(&digest)
373373+ if err != nil {
374374+ t.Fatalf("Failed to query updated tag: %v", err)
375375+ }
376376+ if digest != "sha256:newdigest" {
377377+ t.Errorf("digest = %q, want %q", digest, "sha256:newdigest")
378378+ }
379379+380380+ // Verify still only one tag (upsert, not insert)
381381+ err = database.QueryRow("SELECT COUNT(*) FROM tags WHERE did = ? AND repository = ? AND tag = ?",
382382+ "did:plc:test123", "test-app", "latest").Scan(&count)
383383+ if err != nil {
384384+ t.Fatalf("Failed to query tags after upsert: %v", err)
385385+ }
386386+ if count != 1 {
387387+ t.Errorf("Expected 1 tag after upsert, got %d", count)
388388+ }
389389+}
390390+391391+func TestProcessStar(t *testing.T) {
392392+ database := setupTestDB(t)
393393+ defer database.Close()
394394+395395+ p := NewProcessor(database, false)
396396+ ctx := context.Background()
397397+398398+ // Create test star record
399399+ starRecord := &atproto.StarRecord{
400400+ Subject: atproto.StarSubject{
401401+ DID: "did:plc:owner123",
402402+ Repository: "test-app",
403403+ },
404404+ CreatedAt: time.Now(),
405405+ }
406406+407407+ // Marshal to bytes for ProcessStar
408408+ recordBytes, err := json.Marshal(starRecord)
409409+ if err != nil {
410410+ t.Fatalf("Failed to marshal star: %v", err)
411411+ }
412412+413413+ // Process star
414414+ err = p.ProcessStar(ctx, "did:plc:starrer123", recordBytes)
415415+ if err != nil {
416416+ t.Fatalf("ProcessStar failed: %v", err)
417417+ }
418418+419419+ // Verify star was inserted
420420+ var count int
421421+ err = database.QueryRow("SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = ? AND repository = ?",
422422+ "did:plc:starrer123", "did:plc:owner123", "test-app").Scan(&count)
423423+ if err != nil {
424424+ t.Fatalf("Failed to query stars: %v", err)
425425+ }
426426+ if count != 1 {
427427+ t.Errorf("Expected 1 star, got %d", count)
428428+ }
429429+430430+ // Test upserting same star (should be idempotent)
431431+ recordBytes, err = json.Marshal(starRecord)
432432+ if err != nil {
433433+ t.Fatalf("Failed to marshal star: %v", err)
434434+ }
435435+ err = p.ProcessStar(ctx, "did:plc:starrer123", recordBytes)
436436+ if err != nil {
437437+ t.Fatalf("ProcessStar (upsert) failed: %v", err)
438438+ }
439439+440440+ // Verify still only one star
441441+ err = database.QueryRow("SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = ? AND repository = ?",
442442+ "did:plc:starrer123", "did:plc:owner123", "test-app").Scan(&count)
443443+ if err != nil {
444444+ t.Fatalf("Failed to query stars after upsert: %v", err)
445445+ }
446446+ if count != 1 {
447447+ t.Errorf("Expected 1 star after upsert, got %d", count)
448448+ }
449449+}
450450+451451+func TestProcessManifest_Duplicate(t *testing.T) {
452452+ database := setupTestDB(t)
453453+ defer database.Close()
454454+455455+ p := NewProcessor(database, false)
456456+ ctx := context.Background()
457457+458458+ manifestRecord := &atproto.ManifestRecord{
459459+ Repository: "test-app",
460460+ Digest: "sha256:abc123",
461461+ MediaType: "application/vnd.oci.image.manifest.v1+json",
462462+ SchemaVersion: 2,
463463+ HoldEndpoint: "did:web:hold01.atcr.io",
464464+ CreatedAt: time.Now(),
465465+ }
466466+467467+ // Marshal to bytes for ProcessManifest
468468+ recordBytes, err := json.Marshal(manifestRecord)
469469+ if err != nil {
470470+ t.Fatalf("Failed to marshal manifest: %v", err)
471471+ }
472472+473473+ // Insert first time
474474+ id1, err := p.ProcessManifest(ctx, "did:plc:test123", recordBytes)
475475+ if err != nil {
476476+ t.Fatalf("First ProcessManifest failed: %v", err)
477477+ }
478478+479479+ // Insert duplicate
480480+ id2, err := p.ProcessManifest(ctx, "did:plc:test123", recordBytes)
481481+ if err != nil {
482482+ t.Fatalf("Duplicate ProcessManifest failed: %v", err)
483483+ }
484484+485485+ // Should return existing ID
486486+ if id1 != id2 {
487487+ t.Errorf("Duplicate manifest got different ID: %d vs %d", id1, id2)
488488+ }
489489+490490+ // Verify only one manifest exists
491491+ var count int
492492+ err = database.QueryRow("SELECT COUNT(*) FROM manifests WHERE did = ? AND digest = ?",
493493+ "did:plc:test123", "sha256:abc123").Scan(&count)
494494+ if err != nil {
495495+ t.Fatalf("Failed to query manifests: %v", err)
496496+ }
497497+ if count != 1 {
498498+ t.Errorf("Expected 1 manifest, got %d", count)
499499+ }
500500+}
501501+502502+func TestProcessManifest_EmptyAnnotations(t *testing.T) {
503503+ database := setupTestDB(t)
504504+ defer database.Close()
505505+506506+ p := NewProcessor(database, false)
507507+ ctx := context.Background()
508508+509509+ // Manifest with nil annotations
510510+ manifestRecord := &atproto.ManifestRecord{
511511+ Repository: "test-app",
512512+ Digest: "sha256:abc123",
513513+ MediaType: "application/vnd.oci.image.manifest.v1+json",
514514+ SchemaVersion: 2,
515515+ HoldEndpoint: "did:web:hold01.atcr.io",
516516+ CreatedAt: time.Now(),
517517+ Annotations: nil,
518518+ }
519519+520520+ // Marshal to bytes for ProcessManifest
521521+ recordBytes, err := json.Marshal(manifestRecord)
522522+ if err != nil {
523523+ t.Fatalf("Failed to marshal manifest: %v", err)
524524+ }
525525+526526+ manifestID, err := p.ProcessManifest(ctx, "did:plc:test123", recordBytes)
527527+ if err != nil {
528528+ t.Fatalf("ProcessManifest failed: %v", err)
529529+ }
530530+531531+ // Verify annotation fields are empty strings (not NULL)
532532+ var title string
533533+ err = database.QueryRow("SELECT title FROM manifests WHERE id = ?", manifestID).Scan(&title)
534534+ if err != nil {
535535+ t.Fatalf("Failed to query title: %v", err)
536536+ }
537537+ if title != "" {
538538+ t.Errorf("Expected empty title, got %q", title)
539539+ }
540540+}
+28-222
pkg/appview/jetstream/worker.go
···99 "sync"
1010 "time"
11111212- "github.com/bluesky-social/indigo/atproto/identity"
1313- "github.com/bluesky-social/indigo/atproto/syntax"
1414-1512 "atcr.io/pkg/appview/db"
1613 "atcr.io/pkg/atproto"
1714 "github.com/gorilla/websocket"
···3330 startCursor int64
3431 wantedCollections []string
3532 debugCollectionCount int
3636- userCache *UserCache
3737- directory identity.Directory
3333+ processor *Processor // Shared processor for DB operations
3834 eventCallback EventCallback
3935 connStartTime time.Time // Track when connection started for debugging
4036···6561 atproto.TagCollection, // io.atcr.tag
6662 atproto.StarCollection, // io.atcr.sailor.star
6763 },
6868- userCache: &UserCache{
6969- cache: make(map[string]*db.User),
7070- },
7171- directory: identity.DefaultDirectory(),
6464+ processor: NewProcessor(database, true), // Use cache for live streaming
7265 }
7366}
7467···333326 }
334327}
335328336336-// ensureUser resolves and upserts a user by DID
337337-func (w *Worker) ensureUser(ctx context.Context, did string) error {
338338- // Check cache first
339339- if user, ok := w.userCache.cache[did]; ok {
340340- // Update last seen
341341- user.LastSeen = time.Now()
342342- return db.UpsertUser(w.db, user)
343343- }
344344-345345- // Resolve DID to get handle and PDS endpoint
346346- didParsed, err := syntax.ParseDID(did)
347347- if err != nil {
348348- fmt.Printf("WARNING: Invalid DID %s: %v (using DID as handle)\n", did, err)
349349- // Fallback: use DID as handle
350350- user := &db.User{
351351- DID: did,
352352- Handle: did,
353353- PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback
354354- LastSeen: time.Now(),
355355- }
356356- w.userCache.cache[did] = user
357357- return db.UpsertUser(w.db, user)
358358- }
359359-360360- ident, err := w.directory.LookupDID(ctx, didParsed)
361361- if err != nil {
362362- fmt.Printf("WARNING: Failed to resolve DID %s: %v (using DID as handle)\n", did, err)
363363- // Fallback: use DID as handle
364364- user := &db.User{
365365- DID: did,
366366- Handle: did,
367367- PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback
368368- LastSeen: time.Now(),
369369- }
370370- w.userCache.cache[did] = user
371371- return db.UpsertUser(w.db, user)
372372- }
373373-374374- resolvedDID := ident.DID.String()
375375- handle := ident.Handle.String()
376376- pdsEndpoint := ident.PDSEndpoint()
377377-378378- // If handle is invalid or PDS is missing, use defaults
379379- if handle == "handle.invalid" || handle == "" {
380380- handle = resolvedDID
381381- }
382382- if pdsEndpoint == "" {
383383- pdsEndpoint = "https://bsky.social"
384384- }
385385-386386- // Fetch user's Bluesky profile (including avatar)
387387- // Use public Bluesky AppView API (doesn't require auth for public profiles)
388388- avatar := ""
389389- publicClient := atproto.NewClient("https://public.api.bsky.app", "", "")
390390- profile, err := publicClient.GetActorProfile(ctx, resolvedDID)
391391- if err != nil {
392392- fmt.Printf("WARNING [worker]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err)
393393- // Continue without avatar
394394- } else {
395395- avatar = profile.Avatar
396396- }
397397-398398- // Cache the user
399399- user := &db.User{
400400- DID: resolvedDID,
401401- Handle: handle,
402402- PDSEndpoint: pdsEndpoint,
403403- Avatar: avatar,
404404- LastSeen: time.Now(),
405405- }
406406- w.userCache.cache[did] = user
407407-408408- // Upsert to database
409409- return db.UpsertUser(w.db, user)
410410-}
411411-412329// processManifest processes a manifest commit event
413330func (w *Worker) processManifest(commit *CommitEvent) error {
414331 // Resolve and upsert user with handle/PDS endpoint
415415- if err := w.ensureUser(context.Background(), commit.DID); err != nil {
332332+ if err := w.processor.EnsureUser(context.Background(), commit.DID); err != nil {
416333 return fmt.Errorf("failed to ensure user: %w", err)
417334 }
418335···427344 }
428345429346 // Parse manifest record
430430- var manifestRecord atproto.ManifestRecord
431431- if commit.Record != nil {
432432- recordBytes, err := json.Marshal(commit.Record)
433433- if err != nil {
434434- return fmt.Errorf("failed to marshal record: %w", err)
435435- }
436436- if err := json.Unmarshal(recordBytes, &manifestRecord); err != nil {
437437- return fmt.Errorf("failed to unmarshal manifest: %w", err)
438438- }
439439- } else {
440440- // No record data, can't process
441441- return nil
442442- }
443443-444444- // Extract OCI annotations from manifest
445445- var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string
446446- if manifestRecord.Annotations != nil {
447447- title = manifestRecord.Annotations["org.opencontainers.image.title"]
448448- description = manifestRecord.Annotations["org.opencontainers.image.description"]
449449- sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"]
450450- documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"]
451451- licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"]
452452- iconURL = manifestRecord.Annotations["io.atcr.icon"]
453453- readmeURL = manifestRecord.Annotations["io.atcr.readme"]
454454- }
455455-456456- // Detect manifest type
457457- isManifestList := len(manifestRecord.Manifests) > 0
458458-459459- // Prepare manifest for insertion
460460- manifest := &db.Manifest{
461461- DID: commit.DID,
462462- Repository: manifestRecord.Repository,
463463- Digest: manifestRecord.Digest,
464464- MediaType: manifestRecord.MediaType,
465465- SchemaVersion: manifestRecord.SchemaVersion,
466466- HoldEndpoint: manifestRecord.HoldEndpoint,
467467- CreatedAt: manifestRecord.CreatedAt,
468468- Title: title,
469469- Description: description,
470470- SourceURL: sourceURL,
471471- DocumentationURL: documentationURL,
472472- Licenses: licenses,
473473- IconURL: iconURL,
474474- ReadmeURL: readmeURL,
475475- }
476476-477477- // Set config fields only for image manifests (not manifest lists)
478478- if !isManifestList && manifestRecord.Config != nil {
479479- manifest.ConfigDigest = manifestRecord.Config.Digest
480480- manifest.ConfigSize = manifestRecord.Config.Size
347347+ if commit.Record == nil {
348348+ return nil // No record data, can't process
481349 }
482350483483- // Insert manifest
484484- manifestID, err := db.InsertManifest(w.db, manifest)
351351+ // Marshal map to bytes for processing
352352+ recordBytes, err := json.Marshal(commit.Record)
485353 if err != nil {
486486- return fmt.Errorf("failed to insert manifest: %w", err)
354354+ return fmt.Errorf("failed to marshal record: %w", err)
487355 }
488356489489- if isManifestList {
490490- // Insert manifest references (for manifest lists/indexes)
491491- for i, ref := range manifestRecord.Manifests {
492492- platformArch := ""
493493- platformOS := ""
494494- platformVariant := ""
495495- platformOSVersion := ""
496496-497497- if ref.Platform != nil {
498498- platformArch = ref.Platform.Architecture
499499- platformOS = ref.Platform.OS
500500- platformVariant = ref.Platform.Variant
501501- platformOSVersion = ref.Platform.OSVersion
502502- }
503503-504504- if err := db.InsertManifestReference(w.db, &db.ManifestReference{
505505- ManifestID: manifestID,
506506- Digest: ref.Digest,
507507- MediaType: ref.MediaType,
508508- Size: ref.Size,
509509- PlatformArchitecture: platformArch,
510510- PlatformOS: platformOS,
511511- PlatformVariant: platformVariant,
512512- PlatformOSVersion: platformOSVersion,
513513- ReferenceIndex: i,
514514- }); err != nil {
515515- // Continue on error - reference might already exist
516516- continue
517517- }
518518- }
519519- } else {
520520- // Insert layers (for image manifests)
521521- for i, layer := range manifestRecord.Layers {
522522- if err := db.InsertLayer(w.db, &db.Layer{
523523- ManifestID: manifestID,
524524- Digest: layer.Digest,
525525- MediaType: layer.MediaType,
526526- Size: layer.Size,
527527- LayerIndex: i,
528528- }); err != nil {
529529- // Continue on error - layer might already exist
530530- continue
531531- }
532532- }
533533- }
534534-535535- return nil
357357+ // Use shared processor for DB operations
358358+ _, err = w.processor.ProcessManifest(context.Background(), commit.DID, recordBytes)
359359+ return err
536360}
537361538362// processTag processes a tag commit event
539363func (w *Worker) processTag(commit *CommitEvent) error {
540364 // Resolve and upsert user with handle/PDS endpoint
541541- if err := w.ensureUser(context.Background(), commit.DID); err != nil {
365365+ if err := w.processor.EnsureUser(context.Background(), commit.DID); err != nil {
542366 return fmt.Errorf("failed to ensure user: %w", err)
543367 }
544368···557381 }
558382559383 // Parse tag record
560560- var tagRecord atproto.TagRecord
561561- if commit.Record != nil {
562562- recordBytes, err := json.Marshal(commit.Record)
563563- if err != nil {
564564- return fmt.Errorf("failed to marshal record: %w", err)
565565- }
566566- if err := json.Unmarshal(recordBytes, &tagRecord); err != nil {
567567- return fmt.Errorf("failed to unmarshal tag: %w", err)
568568- }
569569- } else {
384384+ if commit.Record == nil {
570385 return nil
571386 }
572387573573- // Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
574574- manifestDigest, err := tagRecord.GetManifestDigest()
388388+ // Marshal map to bytes for processing
389389+ recordBytes, err := json.Marshal(commit.Record)
575390 if err != nil {
576576- return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
391391+ return fmt.Errorf("failed to marshal record: %w", err)
577392 }
578393579579- // Insert or update tag
580580- return db.UpsertTag(w.db, &db.Tag{
581581- DID: commit.DID,
582582- Repository: tagRecord.Repository,
583583- Tag: tagRecord.Tag,
584584- Digest: manifestDigest,
585585- CreatedAt: tagRecord.UpdatedAt,
586586- })
394394+ // Use shared processor for DB operations
395395+ return w.processor.ProcessTag(context.Background(), commit.DID, recordBytes)
587396}
588397589398// processStar processes a star commit event
590399func (w *Worker) processStar(commit *CommitEvent) error {
591400 // Resolve and upsert the user who starred (starrer)
592592- if err := w.ensureUser(context.Background(), commit.DID); err != nil {
401401+ if err := w.processor.EnsureUser(context.Background(), commit.DID); err != nil {
593402 return fmt.Errorf("failed to ensure user: %w", err)
594403 }
595404···606415 }
607416608417 // Parse star record
609609- var starRecord atproto.StarRecord
610610- if commit.Record != nil {
611611- recordBytes, err := json.Marshal(commit.Record)
612612- if err != nil {
613613- return fmt.Errorf("failed to marshal record: %w", err)
614614- }
615615- if err := json.Unmarshal(recordBytes, &starRecord); err != nil {
616616- return fmt.Errorf("failed to unmarshal star: %w", err)
617617- }
618618- } else {
418418+ if commit.Record == nil {
619419 return nil
620420 }
621421622622- // Upsert the star record (idempotent - star count will be calculated on demand)
623623- return db.UpsertStar(w.db, commit.DID, starRecord.Subject.DID, starRecord.Subject.Repository, starRecord.CreatedAt)
422422+ // Marshal map to bytes for processing
423423+ recordBytes, err := json.Marshal(commit.Record)
424424+ if err != nil {
425425+ return fmt.Errorf("failed to marshal record: %w", err)
426426+ }
427427+428428+ // Use shared processor for DB operations
429429+ return w.processor.ProcessStar(context.Background(), commit.DID, recordBytes)
624430}
625431626432// JetstreamEvent represents a Jetstream event
+1-7
pkg/appview/storage/proxy_blob_store.go
···4040// NewProxyBlobStore creates a new proxy blob store
4141func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
4242 // Resolve DID to URL once at construction time
4343- holdURL := resolveHoldURL(ctx.HoldDID)
4343+ holdURL := appview.ResolveHoldURL(ctx.HoldDID)
44444545 fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with holdDID=%s, holdURL=%s, userDID=%s, repo=%s\n",
4646 ctx.HoldDID, holdURL, ctx.DID, ctx.Repository)
···106106 return fmt.Errorf("write access denied to hold %s", p.ctx.HoldDID)
107107 }
108108 return nil
109109-}
110110-111111-// resolveHoldURL converts a hold identifier (DID or URL) to an HTTP URL
112112-// Deprecated: Use appview.ResolveHoldURL instead
113113-func resolveHoldURL(holdDID string) string {
114114- return appview.ResolveHoldURL(holdDID)
115109}
116110117111// Stat returns the descriptor for a blob
+2-1
pkg/appview/storage/proxy_blob_store_test.go
···1111 "testing"
1212 "time"
13131414+ "atcr.io/pkg/appview"
1415 "atcr.io/pkg/atproto"
1516 "atcr.io/pkg/auth/token"
1617 "github.com/opencontainers/go-digest"
···218219219220 for _, tt := range tests {
220221 t.Run(tt.name, func(t *testing.T) {
221221- result := resolveHoldURL(tt.holdDID)
222222+ result := appview.ResolveHoldURL(tt.holdDID)
222223 if result != tt.expected {
223224 t.Errorf("Expected %s, got %s", tt.expected, result)
224225 }
+54
scripts/migrate-image.sh
···11+#!/bin/bash
22+set -e
33+44+# Configuration
55+SOURCE_REGISTRY="ghcr.io/evanjarrett/hsm-secrets-operator"
66+TARGET_REGISTRY="atcr.io/evan.jarrett.net/hsm-secrets-operator"
77+TAG="latest"
88+99+# Image digests
1010+AMD64_DIGEST="sha256:274284a623810cf07c5b4735628832751926b7d192863681d5af1b4137f44254"
1111+ARM64_DIGEST="sha256:b57929fd100033092766aad1c7e747deef9b1e3206756c11d0d7a7af74daedff"
1212+1313+echo "=== Migrating multi-arch image from GHCR to ATCR ==="
1414+echo "Source: ${SOURCE_REGISTRY}"
1515+echo "Target: ${TARGET_REGISTRY}:${TAG}"
1616+echo ""
1717+1818+# Tag and push amd64 image
1919+echo ">>> Tagging and pushing amd64 image..."
2020+docker tag "${SOURCE_REGISTRY}@${AMD64_DIGEST}" "${TARGET_REGISTRY}:${TAG}-amd64"
2121+docker push "${TARGET_REGISTRY}:${TAG}-amd64"
2222+echo ""
2323+2424+# Tag and push arm64 image
2525+echo ">>> Tagging and pushing arm64 image..."
2626+docker tag "${SOURCE_REGISTRY}@${ARM64_DIGEST}" "${TARGET_REGISTRY}:${TAG}-arm64"
2727+docker push "${TARGET_REGISTRY}:${TAG}-arm64"
2828+echo ""
2929+3030+# Create multi-arch manifest using the pushed tags
3131+echo ">>> Creating multi-arch manifest..."
3232+docker manifest create "${TARGET_REGISTRY}:${TAG}" \
3333+ --amend "${TARGET_REGISTRY}:${TAG}-amd64" \
3434+ --amend "${TARGET_REGISTRY}:${TAG}-arm64"
3535+echo ""
3636+3737+# Annotate the manifest with platform information
3838+echo ">>> Annotating manifest with platform information..."
3939+docker manifest annotate "${TARGET_REGISTRY}:${TAG}" \
4040+ "${TARGET_REGISTRY}:${TAG}-amd64" \
4141+ --os linux --arch amd64
4242+4343+docker manifest annotate "${TARGET_REGISTRY}:${TAG}" \
4444+ "${TARGET_REGISTRY}:${TAG}-arm64" \
4545+ --os linux --arch arm64
4646+echo ""
4747+4848+# Push the manifest list
4949+echo ">>> Pushing multi-arch manifest..."
5050+docker manifest push "${TARGET_REGISTRY}:${TAG}"
5151+echo ""
5252+5353+echo "=== Migration complete! ==="
5454+echo "You can now pull: docker pull ${TARGET_REGISTRY}:${TAG}"