mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 20:57:01 +00:00
296 lines
9.3 KiB
Go
296 lines
9.3 KiB
Go
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
|
|
}
|