From 08272197168b67a0d713a439e4e8d75875b8d6d0 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Fri, 27 Feb 2026 20:14:26 -0600 Subject: [PATCH] cleanup relay-compare script --- cmd/relay-compare/main.go | 171 +++++++++++++++++++++++++++++--------- 1 file changed, 131 insertions(+), 40 deletions(-) diff --git a/cmd/relay-compare/main.go b/cmd/relay-compare/main.go index 47cb795..f1b5c38 100644 --- a/cmd/relay-compare/main.go +++ b/cmd/relay-compare/main.go @@ -8,8 +8,10 @@ package main import ( "context" + "encoding/json" "flag" "fmt" + "net/http" "net/url" "os" "sort" @@ -17,7 +19,9 @@ import ( "sync" "time" - "atcr.io/pkg/atproto" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/xrpc" ) // ANSI color codes (disabled via --no-color or NO_COLOR env) @@ -37,16 +41,16 @@ func disableColors() { // All io.atcr.* collections to compare var allCollections = []string{ - atproto.ManifestCollection, // io.atcr.manifest - atproto.TagCollection, // io.atcr.tag - atproto.SailorProfileCollection, // io.atcr.sailor.profile - atproto.StarCollection, // io.atcr.sailor.star - atproto.RepoPageCollection, // io.atcr.repo.page - atproto.CaptainCollection, // io.atcr.hold.captain - atproto.CrewCollection, // io.atcr.hold.crew - atproto.LayerCollection, // io.atcr.hold.layer - atproto.StatsCollection, // io.atcr.hold.stats - atproto.ScanCollection, // io.atcr.hold.scan + "io.atcr.manifest", + "io.atcr.tag", + "io.atcr.sailor.profile", + "io.atcr.sailor.star", + "io.atcr.repo.page", + "io.atcr.hold.captain", + "io.atcr.hold.crew", + "io.atcr.hold.layer", + "io.atcr.hold.stats", + "io.atcr.hold.scan", } type summaryRow struct { @@ -76,9 +80,29 @@ type diffEntry struct { relayIdx int } +// XRPC response types for listReposByCollection +type listReposByCollectionResult struct { + Repos []repoRef `json:"repos"` + Cursor string `json:"cursor,omitempty"` +} + +type repoRef struct { + DID string `json:"did"` +} + +// XRPC response types for listRecords +type listRecordsResult struct { + Records []json.RawMessage `json:"records"` + Cursor string `json:"cursor,omitempty"` +} + +// Shared identity directory for DID resolution +var dir identity.Directory + func main() { noColor := flag.Bool("no-color", false, "disable colored output") verify := flag.Bool("verify", false, "verify diffs against PDS to distinguish real gaps from ghost entries") + hideGhosts := flag.Bool("hide-ghosts", false, "with --verify, hide ghost and deactivated entries from output") collection := flag.String("collection", "", "compare only this collection") timeout := flag.Duration("timeout", 2*time.Minute, "timeout for all relay queries") flag.Usage = func() { @@ -113,6 +137,8 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() + dir = identity.DefaultDirectory() + // Short display names for each relay names := make([]string, len(relays)) maxNameLen := 0 @@ -253,6 +279,7 @@ func main() { fmt.Printf("\n %sMissing from %s (%d):%s\n", cRed, names[ri], len(missing), cReset) for _, did := range missing { suffix := "" + skip := false if *verify { vr, ok := verified[key{col, did}] if !ok { @@ -263,6 +290,7 @@ func main() { suffix = fmt.Sprintf(" %s← deactivated%s", cDim, cReset) row.deactivated++ totalDeactivated++ + skip = *hideGhosts } else if vr.exists { suffix = fmt.Sprintf(" %s← real gap%s", cRed, cReset) row.realGaps++ @@ -271,9 +299,12 @@ func main() { suffix = fmt.Sprintf(" %s← ghost (not on PDS)%s", cDim, cReset) row.ghosts++ totalGhosts++ + skip = *hideGhosts } } - fmt.Printf(" %s- %s%s%s\n", cRed, did, cReset, suffix) + if !skip { + fmt.Printf(" %s- %s%s%s\n", cRed, did, cReset, suffix) + } } } @@ -283,7 +314,10 @@ func main() { } if inSync { - notes := formatSyncNotes(row.ghosts, row.deactivated) + notes := "" + if !*hideGhosts { + notes = formatSyncNotes(row.ghosts, row.deactivated) + } if notes != "" { fmt.Printf(" %s✓ in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset) } else { @@ -297,28 +331,38 @@ func main() { } // Summary table - printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts, totalDeactivated) + printSummary(summary, names, maxNameLen, totalMissing, *verify, *hideGhosts, totalRealGaps, totalGhosts, totalDeactivated) } -func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts, totalDeactivated int) { +func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify, hideGhosts bool, totalRealGaps, totalGhosts, totalDeactivated int) { fmt.Printf("\n%s%s━━━ Summary ━━━%s\n\n", cBold, cCyan, cReset) - colW := 28 - relayW := maxNameLen + 2 - if relayW < 8 { - relayW = 8 + // Build short labels (A, B, C, ...) for compact columns + labels := make([]string, len(names)) + for i, name := range names { + labels[i] = string(rune('A' + i)) + fmt.Printf(" %s%s%s: %s\n", cBold, labels[i], cReset, name) } + fmt.Println() + + colW := len("Collection") + for _, row := range rows { + if len(row.collection) > colW { + colW = len(row.collection) + } + } + relayW := 6 // Header fmt.Printf(" %-*s", colW, "Collection") - for _, name := range names { - fmt.Printf(" %*s", relayW, name) + for _, label := range labels { + fmt.Printf(" %*s", relayW, label) } fmt.Printf(" Status\n") // Separator fmt.Printf(" %s", strings.Repeat("─", colW)) - for range names { + for range labels { fmt.Printf(" %s", strings.Repeat("─", relayW)) } fmt.Printf(" %s\n", strings.Repeat("─", 14)) @@ -336,7 +380,10 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in } switch row.status { case "sync": - notes := formatSyncNotes(row.ghosts, row.deactivated) + notes := "" + if !hideGhosts { + notes = formatSyncNotes(row.ghosts, row.deactivated) + } if notes != "" { fmt.Printf(" %s✓ in sync%s %s(%s)%s", cGreen, cReset, cDim, notes, cReset) } else { @@ -344,12 +391,16 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in } case "diff": if showVerify { - notes := formatSyncNotes(row.ghosts, row.deactivated) - if notes != "" { - notes = ", " + notes + if hideGhosts { + fmt.Printf(" %s≠ %d missing%s", cYellow, row.realGaps, cReset) + } else { + notes := formatSyncNotes(row.ghosts, row.deactivated) + if notes != "" { + notes = ", " + notes + } + fmt.Printf(" %s≠ %d missing%s %s(%d real%s)%s", + cYellow, row.realGaps, cReset, cDim, row.realGaps, notes, cReset) } - fmt.Printf(" %s≠ %d missing%s %s(%s)%s", - cYellow, row.realGaps, cReset, cDim, fmt.Sprintf("%d real%s", row.realGaps, notes), cReset) } else { fmt.Printf(" %s≠ %d missing%s", cYellow, row.diffCount, cReset) } @@ -363,14 +414,20 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in fmt.Println() if totalMissing > 0 { if showVerify && totalRealGaps == 0 { - notes := formatSyncNotes(totalGhosts, totalDeactivated) - fmt.Printf("%s✓ All relays in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset) + if hideGhosts { + fmt.Printf("%s✓ All relays in sync%s\n", cGreen, cReset) + } else { + notes := formatSyncNotes(totalGhosts, totalDeactivated) + fmt.Printf("%s✓ All relays in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset) + } } else { if showVerify { fmt.Printf("%s%d real gaps across relays%s", cYellow, totalRealGaps, cReset) - notes := formatSyncNotes(totalGhosts, totalDeactivated) - if notes != "" { - fmt.Printf(" %s(%s)%s", cDim, notes, cReset) + if !hideGhosts { + notes := formatSyncNotes(totalGhosts, totalDeactivated) + if notes != "" { + fmt.Printf(" %s(%s)%s", cDim, notes, cReset) + } } fmt.Println() } else { @@ -425,7 +482,7 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult { sem <- struct{}{} defer func() { <-sem }() - pds, err := atproto.ResolveDIDToPDS(ctx, did) + pds, err := resolveDIDToPDS(ctx, did) mu.Lock() if err != nil { pdsErrors[did] = err @@ -466,8 +523,13 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult { } pds := pdsEndpoints[dc.did] - client := atproto.NewClient(pds, "", "") - records, _, err := client.ListRecordsForRepo(ctx, dc.did, dc.col, 1, "") + client := &xrpc.Client{Host: pds, Client: http.DefaultClient} + var listResult listRecordsResult + err := client.LexDo(ctx, "GET", "", "com.atproto.repo.listRecords", map[string]any{ + "repo": dc.did, + "collection": dc.col, + "limit": 1, + }, nil, &listResult) mu.Lock() if err != nil { errStr := err.Error() @@ -480,7 +542,7 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult { results[k] = verifyResult{err: err} } } else { - results[k] = verifyResult{exists: len(records) > 0} + results[k] = verifyResult{exists: len(listResult.Records) > 0} } mu.Unlock() }(dc) @@ -490,16 +552,45 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult { return results } +// resolveDIDToPDS resolves a DID to its PDS endpoint using the shared identity directory. +func resolveDIDToPDS(ctx context.Context, did string) (string, error) { + didParsed, err := syntax.ParseDID(did) + if err != nil { + return "", fmt.Errorf("invalid DID: %w", err) + } + + ident, err := dir.LookupDID(ctx, didParsed) + if err != nil { + return "", fmt.Errorf("failed to resolve DID: %w", err) + } + + pdsEndpoint := ident.PDSEndpoint() + if pdsEndpoint == "" { + return "", fmt.Errorf("no PDS endpoint found for DID") + } + + return pdsEndpoint, nil +} + // fetchAllDIDs paginates through listReposByCollection to collect all DIDs. func fetchAllDIDs(ctx context.Context, relay, collection string) (map[string]struct{}, error) { - client := atproto.NewClient(relay, "", "") + client := &xrpc.Client{Host: relay, Client: http.DefaultClient} dids := make(map[string]struct{}) var cursor string for { - result, err := client.ListReposByCollection(ctx, collection, 1000, cursor) + params := map[string]any{ + "collection": collection, + "limit": 1000, + } + if cursor != "" { + params["cursor"] = cursor + } + + var result listReposByCollectionResult + err := client.LexDo(ctx, "GET", "", "com.atproto.sync.listReposByCollection", params, nil, &result) if err != nil { - return dids, err + return dids, fmt.Errorf("listReposByCollection failed: %w", err) } for _, repo := range result.Repos {