package pds import ( "bytes" "context" "errors" "fmt" "log/slog" "strings" "time" "atcr.io/pkg/atproto" "github.com/bluesky-social/indigo/repo" "github.com/ipfs/go-cid" ) // AddCrewMember adds a new crew member to the hold and commits to carstore // Uses deterministic rkey based on member DID hash for O(1) lookups and automatic deduplication // If the member already exists, updates their record (upsert behavior) func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) { crewRecord := &atproto.CrewRecord{ Type: atproto.CrewCollection, Member: memberDID, Role: role, Permissions: permissions, AddedAt: time.Now().Format(time.RFC3339), } // Use deterministic rkey based on member DID hash // UpsertRecord handles create-or-update automatically rkey := atproto.CrewRecordKey(memberDID) _, recordCID, _, err := p.repomgr.UpsertRecord(ctx, p.uid, atproto.CrewCollection, rkey, crewRecord) if err != nil { return cid.Undef, fmt.Errorf("failed to upsert crew record: %w", err) } return recordCID, nil } // GetCrewMember retrieves a crew member by their record key func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (cid.Cid, *atproto.CrewRecord, error) { // Use repomgr.GetRecord - our types are registered in init() recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.CrewCollection, rkey, cid.Undef) if err != nil { return cid.Undef, nil, fmt.Errorf("failed to get crew record: %w", err) } // Type assert to our concrete type crewRecord, ok := val.(*atproto.CrewRecord) if !ok { return cid.Undef, nil, fmt.Errorf("unexpected type for crew record: %T", val) } return recordCID, crewRecord, nil } // GetCrewMemberByDID retrieves a crew member by their DID using O(1) lookup // Uses deterministic rkey based on member DID hash func (p *HoldPDS) GetCrewMemberByDID(ctx context.Context, memberDID string) (cid.Cid, *atproto.CrewRecord, error) { rkey := atproto.CrewRecordKey(memberDID) return p.GetCrewMember(ctx, rkey) } // CrewMemberWithKey pairs a crew record with its rkey and CID type CrewMemberWithKey struct { Rkey string Cid cid.Cid Record *atproto.CrewRecord } // ListCrewMembers returns all crew members with their rkeys func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, error) { var crew []*CrewMemberWithKey // Create read-only session for ForEach access // repomgr doesn't expose ForEach, so we need direct repo access session, err := p.carstore.ReadOnlySession(p.uid) if err != nil { return nil, fmt.Errorf("failed to create read-only session: %w", err) } // Get repo head head, err := p.carstore.GetUserRepoHead(ctx, p.uid) if err != nil { return nil, fmt.Errorf("failed to get repo head: %w", err) } if !head.Defined() { return nil, fmt.Errorf("repo not initialized") } // Open repo r, err := repo.OpenRepo(ctx, session, head) if err != nil { return nil, fmt.Errorf("failed to open repo: %w", err) } // Iterate over all crew records err = r.ForEach(ctx, atproto.CrewCollection, func(k string, v cid.Cid) error { // Extract collection and rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22") parts := strings.Split(k, "/") if len(parts) < 2 { return nil // Skip invalid keys } // Extract actual collection and rkey actualCollection := strings.Join(parts[:len(parts)-1], "/") rkey := parts[len(parts)-1] // MST keys are sorted, so once we hit a different collection, stop walking if actualCollection != atproto.CrewCollection { return repo.ErrDoneIterating } // Get the record directly from the repo we already have open // (calling GetCrewMember would open a new session unnecessarily) recordCID, recBytes, err := r.GetRecordBytes(ctx, k) if err != nil { return fmt.Errorf("failed to get crew record: %w", err) } // Unmarshal the CBOR bytes into our concrete type var crewRecord atproto.CrewRecord if err := crewRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil { return fmt.Errorf("failed to decode crew record: %w", err) } crew = append(crew, &CrewMemberWithKey{ Rkey: rkey, Cid: recordCID, Record: &crewRecord, }) return nil }) if err != nil { // ErrDoneIterating is expected when we stop walking early // Use errors.Is to handle wrapped errors (indigo wraps with %w in MST walk) if errors.Is(err, repo.ErrDoneIterating) { // Successfully stopped at collection boundary } else if strings.Contains(err.Error(), "not found") { // If the collection doesn't exist yet (empty repo or no records created), // return empty list instead of error return []*CrewMemberWithKey{}, nil } else { return nil, fmt.Errorf("failed to list crew members: %w", err) } } return crew, nil } // RemoveCrewMember removes a crew member by rkey func (p *HoldPDS) RemoveCrewMember(ctx context.Context, rkey string) error { // Use repomgr.DeleteRecord - it will automatically commit! // This fixes the bug where deletions weren't being committed err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.CrewCollection, rkey) if err != nil { return fmt.Errorf("failed to delete crew record: %w", err) } return nil } // RemoveCrewMemberByDID removes a crew member by their DID using O(1) lookup func (p *HoldPDS) RemoveCrewMemberByDID(ctx context.Context, memberDID string) error { rkey := atproto.CrewRecordKey(memberDID) return p.RemoveCrewMember(ctx, rkey) } // UpdateCrewMemberTier updates a crew member's tier // Uses O(1) lookup via hash-based rkey and PutRecord for atomic upsert func (p *HoldPDS) UpdateCrewMemberTier(ctx context.Context, memberDID, tier string) error { // O(1) lookup using hash-based rkey _, existing, err := p.GetCrewMemberByDID(ctx, memberDID) if err != nil { return fmt.Errorf("crew member not found: %w", err) } // If tier is already the same, no update needed if existing.Tier == tier { return nil } // Create updated record (PutRecord handles upsert with same rkey) newRecord := &atproto.CrewRecord{ Type: atproto.CrewCollection, Member: existing.Member, Role: existing.Role, Permissions: existing.Permissions, Tier: tier, Plankowner: existing.Plankowner, // Preserve early adopter flag AddedAt: existing.AddedAt, // Preserve original add time } rkey := atproto.CrewRecordKey(memberDID) _, _, err = p.repomgr.PutRecord(ctx, p.uid, atproto.CrewCollection, rkey, newRecord) if err != nil { return fmt.Errorf("failed to update crew record: %w", err) } return nil } // TODO(crew-migration): Remove this migration code after all holds have been upgraded (added 2026-01-06) // This migrates TID-based crew records to hash-based rkeys for O(1) lookups // MigrateCrewRecordsToHashRkeys migrates old TID-based crew records to hash-based rkeys // This is idempotent - records that already have hash-based rkeys are skipped // Returns the number of records migrated func (p *HoldPDS) MigrateCrewRecordsToHashRkeys(ctx context.Context) (int, error) { // List all crew members (includes both TID and hash-based rkeys) members, err := p.ListCrewMembers(ctx) if err != nil { return 0, fmt.Errorf("failed to list crew members: %w", err) } slog.Info("Starting crew record migration", "totalRecords", len(members)) migrated := 0 duplicatesDeleted := 0 alreadyHashBased := 0 seen := make(map[string]bool) // Track seen member DIDs to handle duplicates for _, m := range members { memberDID := m.Record.Member expectedRkey := atproto.CrewRecordKey(memberDID) // Skip if already using hash-based rkey if m.Rkey == expectedRkey { seen[memberDID] = true alreadyHashBased++ continue } // This is a TID-based record that needs migration slog.Info("Migrating crew record to hash-based rkey", "memberDID", memberDID, "oldRkey", m.Rkey, "newRkey", expectedRkey) // Check if we already have a hash-based record for this DID (duplicate handling) if seen[memberDID] { // Already migrated this DID, just delete the old TID record slog.Info("Deleting duplicate TID-based crew record", "memberDID", memberDID, "rkey", m.Rkey) if err := p.RemoveCrewMember(ctx, m.Rkey); err != nil { slog.Warn("Failed to delete duplicate crew record", "rkey", m.Rkey, "error", err) } else { duplicatesDeleted++ } continue } // Create new record with hash-based rkey (PutRecord handles upsert) newRecord := &atproto.CrewRecord{ Type: atproto.CrewCollection, Member: m.Record.Member, Role: m.Record.Role, Permissions: m.Record.Permissions, Tier: m.Record.Tier, AddedAt: m.Record.AddedAt, } _, _, err := p.repomgr.PutRecord(ctx, p.uid, atproto.CrewCollection, expectedRkey, newRecord) if err != nil { slog.Error("Failed to create hash-based crew record", "memberDID", memberDID, "error", err) continue } // Delete the old TID-based record if err := p.RemoveCrewMember(ctx, m.Rkey); err != nil { slog.Warn("Failed to delete old TID-based crew record", "rkey", m.Rkey, "error", err) // Continue anyway - the new record is created } seen[memberDID] = true migrated++ } slog.Info("Crew record migration complete", "migrated", migrated, "duplicatesDeleted", duplicatesDeleted, "alreadyHashBased", alreadyHashBased, "totalRecords", len(members)) return migrated, nil }