From f99fe266602b9221291cefb8b5455856e199b274 Mon Sep 17 00:00:00 2001 From: Lewis Date: Mon, 18 May 2026 06:36:12 +0000 Subject: [PATCH] appview: ingest profile deletion Lewis: May this revision serve well! --- appview/db/profile.go | 10 ++++++++++ appview/db/profile_test.go | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/ingester.go | 24 ++++++++++++++++++++++-- 3 file(s) changed, 183 insertion(s)(+), 2 deletion(s)(-) diff --git a/appview/db/profile.go b/appview/db/profile.go --- a/appview/db/profile.go +++ b/appview/db/profile.go @@ -234,6 +234,16 @@ return tx.Commit() } +func DeleteProfile(tx *sql.Tx, did string) error { + defer tx.Rollback() + + if _, err := tx.Exec(`delete from profile where did = ?`, did); err != nil { + return err + } + + return tx.Commit() +} + func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { var conditions []string var args []any diff --git a/appview/db/profile_test.go b/appview/db/profile_test.go new file mode 100644 --- /dev/null +++ b/appview/db/profile_test.go @@ -0,0 +1,151 @@ +package db + +import ( + "database/sql" + "errors" + "testing" +) + +func seedProfile(t *testing.T, d *DB, did string) { + t.Helper() + if _, err := d.Exec( + `insert into profile (did, description, include_bluesky, location, preferred_handle) + values (?, ?, ?, ?, ?)`, + did, "hi", 0, "", "boltless.bsky.social", + ); err != nil { + t.Fatalf("seed profile: %v", err) + } + if _, err := d.Exec( + `insert into profile_links (did, link) values (?, ?)`, + did, "https://boltless.example/blog", + ); err != nil { + t.Fatalf("seed profile_links: %v", err) + } + if _, err := d.Exec( + `insert into profile_stats (did, kind) values (?, ?)`, + did, "open-pull-request-count", + ); err != nil { + t.Fatalf("seed profile_stats: %v", err) + } + if _, err := d.Exec( + `insert into profile_pinned_repositories (did, pin) values (?, ?)`, + did, "did:plc:limpet", + ); err != nil { + t.Fatalf("seed profile_pinned_repositories: %v", err) + } +} + +func countRows(t *testing.T, d *DB, query string, args ...any) int { + t.Helper() + var n int + if err := d.QueryRow(query, args...).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + return n +} + +func TestDeleteProfile_CascadesAllChildTables(t *testing.T) { + d := newTestDB(t) + const did = "did:plc:boltless" + seedProfile(t, d, did) + + if got := countRows(t, d, `select count(*) from profile where did = ?`, did); got != 1 { + t.Fatalf("pre: profile rows = %d, want 1", got) + } + if got := countRows(t, d, `select count(*) from profile_links where did = ?`, did); got != 1 { + t.Fatalf("pre: profile_links rows = %d, want 1", got) + } + if got := countRows(t, d, `select count(*) from profile_stats where did = ?`, did); got != 1 { + t.Fatalf("pre: profile_stats rows = %d, want 1", got) + } + if got := countRows(t, d, `select count(*) from profile_pinned_repositories where did = ?`, did); got != 1 { + t.Fatalf("pre: profile_pinned_repositories rows = %d, want 1", got) + } + + tx, err := d.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + if err := DeleteProfile(tx, did); err != nil { + t.Fatalf("DeleteProfile: %v", err) + } + + if got := countRows(t, d, `select count(*) from profile where did = ?`, did); got != 0 { + t.Errorf("post: profile rows = %d, want 0", got) + } + if got := countRows(t, d, `select count(*) from profile_links where did = ?`, did); got != 0 { + t.Errorf("post: profile_links rows = %d, want 0 (cascade)", got) + } + if got := countRows(t, d, `select count(*) from profile_stats where did = ?`, did); got != 0 { + t.Errorf("post: profile_stats rows = %d, want 0 (cascade)", got) + } + if got := countRows(t, d, `select count(*) from profile_pinned_repositories where did = ?`, did); got != 0 { + t.Errorf("post: profile_pinned_repositories rows = %d, want 0 (cascade)", got) + } +} + +func TestDeleteProfile_NoRowsIsNoop(t *testing.T) { + d := newTestDB(t) + + tx, err := d.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + if err := DeleteProfile(tx, "did:plc:akshay"); err != nil { + t.Errorf("DeleteProfile on missing did: %v, want nil", err) + } +} + +func TestDeleteProfile_LeavesOtherDidsAlone(t *testing.T) { + d := newTestDB(t) + seedProfile(t, d, "did:plc:boltless") + seedProfile(t, d, "did:plc:akshay") + + tx, err := d.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + if err := DeleteProfile(tx, "did:plc:boltless"); err != nil { + t.Fatalf("DeleteProfile: %v", err) + } + + if got := countRows(t, d, `select count(*) from profile where did = ?`, "did:plc:akshay"); got != 1 { + t.Errorf("other profile should survive: rows = %d, want 1", got) + } + if got := countRows(t, d, `select count(*) from profile_links where did = ?`, "did:plc:akshay"); got != 1 { + t.Errorf("other profile_links should survive: rows = %d, want 1", got) + } + if got := countRows(t, d, `select count(*) from profile_stats where did = ?`, "did:plc:akshay"); got != 1 { + t.Errorf("other profile_stats should survive: rows = %d, want 1", got) + } + if got := countRows(t, d, `select count(*) from profile_pinned_repositories where did = ?`, "did:plc:akshay"); got != 1 { + t.Errorf("other profile_pinned_repositories should survive: rows = %d, want 1", got) + } +} + +func TestGetPreferredHandle_AfterDeleteReturnsNoRows(t *testing.T) { + d := newTestDB(t) + const did = "did:plc:boltless" + seedProfile(t, d, did) + + h, err := GetPreferredHandle(d, did) + if err != nil { + t.Fatalf("GetPreferredHandle pre-delete: %v", err) + } + if string(h) != "boltless.bsky.social" { + t.Fatalf("handle = %q, want %q", h, "boltless.bsky.social") + } + + tx, err := d.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + if err := DeleteProfile(tx, did); err != nil { + t.Fatalf("DeleteProfile: %v", err) + } + + _, err = GetPreferredHandle(d, did) + if !errors.Is(err, sql.ErrNoRows) { + t.Errorf("GetPreferredHandle post-delete: err = %v, want sql.ErrNoRows", err) + } +} diff --git a/appview/ingester.go b/appview/ingester.go --- a/appview/ingester.go +++ b/appview/ingester.go @@ -579,7 +579,7 @@ } tx, err := i.Db.Begin() if err != nil { - return fmt.Errorf("failed to start transaction") + return fmt.Errorf("failed to start transaction: %w", err) } err = db.ValidateProfile(tx, &profile) @@ -602,7 +602,27 @@ l.Warn("failed to update preferred handle cache", "err", execErr) } } case jmodels.CommitOperationDelete: - err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) + tx, beginErr := i.Db.Begin() + if beginErr != nil { + return fmt.Errorf("failed to start transaction: %w", beginErr) + } + + priorHandle, phErr := db.GetPreferredHandle(tx, did) + if phErr != nil && !errors.Is(phErr, sql.ErrNoRows) { + l.Warn("failed to read prior preferred handle", "err", phErr) + } + + err = db.DeleteProfile(tx, did) + if err == nil && i.Cache != nil { + pipe := i.Cache.Pipeline() + pipe.Del(ctx, fmt.Sprintf(cache.PreferredHandleByDid, did)) + if priorHandle != "" { + pipe.Del(ctx, fmt.Sprintf(cache.PreferredHandleByHandle, string(priorHandle))) + } + if _, execErr := pipe.Exec(ctx); execErr != nil { + l.Warn("failed to evict preferred handle cache", "err", execErr) + } + } } if err != nil { -- tangled.sh