+22
cmd/server/main.go
+22
cmd/server/main.go
············
············
+1125
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
+1125
docs/COMMENT_SYSTEM_IMPLEMENTATION.md
···
···+This document details the complete implementation of the comment system for Coves, a forum-like atProto social media platform. The comment system follows the established vote system pattern, with comments living in user repositories and being indexed by the AppView via Jetstream firehose.+**Last Updated:** November 6, 2025 (Final PR review fixes complete - lexicon compliance, data integrity, SQL correctness)+1. `internal/db/postgres/comment_repo.go` - Added query methods (~450 lines), fixed INNER→LEFT JOIN, fixed window function SQL+After initial implementation, a thorough PR review identified several critical issues that were addressed before production deployment:+- **Files:** `internal/core/comments/interfaces.go`, `internal/db/postgres/comment_repo.go`, `internal/core/comments/comment_service.go`+- **Problem:** When fetching comments for non-existent post, service returned wrapped `posts.ErrNotFound` which handler didn't recognize+- **Problem:** Comment queries with deep nesting expensive but only protected by global 100 req/min limit+- **Problem:** No validation before base64 decoding - attacker could send massive cursor string+- **Problem:** Both `postView.record` and `commentView.record` fields were null despite lexicon marking them as required+- `postView.author.handle` contained DID instead of proper handle (violates `format:"handle"`)+- **Impact:** Lexicon format constraints violated, poor UX showing DIDs instead of readable names+- **Files:** `internal/core/comments/comment_service.go:34-37`, `:292-325`, `cmd/server/main.go:297`+- **Problem:** Three query methods used `INNER JOIN users` which dropped comments when user not indexed yet+- **Impact:** New user's first comments would disappear until user consumer caught up (violates out-of-order design)+- **Files:** `internal/db/postgres/comment_repo.go:396`, `:407`, `:415`, `:694-706`, `:761-836`+- **Problem:** `ListByParentsBatch` used `ORDER BY hot_rank DESC` in window function, but PostgreSQL doesn't allow SELECT aliases in window ORDER BY+- **Impact:** SQL error "column hot_rank does not exist" caused silent failure, dropping ALL nested replies in hot sort mode+- **Critical Note:** This affected default sorting mode (hot) and would have broken production UX+1. **User-Owned Records**: Comments live in user repositories (like votes), not community repositories (like posts)+4. **Out-of-Order Indexing**: No foreign key constraints to allow Jetstream events to arrive in any order+- **No FK on `commenter_did`**: Allows out-of-order Jetstream indexing (comment events may arrive before user events)+- **Vote counts included**: The vote lexicon explicitly allows voting on comments (not just posts)+ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error)+Standard error types following the vote system pattern, with helper functions `IsNotFound()` and `IsConflict()`.+func (c *CommentEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEvent) error {+func (c *CommentEventConsumer) validateCommentEvent(ctx, repoDID string, comment *CommentRecord) error {+commentJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"+commentJetstreamConnector := jetstream.NewCommentJetstreamConnector(commentEventConsumer, commentJetstreamURL)+The vote lexicon explicitly states: *"Record declaring a vote (upvote or downvote) on a **post or comment**"*+**Problem Solved:** The initial implementation had a classic N+1 query problem where nested reply loading made separate database queries for each comment's children. For a post with 50 top-level comments and 3 levels of depth, this could result in ~1,551 queries.+The comment system has successfully completed **Phase 1 (Indexing)** and **Phase 2A (Query API)**, providing a production-ready threaded discussion system for Coves:+The implementation provides a solid foundation for building rich threaded discussions in Coves while maintaining compatibility with the broader atProto ecosystem and following established patterns from platforms like Lemmy and Reddit.+TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \+TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \+TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \+GOOSE_DBSTRING="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable" \+export COMMENT_JETSTREAM_URL="ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.comment"+export TEST_DATABASE_URL="postgres://test_user:test_password@localhost:5434/coves_test?sslmode=disable"
+44
internal/api/handlers/comments/errors.go
+44
internal/api/handlers/comments/errors.go
···
···
+167
internal/api/handlers/comments/get_comments.go
+167
internal/api/handlers/comments/get_comments.go
···
···
+22
internal/api/handlers/comments/middleware.go
+22
internal/api/handlers/comments/middleware.go
···
···+// OptionalAuthMiddleware wraps the existing OptionalAuth middleware from the middleware package.+// This ensures comment handlers can access viewer identity when available, but don't require authentication.+func OptionalAuthMiddleware(authMiddleware *middleware.AtProtoAuthMiddleware, next http.HandlerFunc) http.Handler {
+37
internal/api/handlers/comments/service_adapter.go
+37
internal/api/handlers/comments/service_adapter.go
···
···+// This bridges the gap between HTTP-layer concerns (http.Request) and domain-layer concerns (context.Context)+func (a *ServiceAdapter) GetComments(r *http.Request, req *GetCommentsRequest) (*comments.GetCommentsResponse, error) {
+6
-8
internal/atproto/jetstream/comment_consumer.go
+6
-8
internal/atproto/jetstream/comment_consumer.go
······comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs),······func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) {···
······comment.Content, comment.ContentFacets, comment.Embed, comment.ContentLabels, pq.Array(comment.Langs),······func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) {···
+8
-3
internal/atproto/jetstream/community_consumer.go
+8
-3
internal/atproto/jetstream/community_consumer.go
···-identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) } // For resolving handles from DIDsdidCache *lru.Cache[string, cachedDIDDoc] // Bounded LRU cache for .well-known verification results···-func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface{ Resolve(context.Context, string) (*identity.Identity, error) }) *CommunityEventConsumer {
···didCache *lru.Cache[string, cachedDIDDoc] // Bounded LRU cache for .well-known verification results···+func NewCommunityEventConsumer(repo communities.Repository, instanceDID string, skipVerification bool, identityResolver interface {
+3
-3
internal/atproto/jetstream/post_consumer.go
+3
-3
internal/atproto/jetstream/post_consumer.go
······
······
+32
-39
internal/core/comments/comment.go
+32
-39
internal/core/comments/comment.go
······
······
+460
internal/core/comments/comment_service.go
+460
internal/core/comments/comment_service.go
···
···+func (s *commentService) GetComments(ctx context.Context, req *GetCommentsRequest) (*GetCommentsResponse, error) {+// voteState, err := s.commentRepo.GetVoteStateForComments(ctx, *viewerDID, []string{comment.URI})+func (s *commentService) buildPostView(ctx context.Context, post *posts.Post, viewerDID *string) *posts.PostView {+return fmt.Errorf("invalid timeframe: must be one of [hour, day, week, month, year, all], got '%s'", req.Timeframe)
+7
internal/core/comments/errors.go
+7
internal/core/comments/errors.go
···
+33
internal/core/comments/interfaces.go
+33
internal/core/comments/interfaces.go
···+// ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination+GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error)
+65
internal/core/comments/view_models.go
+65
internal/core/comments/view_models.go
···
···
+5
-5
internal/core/posts/post.go
+5
-5
internal/core/posts/post.go
·········
·········
+2
-2
internal/core/posts/service.go
+2
-2
internal/core/posts/service.go
···
···
+587
internal/db/postgres/comment_repo.go
+587
internal/db/postgres/comment_repo.go
······+// ListByParentWithHotRank retrieves direct replies to a post or comment with sorting and pagination+// Supports three sort modes: hot (Lemmy algorithm), top (by score + timeframe), and new (by created_at)+// log(greatest(2, score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) + 2), 1.8)+log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,+// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)+func (r *postgresCommentRepo) buildCommentSortClause(sort, timeframe string) (string, string) {+func (r *postgresCommentRepo) parseCommentCursor(cursor *string, sort string) (string, []interface{}, error) {+filter := `AND (c.score < $3 OR (c.score = $3 AND c.created_at < $4) OR (c.score = $3 AND c.created_at = $4 AND c.uri < $5))`+hotRankExpr := `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8)`+filter := fmt.Sprintf(`AND ((%s < $3 OR (%s = $3 AND c.score < $4) OR (%s = $3 AND c.score = $4 AND c.created_at < $5) OR (%s = $3 AND c.score = $4 AND c.created_at = $5 AND c.uri < $6)) AND c.uri != $7)`,+func (r *postgresCommentRepo) buildCommentCursor(comment *comments.Comment, sort string, hotRank float64) string {+func (r *postgresCommentRepo) GetByURIsBatch(ctx context.Context, uris []string) (map[string]*comments.Comment, error) {+// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)+log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,+// CRITICAL: Must inline hot_rank formula - PostgreSQL doesn't allow SELECT aliases in window ORDER BY+windowOrderBy = `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) DESC, c.score DESC, c.created_at DESC`+log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) as hot_rank,+// CRITICAL: Must inline hot_rank formula - PostgreSQL doesn't allow SELECT aliases in window ORDER BY+windowOrderBy = `log(greatest(2, c.score + 2)) / power(((EXTRACT(EPOCH FROM (NOW() - c.created_at)) / 3600) + 2), 1.8) DESC, c.score DESC, c.created_at DESC`+// LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events)+func (r *postgresCommentRepo) GetVoteStateForComments(ctx context.Context, viewerDID string, commentURIs []string) (map[string]interface{}, error) {
+2
-2
tests/integration/comment_consumer_test.go
+2
-2
tests/integration/comment_consumer_test.go
······
······
+928
tests/integration/comment_query_test.go
+928
tests/integration/comment_query_test.go
···
···+postURI := createTestPost(t, db, testCommunity, testUser.DID, "Basic Fetch Test Post", 0, time.Now())+comment1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "First comment", 10, 2, time.Now().Add(-2*time.Hour))+comment2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Second comment", 5, 1, time.Now().Add(-30*time.Minute))+comment3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Third comment", 3, 0, time.Now().Add(-5*time.Minute))+postURI := createTestPost(t, db, testCommunity, testUser.DID, "Nested Test Post", 0, time.Now())+commentA := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment A", 5, 0, time.Now().Add(-1*time.Hour))+replyA1 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A1", 3, 0, time.Now().Add(-50*time.Minute))+replyA1a := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1a", 2, 0, time.Now().Add(-40*time.Minute))+replyA1b := createTestCommentWithScore(t, db, testUser.DID, postURI, replyA1, "Reply A1b", 1, 0, time.Now().Add(-30*time.Minute))+replyA2 := createTestCommentWithScore(t, db, testUser.DID, postURI, commentA, "Reply A2", 2, 0, time.Now().Add(-20*time.Minute))+commentB := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Comment B", 4, 0, time.Now().Add(-10*time.Minute))+assert.Len(t, commentAThread.Replies, 2, "Comment A should have 2 direct replies (A1 and A2)")+assert.Len(t, replyA1Thread.Replies, 2, "Reply A1 should have 2 nested replies (A1a and A1b)")+postURI := createTestPost(t, db, testCommunity, testUser.DID, "Depth Test Post", 0, time.Now())+c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Level 1", 5, 0, time.Now().Add(-5*time.Minute))+c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, c1, "Level 2", 4, 0, time.Now().Add(-4*time.Minute))+c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, c2, "Level 3", 3, 0, time.Now().Add(-3*time.Minute))+c4 := createTestCommentWithScore(t, db, testUser.DID, postURI, c3, "Level 4", 2, 0, time.Now().Add(-2*time.Minute))+c5 := createTestCommentWithScore(t, db, testUser.DID, postURI, c4, "Level 5", 1, 0, time.Now().Add(-1*time.Minute))+assert.True(t, resp.Comments[0].HasMore, "HasMore should be true when replies exist but depth=0")+assert.True(t, level4.HasMore, "HasMore should be true at depth boundary when more replies exist")+postURI := createTestPost(t, db, testCommunity, testUser.DID, "Hot Sorting Test", 0, time.Now())+c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Old high score", 10, 0, time.Now().Add(-1*time.Hour))+c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Recent medium score", 5, 0, time.Now().Add(-5*time.Minute))+c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Negative score", 0, 2, time.Now())+postURI := createTestPost(t, db, testCommunity, testUser.DID, "Top Sorting Test", 0, time.Now())+c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Low score", 2, 0, time.Now().Add(-30*time.Minute))+c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "High score", 10, 0, time.Now().Add(-1*time.Hour))+c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Medium score", 5, 0, time.Now().Add(-15*time.Minute))+postURI := createTestPost(t, db, testCommunity, testUser.DID, "New Sorting Test", 0, time.Now())+c1 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Oldest", 10, 0, time.Now().Add(-1*time.Hour))+c2 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Middle", 5, 0, time.Now().Add(-30*time.Minute))+c3 := createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Newest", 2, 0, time.Now().Add(-5*time.Minute))+postURI := createTestPost(t, db, testCommunity, testUser.DID, "Pagination Test", 0, time.Now())+assert.False(t, page1URIs[tv.Comment.URI], "Comment %s should not appear in both pages", tv.Comment.URI)+postURI := createTestPost(t, db, testCommunity, testUser.DID, "Empty Thread Test", 0, time.Now())+postURI := createTestPost(t, db, testCommunity, testUser.DID, "Deleted Comments Test", 0, time.Now())+postURI := createTestPost(t, db, testCommunity, testUser.DID, "HTTP Handler Test", 0, time.Now())+createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 1", 5, 0, time.Now().Add(-30*time.Minute))+createTestCommentWithScore(t, db, testUser.DID, postURI, postURI, "Test comment 2", 3, 0, time.Now().Add(-15*time.Minute))+req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&sort=hot&depth=10&limit=50", postURI), nil)+req := httptest.NewRequest("GET", fmt.Sprintf("/xrpc/social.coves.feed.getComments?post=%s&depth=invalid", postURI), nil)+func createTestCommentWithScore(t *testing.T, db *sql.DB, commenterDID, rootURI, parentURI, content string, upvotes, downvotes int, createdAt time.Time) string {+func (s *testCommentServiceAdapter) GetComments(r *http.Request, req *testGetCommentsRequest) (*comments.GetCommentsResponse, error) {
+3
-6
tests/integration/community_consumer_test.go
+3
-6
tests/integration/community_consumer_test.go
···
-12
tests/integration/user_test.go
-12
tests/integration/user_test.go
···-func setupIdentityResolver(db *sql.DB) interface{ Resolve(context.Context, string) (*identity.Identity, error) } {
+6
-6
tests/lexicon_validation_test.go
+6
-6
tests/lexicon_validation_test.go
···
···