package migration import ( "context" "encoding/json" "fmt" "strings" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" ) func (s *Migration) ensureCreatedAt(ctx context.Context, client *atclient.APIClient, did syntax.DID, record syntax.ATURI, current string) (string, error) { if t, err := time.Parse(time.RFC3339, current); err == nil { return t.UTC().Format(time.RFC3339), nil } var raw struct { Value struct { CreatedAt *string `json:"createdAt,omitempty"` AddedAt *string `json:"addedAt,omitempty"` } `json:"value"` } params := map[string]any{ "collection": record.Collection().String(), "repo": did.String(), "rkey": record.RecordKey().String(), } if err := client.LexDo(ctx, lexutil.Query, "", "com.atproto.repo.getRecord", params, nil, &raw); err != nil { return "", fmt.Errorf("ensure createdAt: get record: %w", err) } for _, cand := range []*string{raw.Value.CreatedAt, raw.Value.AddedAt} { if cand == nil || *cand == "" { continue } if t, err := time.Parse(time.RFC3339, *cand); err == nil { return t.UTC().Format(time.RFC3339), nil } } s.logger.Warn("createdAt unparseable, defaulting to now", "record", record.String()) return time.Now().UTC().Format(time.RFC3339), nil } func (s *Migration) migrateAddRepoDid(ctx context.Context, client *atclient.APIClient, did syntax.DID, record syntax.ATURI) error { if record.Collection().String() == tangled.FeedStarNSID { return s.migrateAddRepoDidStar(ctx, client, did, record) } ex, err := comatproto.RepoGetRecord(ctx, client, "", record.Collection().String(), did.String(), record.RecordKey().String()) if err != nil { return fmt.Errorf("pds: %w", err) } val := ex.Value.Val switch record.Collection().String() { case tangled.RepoNSID: rec, ok := val.(*tangled.Repo) if !ok { return fmt.Errorf("unexpected type for repo record") } repo, err := db.GetRepoByAtUri(s.db, record.String()) if err != nil { return fmt.Errorf("db: failed to query repo: %w", err) } rec.RepoDid = &repo.RepoDid rec.CreatedAt, err = s.ensureCreatedAt(ctx, client, did, record, rec.CreatedAt) if err != nil { return err } case tangled.RepoIssueNSID: rec, ok := val.(*tangled.RepoIssue) if !ok { return fmt.Errorf("unexpected type for issue record") } if strings.HasPrefix(rec.Repo, "did:") { return nil } repo, err := db.GetRepoByAtUri(s.db, rec.Repo) if err != nil { return fmt.Errorf("db: failed to query repo by at_uri %q: %w", rec.Repo, err) } rec.Repo = repo.RepoDid rec.CreatedAt, err = s.ensureCreatedAt(ctx, client, did, record, rec.CreatedAt) if err != nil { return err } case tangled.RepoPullNSID: rec, ok := val.(*tangled.RepoPull) if !ok { return fmt.Errorf("unexpected type for pull record") } if rec.Target == nil { return fmt.Errorf("pull record has nil target") } if !strings.HasPrefix(rec.Target.Repo, "did:") { repo, err := db.GetRepoByAtUri(s.db, rec.Target.Repo) if err != nil { return fmt.Errorf("db: failed to query target repo by at_uri %q: %w", rec.Target.Repo, err) } rec.Target.Repo = repo.RepoDid } if rec.Source != nil && rec.Source.Repo != nil && !strings.HasPrefix(*rec.Source.Repo, "did:") { sourceRepo, srcErr := db.GetRepoByAtUri(s.db, *rec.Source.Repo) if srcErr == nil && sourceRepo.RepoDid != "" { rec.Source.Repo = &sourceRepo.RepoDid } } rec.CreatedAt, err = s.ensureCreatedAt(ctx, client, did, record, rec.CreatedAt) if err != nil { return err } case tangled.RepoCollaboratorNSID: rec, ok := val.(*tangled.RepoCollaborator) if !ok { return fmt.Errorf("unexpected type for collaborator record") } if strings.HasPrefix(rec.Repo, "did:") { return nil } repo, err := db.GetRepoByAtUri(s.db, rec.Repo) if err != nil { return fmt.Errorf("db: failed to query repo by at_uri %q: %w", rec.Repo, err) } rec.Repo = repo.RepoDid rec.CreatedAt, err = s.ensureCreatedAt(ctx, client, did, record, rec.CreatedAt) if err != nil { return err } case tangled.RepoArtifactNSID: rec, ok := val.(*tangled.RepoArtifact) if !ok { return fmt.Errorf("unexpected type for artifact record") } if rec.Repo != nil { repo, err := db.GetRepoByAtUri(s.db, *rec.Repo) if err != nil { return fmt.Errorf("db: failed to query repo by at_uri %q: %w", *rec.Repo, err) } rec.RepoDid = &repo.RepoDid } rec.CreatedAt, err = s.ensureCreatedAt(ctx, client, did, record, rec.CreatedAt) if err != nil { return err } case tangled.ActorProfileNSID: rec, ok := val.(*tangled.ActorProfile) if !ok { return fmt.Errorf("unexpected type for profile record") } rewritten := make([]string, 0, len(rec.PinnedRepositories)) for _, pin := range rec.PinnedRepositories { if strings.HasPrefix(pin, "did:") { rewritten = append(rewritten, pin) continue } repo, repoErr := db.GetRepoByAtUri(s.db, pin) if repoErr != nil || repo.RepoDid == "" { rewritten = append(rewritten, pin) continue } rewritten = append(rewritten, repo.RepoDid) } rec.PinnedRepositories = rewritten default: return fmt.Errorf("unexpected collection: '%s'", record.Collection()) } _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Repo: did.String(), Collection: record.Collection().String(), Rkey: record.RecordKey().String(), SwapRecord: ex.Cid, Record: &lexutil.LexiconTypeDecoder{Val: val}, }) if err != nil { return fmt.Errorf("put record: %w", err) } return nil } func (s *Migration) migrateAddRepoDidStar(ctx context.Context, client *atclient.APIClient, did syntax.DID, record syntax.ATURI) error { var raw struct { Cid *string `json:"cid,omitempty"` Uri string `json:"uri"` Value json.RawMessage `json:"value"` } params := map[string]any{ "collection": record.Collection().String(), "repo": did.String(), "rkey": record.RecordKey().String(), } if err := client.LexDo(ctx, lexutil.Query, "", "com.atproto.repo.getRecord", params, nil, &raw); err != nil { return fmt.Errorf("get record: %w", err) } var legacy struct { CreatedAt string `json:"createdAt"` AddedAt string `json:"addedAt"` Subject *string `json:"subject,omitempty"` } if err := json.Unmarshal(raw.Value, &legacy); err != nil { return fmt.Errorf("decode old star fields: %w", err) } if legacy.Subject == nil { return fmt.Errorf("star record has no subject field") } repo, err := db.GetRepoByAtUri(s.db, *legacy.Subject) if err != nil { return fmt.Errorf("db: failed to query repo by at_uri %q: %w", *legacy.Subject, err) } if repo.RepoDid == "" { return fmt.Errorf("repo has no repoDid: %s", *legacy.Subject) } createdAt := legacy.CreatedAt if t, err := time.Parse(time.RFC3339, createdAt); err == nil { createdAt = t.UTC().Format(time.RFC3339) } else if t, err := time.Parse(time.RFC3339, legacy.AddedAt); err == nil { createdAt = t.UTC().Format(time.RFC3339) } else { s.logger.Warn("star createdAt unparseable, defaulting to now", "record", record.String()) createdAt = time.Now().UTC().Format(time.RFC3339) } newRecord := &tangled.FeedStar{ CreatedAt: createdAt, Subject: &tangled.FeedStar_Subject{ FeedStar_Repo: &tangled.FeedStar_Repo{Did: repo.RepoDid}, }, } _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Repo: did.String(), Collection: record.Collection().String(), Rkey: record.RecordKey().String(), SwapRecord: raw.Cid, Record: &lexutil.LexiconTypeDecoder{Val: newRecord}, }) if err != nil { return fmt.Errorf("put record: %w", err) } return nil }