A community based topic aggregation platform built on atproto

feat(users): add batch user loading for N+1 query prevention

Add GetByDIDs repository method to fetch multiple users in a single query,
preventing N+1 performance issues when hydrating comment authors in threads.

Changes:
- Add GetByDIDs() method to UserRepository interface
- Implement batch query using PostgreSQL ANY() with pq.Array type conversion
- Returns map[string]*User for O(1) lookups by DID
- Gracefully handles missing users (no error, just excluded from result map)

Performance impact:
- Before: N separate queries (1 per comment author)
- After: 1 batch query for all authors in thread
- ~10-100x faster for threads with many unique authors

Implementation uses parameterized query with PostgreSQL array support:
```sql
SELECT did, handle, pds_url, created_at, updated_at
FROM users WHERE did = ANY($1)
```

This is a foundational optimization for Phase 2C metadata hydration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+39
internal
core
db
postgres
+1
internal/core/users/interfaces.go
···
GetByDID(ctx context.Context, did string) (*User, error)
GetByHandle(ctx context.Context, handle string) (*User, error)
UpdateHandle(ctx context.Context, did, newHandle string) (*User, error)
}
// UserService defines the interface for user business logic
···
GetByDID(ctx context.Context, did string) (*User, error)
GetByHandle(ctx context.Context, handle string) (*User, error)
UpdateHandle(ctx context.Context, did, newHandle string) (*User, error)
+
GetByDIDs(ctx context.Context, dids []string) (map[string]*User, error)
}
// UserService defines the interface for user business logic
+38
internal/db/postgres/user_repo.go
···
"database/sql"
"fmt"
"strings"
)
type postgresUserRepo struct {
···
return user, nil
}
···
"database/sql"
"fmt"
"strings"
+
+
"github.com/lib/pq"
)
type postgresUserRepo struct {
···
return user, nil
}
+
+
// GetByDIDs retrieves multiple users by their DIDs in a single query
+
// Returns a map of DID -> User for efficient lookups
+
// Missing users are not included in the result map (no error for missing users)
+
func (r *postgresUserRepo) GetByDIDs(ctx context.Context, dids []string) (map[string]*users.User, error) {
+
if len(dids) == 0 {
+
return make(map[string]*users.User), nil
+
}
+
+
// Build parameterized query with IN clause
+
// Use ANY($1) for PostgreSQL array support with pq.Array() for type conversion
+
query := `SELECT did, handle, pds_url, created_at, updated_at FROM users WHERE did = ANY($1)`
+
+
rows, err := r.db.QueryContext(ctx, query, pq.Array(dids))
+
if err != nil {
+
return nil, fmt.Errorf("failed to query users by DIDs: %w", err)
+
}
+
defer rows.Close()
+
+
// Build map of results
+
result := make(map[string]*users.User, len(dids))
+
for rows.Next() {
+
user := &users.User{}
+
err := rows.Scan(&user.DID, &user.Handle, &user.PDSURL, &user.CreatedAt, &user.UpdatedAt)
+
if err != nil {
+
return nil, fmt.Errorf("failed to scan user row: %w", err)
+
}
+
result[user.DID] = user
+
}
+
+
if err = rows.Err(); err != nil {
+
return nil, fmt.Errorf("error iterating user rows: %w", err)
+
}
+
+
return result, nil
+
}