···11+description: Add user_edited flag to repo_pages to prevent auto-overwrite of manually edited descriptions
22+query: |
33+ ALTER TABLE repo_pages ADD COLUMN user_edited BOOLEAN NOT NULL DEFAULT 0;
+50-22
pkg/appview/db/queries.go
···13561356 return tags, nil
13571357}
1358135813591359-// GetUntaggedTopLevelManifestDigests returns digests of top-level manifests that have no tags.
13591359+// GetAllUntaggedManifestDigests returns digests of all untagged manifests eligible for deletion.
13601360+// Returns children of untagged manifest lists first (bottom-up) so the handler can delete
13611361+// children before parents, avoiding orphaned manifests from cascade-deleted references.
13601362// Uses the same filtering logic as GetTopLevelManifests (manifest lists + orphaned single-arch).
13611361-func GetUntaggedTopLevelManifestDigests(db DBTX, did, repository string) ([]string, error) {
13631363+func GetAllUntaggedManifestDigests(db DBTX, did, repository string) ([]string, error) {
13621364 rows, err := db.Query(`
13631365 WITH manifest_list_children AS (
13641366 SELECT DISTINCT mr.digest
13651367 FROM manifest_references mr
13661368 JOIN manifests m ON mr.manifest_id = m.id
13671369 WHERE m.did = ? AND m.repository = ?
13701370+ ),
13711371+ untagged_top_level AS (
13721372+ SELECT m.id, m.digest,
13731373+ CASE WHEN m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
13741374+ THEN 1 ELSE 0 END as is_list
13751375+ FROM manifests m
13761376+ LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
13771377+ WHERE m.did = ? AND m.repository = ?
13781378+ AND (
13791379+ m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
13801380+ OR
13811381+ m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
13821382+ )
13831383+ GROUP BY m.id
13841384+ HAVING COUNT(t.tag) = 0
13851385+ ),
13861386+ untagged_children AS (
13871387+ SELECT DISTINCT mr.digest
13881388+ FROM untagged_top_level ul
13891389+ JOIN manifest_references mr ON ul.id = mr.manifest_id
13901390+ JOIN manifests child_m ON mr.digest = child_m.digest
13911391+ AND child_m.did = ? AND child_m.repository = ?
13921392+ LEFT JOIN tags ct ON child_m.digest = ct.digest
13931393+ AND child_m.did = ct.did AND child_m.repository = ct.repository
13941394+ WHERE ul.is_list = 1 AND ct.tag IS NULL
13951395+ AND mr.digest NOT IN (
13961396+ SELECT mr2.digest FROM manifest_references mr2
13971397+ JOIN manifests m2 ON mr2.manifest_id = m2.id
13981398+ JOIN tags t2 ON m2.digest = t2.digest AND m2.did = t2.did AND m2.repository = t2.repository
13991399+ WHERE m2.did = ? AND m2.repository = ?
14001400+ )
13681401 )
13691369- SELECT m.digest
13701370- FROM manifests m
13711371- LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
13721372- WHERE m.did = ? AND m.repository = ?
13731373- AND (
13741374- m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
13751375- OR
13761376- m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
13771377- )
13781378- GROUP BY m.id
13791379- HAVING COUNT(t.tag) = 0
13801380- `, did, repository, did, repository)
14021402+ SELECT digest FROM untagged_children
14031403+ UNION ALL
14041404+ SELECT digest FROM untagged_top_level
14051405+ `, did, repository, did, repository, did, repository, did, repository)
13811406 if err != nil {
13821407 return nil, err
13831408 }
···20742099 Repository string
20752100 Description string
20762101 AvatarCID string
21022102+ UserEdited bool
20772103 CreatedAt time.Time
20782104 UpdatedAt time.Time
20792105}
2080210620812107// UpsertRepoPage inserts or updates a repo page record
20822082-func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, createdAt, updatedAt time.Time) error {
21082108+func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, userEdited bool, createdAt, updatedAt time.Time) error {
20832109 _, err := db.Exec(`
20842084- INSERT INTO repo_pages (did, repository, description, avatar_cid, created_at, updated_at)
20852085- VALUES (?, ?, ?, ?, ?, ?)
21102110+ INSERT INTO repo_pages (did, repository, description, avatar_cid, user_edited, created_at, updated_at)
21112111+ VALUES (?, ?, ?, ?, ?, ?, ?)
20862112 ON CONFLICT(did, repository) DO UPDATE SET
20872113 description = excluded.description,
20882114 avatar_cid = excluded.avatar_cid,
21152115+ user_edited = excluded.user_edited,
20892116 updated_at = excluded.updated_at
20902117 WHERE excluded.description IS NOT repo_pages.description
20912118 OR excluded.avatar_cid IS NOT repo_pages.avatar_cid
20922092- `, did, repository, description, avatarCID, createdAt, updatedAt)
21192119+ OR excluded.user_edited IS NOT repo_pages.user_edited
21202120+ `, did, repository, description, avatarCID, userEdited, createdAt, updatedAt)
20932121 return err
20942122}
20952123···20972125func GetRepoPage(db DBTX, did, repository string) (*RepoPage, error) {
20982126 var rp RepoPage
20992127 err := db.QueryRow(`
21002100- SELECT did, repository, description, avatar_cid, created_at, updated_at
21282128+ SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at
21012129 FROM repo_pages
21022130 WHERE did = ? AND repository = ?
21032103- `, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt)
21312131+ `, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt)
21042132 if err != nil {
21052133 return nil, err
21062134 }
···21182146// GetRepoPagesByDID returns all repo pages for a DID
21192147func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) {
21202148 rows, err := db.Query(`
21212121- SELECT did, repository, description, avatar_cid, created_at, updated_at
21492149+ SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at
21222150 FROM repo_pages
21232151 WHERE did = ?
21242152 `, did)
···21302158 var pages []RepoPage
21312159 for rows.Next() {
21322160 var rp RepoPage
21332133- if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt); err != nil {
21612161+ if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt); err != nil {
21342162 return nil, err
21352163 }
21362164 pages = append(pages, rp)
+155
pkg/appview/db/queries_test.go
···13761376 t.Error("Expected sha256:childdef to NOT be referenced for different user")
13771377 }
13781378}
13791379+13801380+func TestGetAllUntaggedManifestDigests(t *testing.T) {
13811381+ db, err := InitDB(":memory:", LibsqlConfig{})
13821382+ if err != nil {
13831383+ t.Fatalf("Failed to init database: %v", err)
13841384+ }
13851385+ defer db.Close()
13861386+13871387+ did := "did:plc:test123"
13881388+ repo := "myapp"
13891389+ now := time.Now()
13901390+13911391+ if err := UpsertUser(db, &User{
13921392+ DID: did,
13931393+ Handle: "test.bsky.social",
13941394+ PDSEndpoint: "https://test.pds.example.com",
13951395+ LastSeen: now,
13961396+ }); err != nil {
13971397+ t.Fatalf("Failed to insert user: %v", err)
13981398+ }
13991399+14001400+ indexType := "application/vnd.oci.image.index.v1+json"
14011401+ manifestType := "application/vnd.oci.image.manifest.v1+json"
14021402+ hold := "did:web:hold.example.com"
14031403+14041404+ insertManifest := func(t *testing.T, digest, mediaType string) int64 {
14051405+ t.Helper()
14061406+ id, err := InsertManifest(db, &Manifest{
14071407+ DID: did, Repository: repo, Digest: digest,
14081408+ HoldEndpoint: hold, SchemaVersion: 2, MediaType: mediaType,
14091409+ CreatedAt: now,
14101410+ })
14111411+ if err != nil {
14121412+ t.Fatalf("Failed to insert manifest %s: %v", digest, err)
14131413+ }
14141414+ return id
14151415+ }
14161416+14171417+ insertRef := func(t *testing.T, parentID int64, childDigest string, idx int) {
14181418+ t.Helper()
14191419+ err := InsertManifestReference(db, &ManifestReference{
14201420+ ManifestID: parentID,
14211421+ Digest: childDigest,
14221422+ Size: 1000,
14231423+ MediaType: manifestType,
14241424+ PlatformArchitecture: "amd64",
14251425+ PlatformOS: "linux",
14261426+ ReferenceIndex: idx,
14271427+ })
14281428+ if err != nil {
14291429+ t.Fatalf("Failed to insert reference: %v", err)
14301430+ }
14311431+ }
14321432+14331433+ insertTag := func(t *testing.T, digest, tag string) {
14341434+ t.Helper()
14351435+ if err := UpsertTag(db, &Tag{
14361436+ DID: did, Repository: repo, Tag: tag,
14371437+ Digest: digest, CreatedAt: now,
14381438+ }); err != nil {
14391439+ t.Fatalf("Failed to insert tag: %v", err)
14401440+ }
14411441+ }
14421442+14431443+ // Setup scenario:
14441444+ //
14451445+ // TAGGED index "sha256:tagged-index" -> tag "v1"
14461446+ // children: sha256:tagged-child-amd64, sha256:shared-child-arm64
14471447+ //
14481448+ // UNTAGGED index "sha256:untagged-index" (no tag)
14491449+ // children: sha256:untagged-child-amd64, sha256:shared-child-arm64
14501450+ //
14511451+ // UNTAGGED orphan single-arch "sha256:orphan-single" (no tag, no parent)
14521452+ //
14531453+ // TAGGED single-arch "sha256:tagged-single" -> tag "latest"
14541454+14551455+ // Tagged index + its children
14561456+ taggedIndexID := insertManifest(t, "sha256:tagged-index", indexType)
14571457+ insertManifest(t, "sha256:tagged-child-amd64", manifestType)
14581458+ insertManifest(t, "sha256:shared-child-arm64", manifestType)
14591459+ insertRef(t, taggedIndexID, "sha256:tagged-child-amd64", 0)
14601460+ insertRef(t, taggedIndexID, "sha256:shared-child-arm64", 1)
14611461+ insertTag(t, "sha256:tagged-index", "v1")
14621462+14631463+ // Untagged index + its children
14641464+ untaggedIndexID := insertManifest(t, "sha256:untagged-index", indexType)
14651465+ insertManifest(t, "sha256:untagged-child-amd64", manifestType)
14661466+ // sha256:shared-child-arm64 already inserted, just add the reference
14671467+ insertRef(t, untaggedIndexID, "sha256:untagged-child-amd64", 0)
14681468+ insertRef(t, untaggedIndexID, "sha256:shared-child-arm64", 1)
14691469+14701470+ // Orphan single-arch (no parent, no tag)
14711471+ insertManifest(t, "sha256:orphan-single", manifestType)
14721472+14731473+ // Tagged single-arch
14741474+ insertManifest(t, "sha256:tagged-single", manifestType)
14751475+ insertTag(t, "sha256:tagged-single", "latest")
14761476+14771477+ // Run the query
14781478+ digests, err := GetAllUntaggedManifestDigests(db, did, repo)
14791479+ if err != nil {
14801480+ t.Fatalf("GetAllUntaggedManifestDigests error: %v", err)
14811481+ }
14821482+14831483+ // Build sets for easy checking
14841484+ digestSet := map[string]bool{}
14851485+ for _, d := range digests {
14861486+ digestSet[d] = true
14871487+ }
14881488+14891489+ // Should include: untagged index, its exclusive child, and the orphan single
14901490+ if !digestSet["sha256:untagged-index"] {
14911491+ t.Error("Expected untagged-index to be included")
14921492+ }
14931493+ if !digestSet["sha256:untagged-child-amd64"] {
14941494+ t.Error("Expected untagged-child-amd64 to be included")
14951495+ }
14961496+ if !digestSet["sha256:orphan-single"] {
14971497+ t.Error("Expected orphan-single to be included")
14981498+ }
14991499+15001500+ // Should NOT include: tagged index, tagged children, shared child (still referenced by tagged index), tagged single
15011501+ if digestSet["sha256:tagged-index"] {
15021502+ t.Error("Expected tagged-index to NOT be included")
15031503+ }
15041504+ if digestSet["sha256:tagged-child-amd64"] {
15051505+ t.Error("Expected tagged-child-amd64 to NOT be included")
15061506+ }
15071507+ if digestSet["sha256:shared-child-arm64"] {
15081508+ t.Error("Expected shared-child-arm64 to NOT be included (still referenced by tagged index)")
15091509+ }
15101510+ if digestSet["sha256:tagged-single"] {
15111511+ t.Error("Expected tagged-single to NOT be included")
15121512+ }
15131513+15141514+ // Verify ordering: children should come before their parent index
15151515+ childIdx := -1
15161516+ parentIdx := -1
15171517+ for i, d := range digests {
15181518+ if d == "sha256:untagged-child-amd64" {
15191519+ childIdx = i
15201520+ }
15211521+ if d == "sha256:untagged-index" {
15221522+ parentIdx = i
15231523+ }
15241524+ }
15251525+ if childIdx >= 0 && parentIdx >= 0 && childIdx > parentIdx {
15261526+ t.Errorf("Expected children before parents: child at index %d, parent at index %d", childIdx, parentIdx)
15271527+ }
15281528+15291529+ // Verify total count: untagged-child-amd64, orphan-single, untagged-index = 3
15301530+ if len(digests) != 3 {
15311531+ t.Errorf("Expected 3 digests, got %d: %v", len(digests), digests)
15321532+ }
15331533+}
+1
pkg/appview/db/schema.sql
···232232 repository TEXT NOT NULL,
233233 description TEXT,
234234 avatar_cid TEXT,
235235+ user_edited BOOLEAN NOT NULL DEFAULT 0,
235236 created_at TIMESTAMP NOT NULL,
236237 updated_at TIMESTAMP NOT NULL,
237238 PRIMARY KEY(did, repository),
···378378 // Avatar is the repository avatar/icon blob reference
379379 Avatar *ATProtoBlobRef `json:"avatar,omitempty"`
380380381381+ // UserEdited indicates the description was manually edited by the user
382382+ // When true, auto-population from manifest annotations is skipped on push
383383+ UserEdited bool `json:"userEdited,omitempty"`
384384+381385 // CreatedAt timestamp
382386 CreatedAt time.Time `json:"createdAt"`
383387